Преглед изворни кода

feat(bot): 结构化人设(一句话职责/反任务/语气/行动偏好)注入提示词

Samien пре 1 месец
родитељ
комит
4a9d230899

+ 36 - 3
src/client/components/workspace/BotProfileDialog.vue

@@ -10,7 +10,7 @@ import {apiRequest} from '@/api/client'
 import type {WorkspaceAgent, WorkspaceAgentUsage, WorkspaceApprovalPolicy, WorkspaceConversation, WorkspaceSource} from '@shared/workspace'
 import type {Profile} from '@shared/types'
 
-interface Draft {name:string;avatar:string;instructions:string;source:string;canManageTeam:boolean;canCollaborate:boolean;memoryEnabled:boolean;approvalPolicy?:WorkspaceApprovalPolicy}
+interface Draft {name:string;avatar:string;instructions:string;job?:string;antiJobs?:string[];voice?:''|'concise'|'casual'|'rigorous'|'custom';voiceCustom?:string;actBias?:''|'ask_first'|'ask_key_then_act'|'act_now';source:string;canManageTeam:boolean;canCollaborate:boolean;memoryEnabled:boolean;approvalPolicy?:WorkspaceApprovalPolicy}
 type Page='overview'|'identity'|'rules'|'usage'|'models'|'memory'|'permissions'
 const props=defineProps<{agent:WorkspaceAgent;draft:Draft;sources:WorkspaceSource[];profiles:Profile[];agents:WorkspaceAgent[];conversations:WorkspaceConversation[];conversationId?:string;isAdmin?:boolean;knowledgeEnabled?:boolean;busy?:boolean;error?:string}>()
 const emit=defineEmits<{close:[];save:[draft:Draft & {approvalPolicy:WorkspaceApprovalPolicy}];changed:[]}>()
@@ -25,7 +25,14 @@ onMounted(async()=>{
   finally{usageLoading.value=false}
 })
 const readOnly=computed(()=>!!props.agent.remoteAgentId)
-const snapshot=()=>({name:draft.name,avatar:draft.avatar,instructions:draft.instructions,source:draft.source,canManageTeam:draft.canManageTeam,canCollaborate:draft.canCollaborate,memoryEnabled:draft.memoryEnabled,approvalPolicy:draft.approvalPolicy})
+const antiDraft=ref('')
+function addAntiJob(){
+ const value=antiDraft.value.trim()
+ if(value&&!(draft.antiJobs??[]).includes(value)&&(draft.antiJobs??[]).length<8)draft.antiJobs=[...(draft.antiJobs??[]),value]
+ antiDraft.value=''
+}
+function removeAntiJob(value:string){draft.antiJobs=(draft.antiJobs??[]).filter(item=>item!==value)}
+const snapshot=()=>({name:draft.name,avatar:draft.avatar,instructions:draft.instructions,job:draft.job,antiJobs:draft.antiJobs,voice:draft.voice,voiceCustom:draft.voiceCustom,actBias:draft.actBias,source:draft.source,canManageTeam:draft.canManageTeam,canCollaborate:draft.canCollaborate,memoryEnabled:draft.memoryEnabled,approvalPolicy:draft.approvalPolicy})
 const baseline=JSON.stringify(snapshot())
 const dirty=computed(()=>JSON.stringify(snapshot())!==baseline)
 const sourceKey=(source:Pick<WorkspaceSource,'nodeId'|'profile'>)=>JSON.stringify([source.nodeId,source.profile])
@@ -85,7 +92,22 @@ function submit(){
             <section class="profile-card"><h4>运行与协作</h4><dl><div><dt>基础机器人</dt><dd>{{sourceLabel}} / {{sourceProfile}}</dd></div><div><dt>执行环境</dt><dd>{{environment}}</dd></div><div><dt>Token 用量</dt><dd>{{usageLoading?'读取中…':usage?`今日 ${formatTokens(usage.todayUsage.total)} · 本月 ${formatTokens(usage.monthUsage.total)}`:'暂无统计'}}</dd></div><div><dt>Bot 协作</dt><dd>{{draft.canCollaborate?'已允许':'未开启'}}</dd></div><div><dt>组建团队</dt><dd>{{draft.canManageTeam?'已允许':'未开启'}}</dd></div></dl><button type="button" @click="selectPage('usage')">查看 Token 用量</button></section>
           </template>
           <form v-else-if="page==='identity'" class="profile-form" @submit.prevent="submit"><label>名称<input v-model="draft.name" required maxlength="100" :readonly="readOnly" :disabled="busy"/></label><AgentIdentityPanel v-if="!readOnly" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" :busy="busy" @avatar-change="draft.avatar=$event"/><AgentAvatar v-else :name="draft.name" :avatar="draft.avatar" :size="96"/></form>
-          <form v-else-if="page==='rules'" class="profile-form" @submit.prevent="submit"><label>角色提示词与规则<textarea v-model="draft.instructions" rows="12" maxlength="24000" :readonly="readOnly" :disabled="busy" placeholder="描述这个 Bot 的职责、回答方式和工作规则。"/></label><p class="profile-note">这些规则属于当前 Bot,会在它的单聊和群聊中使用。</p></form>
+          <form v-else-if="page==='rules'" class="profile-form" @submit.prevent="submit">
+          <label>一句话职责<input v-model="draft.job" maxlength="120" :readonly="readOnly" :disabled="busy" placeholder="这个 Bot 只做的一件事,例如:盯家庭邮箱和日程,表单、账单、RSVP 不漏"/></label>
+          <fieldset class="persona-field"><legend>反任务(明确不做的事)</legend>
+            <ul v-if="draft.antiJobs?.length" class="anti-list"><li v-for="item in draft.antiJobs" :key="item"><span>{{ item }}</span><button type="button" :disabled="busy||readOnly" @click="removeAntiJob(item)" :aria-label="`移除 ${item}`"><AppIcon name="close" :size="12"/></button></li></ul>
+            <div class="anti-input"><input v-model="antiDraft" maxlength="120" :readonly="readOnly" :disabled="busy||(draft.antiJobs?.length??0)>=8" placeholder="例如:绝不直接发送邮件或提交表单" @keydown.enter.prevent="addAntiJob"/><button type="button" :disabled="busy||readOnly||!antiDraft.trim()" @click="addAntiJob">添加</button></div>
+          </fieldset>
+          <label>语气<select v-model="draft.voice" :disabled="busy||readOnly"><option value="">未指定</option><option value="concise">简洁专业</option><option value="casual">轻松随和</option><option value="rigorous">严谨细致</option><option value="custom">自定义</option></select></label>
+          <label v-if="draft.voice==='custom'">自定义语气<textarea v-model="draft.voiceCustom" rows="2" maxlength="200" :readonly="readOnly" :disabled="busy" placeholder="用一句话描述语气,例如:随意一点,像疯狂科学家,多用短句。"/></label>
+          <fieldset class="persona-field"><legend>行动偏好</legend>
+            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="" :disabled="busy||readOnly"/><span>未指定<small>由模型自行判断。</small></span></label>
+            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="ask_first" :disabled="busy||readOnly"/><span>先问再做<small>任务不明确先澄清,确认后执行。</small></span></label>
+            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="ask_key_then_act" :disabled="busy||readOnly"/><span>问关键问题即行动<small>最多问 2–3 个关键问题,职责清楚就开工。</small></span></label>
+            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="act_now" :disabled="busy||readOnly"/><span>直接行动<small>合理默认即开工,重要分叉才询问。</small></span></label>
+          </fieldset>
+          <details class="persona-advanced"><summary>高级:完整提示词</summary><label>角色提示词与规则<textarea v-model="draft.instructions" rows="10" maxlength="24000" :readonly="readOnly" :disabled="busy" placeholder="描述这个 Bot 的职责、回答方式和工作规则。"/></label><p class="profile-note">结构化规范会自动插入在完整提示词之前;这些规则属于当前 Bot,会在它的单聊和群聊中使用。</p></details>
+        </form>
           <template v-else-if="page==='usage'">
             <p v-if="usageError" class="profile-note">{{usageError}}</p>
             <section v-if="usage" class="profile-card">
@@ -135,4 +157,15 @@ function submit(){
 .bot-profile__sidebar{min-width:0}@media(max-width:600px){.bot-profile{grid-template-columns:minmax(0,1fr)}}
 .usage-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.usage-stat{display:grid;gap:6px;padding:14px;border:1px solid var(--line);border-radius:10px;background:var(--surface)}.usage-stat small{color:var(--text-muted);font-size:11px}.usage-stat strong{font-size:20px;font-weight:650;font-variant-numeric:tabular-nums}.usage-stat span{color:var(--text-secondary);font-size:11px}.usage-table{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}.usage-table th{color:var(--text-muted);font-weight:500;text-align:right;padding:6px 4px;border-bottom:1px solid var(--line)}.usage-table th:first-child,.usage-table td:first-child{text-align:left}.usage-table td{padding:7px 4px;border-bottom:1px solid var(--line);text-align:right}.usage-table td:first-child{color:var(--text-secondary)}
 .profile-source .profile-source__picker{position:relative;min-height:48px;padding:6px 8px;margin:0;border:1px solid var(--line);border-radius:8px;background:var(--settings-panel);box-sizing:border-box}.profile-source__identity{flex:1}.profile-source__picker>select{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.profile-source__picker:focus-within{outline:2px solid var(--accent);outline-offset:2px}@media(max-width:600px){.profile-source{display:block}.profile-source .profile-source__picker{max-width:100%}}
+.persona-field{display:grid;gap:8px;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin:0}
+.persona-field legend{font-size:12.5px;color:var(--text-secondary);padding:0 4px}
+.anti-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
+.anti-list li{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}
+.anti-list li button{border:0;background:transparent;min-height:24px;padding:2px;cursor:pointer;color:var(--text-muted)}
+.anti-input{display:flex;gap:8px}
+.anti-input input{flex:1}
+.radio-row{display:flex;align-items:flex-start;gap:8px}
+.radio-row input{width:16px;height:16px;margin-top:3px;accent-color:var(--accent);flex:none}
+.radio-row span{display:grid;gap:2px}
+.persona-advanced summary{cursor:pointer;font-size:13px;color:var(--text-secondary);margin-bottom:8px}
 </style>

+ 1 - 1
src/client/components/workspace/viewModels.ts

@@ -447,7 +447,7 @@ export function workspaceAgentActivity(conversations: import('@shared/workspace'
 export function workspaceConversationItem(c: import('@shared/workspace').WorkspaceConversation, agents: import('@shared/workspace').WorkspaceAgent[], agentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>): SidebarItem {
   const activity = c.kind === 'direct' ? agentActivity?.get(c.memberIds[0] || '') : undefined
   return {
-    id: c.id, title: c.name, subtitle: c.preview || '开始聊天', pinned: c.pinned,
+    id: c.id, title: c.name, subtitle: c.preview || (c.kind === 'direct' ? agents.find(agent => agent.id === c.memberIds[0])?.job || '' : '') || '开始聊天', pinned: c.pinned,
     section: c.pinned ? '已置顶' : '聊天', avatar: c.kind === 'group' ? '' : c.avatar,
     avatarMembers: c.kind === 'group' ? workspaceAvatarMembers(c.memberIds, agents, c) : [],
     meta: formatConversationTime(workspaceConversationTimestamp(c)),

+ 13 - 4
src/client/views/ConversationsView.vue

@@ -190,6 +190,11 @@ const form = reactive({
   name: '',
   avatar: '',
   instructions: '',
+  job: '',
+  antiJobs: [] as string[],
+  voice: '' as ''|'concise'|'casual'|'rigorous'|'custom',
+  voiceCustom: '',
+  actBias: '' as ''|'ask_first'|'ask_key_then_act'|'act_now',
   execution: 'profile' as 'profile'|'computer',
   canManageTeam: false,
   canCollaborate: true,
@@ -216,7 +221,7 @@ function choosePreset(preset?: TeamPreset) {
   const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
   if (preset && available.length < preset.roles.length) return
   selectedPresetId.value = preset?.id ?? 'custom'
-  Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: knowledgeEnabled.value ? 'discussion' : 'host', autoReplyIds: [], maxReplyRounds: 3 })
+  Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', job: '', antiJobs: [], voice: '', voiceCustom: '', actBias: '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: knowledgeEnabled.value ? 'discussion' : 'host', autoReplyIds: [], maxReplyRounds: 3 })
   if (preset) applyPresetRoles(preset)
 }
 function assignPresetRole(index: number, id: string) {
@@ -601,6 +606,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>, conversation =
       const a = agents.value.find(a => a.id === conversation?.memberIds[0])
       if (!a) throw new Error('机器人不存在,请刷新后重试')
       Object.assign(form, a)
+      form.job ??= ''; form.antiJobs ??= []; form.voice ??= ''; form.voiceCustom ??= ''; form.actBias ??= ''
       form.source=JSON.stringify([a.nodeId,a.profile])
       if(a.remoteAgentId){
         const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
@@ -630,8 +636,9 @@ function closeDialog() {
   dialog.value = null
   editingConversationId.value = ''
 }
-function saveBotProfile(draft: Pick<typeof form, 'name'|'avatar'|'instructions'|'source'|'canManageTeam'|'canCollaborate'|'memoryEnabled'|'approvalPolicy'>) {
+function saveBotProfile(draft: Pick<typeof form, 'name'|'avatar'|'instructions'|'source'|'canManageTeam'|'canCollaborate'|'memoryEnabled'|'approvalPolicy'> & Partial<Pick<typeof form, 'job'|'antiJobs'|'voice'|'voiceCustom'|'actBias'>>) {
   Object.assign(form, draft)
+  form.job ??= ''; form.antiJobs ??= []; form.voice ??= ''; form.voiceCustom ??= ''; form.actBias ??= ''
   void save()
 }
 watch(
@@ -647,13 +654,15 @@ async function save() {
   error.value = ''
   try {
     const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, approvalPolicy: form.approvalPolicy, execution:form.execution, ...(knowledgeEnabled.value ? { canCollaborate: form.canCollaborate, memoryEnabled: form.memoryEnabled } : {}) } : {}), instructions: form.instructions }
+    const personaClears = { job: form.job.trim() ? form.job.trim() : null, antiJobs: form.antiJobs.length ? form.antiJobs : null, voice: form.voice || null, voiceCustom: form.voice === 'custom' && form.voiceCustom.trim() ? form.voiceCustom : null, actBias: form.actBias || null }
+    const personaCreates = Object.fromEntries(Object.entries(personaClears).filter(([, value]) => value !== null && value !== undefined))
     if (isRemoteAgent.value) {
       await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { approvalPolicy: form.approvalPolicy } })
     } else if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
         method: 'POST',
-        body: { ...fields, nodeId, profile, computer: 'auto' },
+        body: { ...fields, ...personaCreates, nodeId, profile, computer: 'auto' },
       })
       await refresh()
       const c = conversations.value.find(
@@ -661,7 +670,7 @@ async function save() {
       )
       if (c) await select(c.id)
     } else if (dialog.value === 'editAgent')
-      await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { ...fields, ...(form.source?{nodeId:JSON.parse(form.source)[0],profile:JSON.parse(form.source)[1]}:{}) } })
+      await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { ...fields, ...personaClears, ...(form.source?{nodeId:JSON.parse(form.source)[0],profile:JSON.parse(form.source)[1]}:{}) } })
     else {
       const payload = {
         ...fields,

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -130,7 +130,7 @@ export function workspaceRouter(
   router.patch('/api/app/agents/:id', async (ctx) => {
     const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = parse(agentPatch,body(ctx))
     const authorization = auth.pushAuthorizationVersion(user)
-    const updated={...agent,...input}
+    const updated:WorkspaceAgent={...agent,...input,job:input.job??undefined,antiJobs:input.antiJobs??undefined,voice:input.voice??undefined,voiceCustom:input.voiceCustom??undefined,actBias:input.actBias??undefined}
     const sourceChanged=updated.nodeId!==agent.nodeId||updated.profile!==agent.profile
     nodes.requireSource(user,updated)
     if(sourceChanged){

+ 2 - 1
src/server/workspaceRuntime.ts

@@ -17,6 +17,7 @@ import type {
   WorkspaceInteraction,
   WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
+import { personaSection } from '../shared/workspace.js'
 
 import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT, RESOURCE_WAIT_CODES } from './workspaceScheduler.js'
 import type { WorkspacePlugins, OpenBotPlugins } from './botPlugins/workspacePlugins.js'
@@ -747,7 +748,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const goal = run.conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', run.conversationTaskId) : undefined
         const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
         const rules = [
-          `你是 ${agent.name}。以下是用户为这个独立机器人配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端机器人管理。' : agent.instructions}`,
+          `你是 ${agent.name}。${personaSection(agent) ? `用户为它设定的工作规范:\n${personaSection(agent)}\n\n` : ''}以下是用户为这个独立机器人配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端机器人管理。' : agent.instructions}`,
           c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
             ? `本群角色分工(仅在本群生效):\n${members.flatMap(member => {
                 const role = c.memberRoles?.[member.id]

+ 23 - 1
src/server/workspaceStore.ts

@@ -36,6 +36,13 @@ const avatar = z
   )
 const taskTitle = z.string().trim().min(1).max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符')
 const envsInput = z.object({ vm: z.boolean().optional(), cloud: z.boolean().optional(), desktop: z.boolean().optional(), browser: z.boolean().optional() }).strict()
+const personaInput = {
+  job: z.string().trim().max(120).optional(),
+  antiJobs: z.array(z.string().trim().min(1).max(120)).max(8).optional(),
+  voice: z.enum(['concise', 'casual', 'rigorous', 'custom']).optional(),
+  voiceCustom: z.string().max(200).optional(),
+  actBias: z.enum(['ask_first', 'ask_key_then_act', 'act_now']).optional(),
+}
 export const agentInput = z
   .object({
     name,
@@ -52,6 +59,7 @@ export const agentInput = z
     canCollaborate: z.boolean().default(true),
     memoryEnabled: z.boolean().default(true),
     approvalPolicy: z.enum(['ask', 'allow', 'deny']).default('ask'),
+    ...personaInput,
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
   })
@@ -74,6 +82,11 @@ export const agentPatch = z
     canCollaborate: z.boolean().optional(),
     memoryEnabled: z.boolean().optional(),
     approvalPolicy: z.enum(['ask', 'allow', 'deny']).optional(),
+    job: z.string().trim().max(120).nullable().optional(),
+    antiJobs: z.array(z.string().trim().min(1).max(120)).max(8).nullable().optional(),
+    voice: z.enum(['concise', 'casual', 'rigorous', 'custom']).nullable().optional(),
+    voiceCustom: z.string().max(200).nullable().optional(),
+    actBias: z.enum(['ask_first', 'ask_key_then_act', 'act_now']).nullable().optional(),
     archived: z.boolean().optional(),
   })
   .strict()
@@ -444,7 +457,16 @@ export class WorkspaceStore {
     })
   }
   updateAgent(owner: string, id: string, input: unknown): Agent {
-    const patch = parse(agentPatch, input)
+    // Persona fields use null from clients to clear; normalizing with an
+    // always-present key keeps the clear semantics when spreading over the record.
+    const { job, antiJobs, voice, voiceCustom, actBias, ...rest } = parse(agentPatch, input)
+    const patch: typeof rest & Partial<Pick<Agent, 'job' | 'antiJobs' | 'voice' | 'voiceCustom' | 'actBias'>> = rest
+    // Only materialize keys the request actually carried; null clears the field.
+    if (job !== undefined) patch.job = job ?? undefined
+    if (antiJobs !== undefined) patch.antiJobs = antiJobs ?? undefined
+    if (voice !== undefined) patch.voice = voice ?? undefined
+    if (voiceCustom !== undefined) patch.voiceCustom = voiceCustom ?? undefined
+    if (actBias !== undefined) patch.actBias = actBias ?? undefined
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     if (patch.desktopHost === null) patch.desktopHost = undefined
     return this.atomic(() => {

+ 21 - 0
src/shared/workspace.ts

@@ -1,6 +1,15 @@
 /** Application-owned identities. Hermes profile/session IDs never identify a chat. */
 export type WorkspaceApprovalPolicy = 'ask' | 'allow' | 'deny'
+export type WorkspaceVoice='concise'|'casual'|'rigorous'|'custom'
+export type WorkspaceActBias='ask_first'|'ask_key_then_act'|'act_now'
 export interface WorkspaceAgent {
+  /** 一句话职责:这个 Bot 只做的一件事。 */
+  job?: string
+  /** 反任务清单:任何情况下都不做的事。 */
+  antiJobs?: string[]
+  voice?: WorkspaceVoice
+  voiceCustom?: string
+  actBias?: WorkspaceActBias
   /** Applied to new upstream approval requests; absent on older Bots means ask. */
   approvalPolicy?: WorkspaceApprovalPolicy
   canCollaborate?: boolean
@@ -58,6 +67,18 @@ export function agentEnvs(agent: Pick<WorkspaceAgent,'computer'|'envs'> & {execu
 export function deriveComputer(envs: Pick<AgentEnvs,'vm'|'cloud'|'desktop'|'browser'>): 'vm'|'cloud'|'local'|'browser'|'off' {
   return envs.vm?'vm':envs.cloud?'cloud':envs.desktop?'local':envs.browser?'browser':'off'
 }
+const VOICE_TEXT:Record<WorkspaceVoice,string>={concise:'简洁专业:直击要点,不用客套与废话。',casual:'轻松随和:像朋友一样自然交流,可以适度幽默。',rigorous:'严谨细致:条理清晰,重要结论给出依据,主动指出风险与前提。',custom:''}
+const ACT_BIAS_TEXT:Record<WorkspaceActBias,string>={ask_first:'先问再做:任务不明确时先向用户澄清,确认后再执行。',ask_key_then_act:'先问关键问题即行动:最多问 2–3 个关键问题,职责清楚后立即开始,边做边汇报。',act_now:'直接行动:合理默认即可开工,只在重要分叉点询问。'}
+/** Structured persona spec injected before free-form instructions. */
+export function personaSection(agent:Pick<WorkspaceAgent,'job'|'antiJobs'|'voice'|'voiceCustom'|'actBias'>):string{
+ const lines:string[]=[]
+ if(agent.job)lines.push(`你的唯一职责:${agent.job}`)
+ if(agent.antiJobs?.length)lines.push(`明确不做(反任务,任何情况下都不要做):\n${agent.antiJobs.map(item=>`- ${item}`).join('\n')}`)
+ const voice=agent.voice?(agent.voice==='custom'?(agent.voiceCustom?`按以下要求把握语气:${agent.voiceCustom}`:''):VOICE_TEXT[agent.voice]):''
+ if(voice)lines.push(`语气:${voice}`)
+ if(agent.actBias)lines.push(`行动策略:${ACT_BIAS_TEXT[agent.actBias]}`)
+ return lines.join('\n')
+}
 export function supportsHostEnvironment(agent: Pick<WorkspaceAgent,'computer'|'execution'>):boolean {
   return agent.computer==='vm'||agent.computer==='cloud'||(!agent.computer&&agent.execution==='computer')
 }

+ 35 - 0
tests/server/personaSection.test.ts

@@ -0,0 +1,35 @@
+import {it,expect} from 'vitest'
+import {personaSection} from '../../src/shared/workspace'
+const base={job:undefined,antiJobs:undefined,voice:undefined,voiceCustom:undefined,actBias:undefined}
+it('returns empty when no persona field is set',()=>{
+ expect(personaSection(base)).toBe('')
+})
+it('composes job, anti-jobs, preset voice and act bias',()=>{
+ const text=personaSection({...base,job:'盯家庭邮箱和日程',antiJobs:['绝不直接发送邮件','绝不提交表单'],voice:'concise',actBias:'ask_key_then_act'})
+ expect(text).toContain('你的唯一职责:盯家庭邮箱和日程')
+ expect(text).toContain('- 绝不直接发送邮件')
+ expect(text).toContain('语气:简洁专业')
+ expect(text).toContain('行动策略:先问关键问题即行动')
+ expect(text.indexOf('唯一职责')).toBeLessThan(text.indexOf('明确不做'))
+ expect(text.indexOf('明确不做')).toBeLessThan(text.indexOf('语气'))
+})
+it('uses the custom voice text only when provided',()=>{
+ expect(personaSection({...base,voice:'custom'})).toBe('')
+ expect(personaSection({...base,voice:'custom',voiceCustom:'像朋友一样随意'})).toContain('按以下要求把握语气:像朋友一样随意')
+})
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+it('persists persona fields and clears them via null patches',()=>{
+ const home=mkdtempSync(join(tmpdir(),'persona-'))
+ const store=new WorkspaceStore(home)
+ try{
+  const agent=store.createAgent('owner',{name:'管家',instructions:'',profile:'default',job:'盯家庭日程',antiJobs:['绝不直接发送邮件'],voice:'casual',actBias:'ask_first'})
+  expect(agent.job).toBe('盯家庭日程');expect(agent.antiJobs).toEqual(['绝不直接发送邮件'])
+  const patched=store.updateAgent('owner',agent.id,{job:null,antiJobs:null,voice:null,voiceCustom:null,actBias:'act_now'})
+  expect(patched.job).toBeUndefined();expect(patched.antiJobs).toBeUndefined();expect(patched.voice).toBeUndefined();expect(patched.actBias).toBe('act_now')
+  const reloaded=store.require<import('../../src/shared/workspace').WorkspaceAgent>('owner','agent',agent.id)
+  expect(reloaded.job).toBeUndefined();expect(reloaded.antiJobs).toBeUndefined();expect(reloaded.actBias).toBe('act_now')
+ }finally{store.close();rmSync(home,{recursive:true,force:true})}
+})