Forráskód Böngészése

修复历史工具导致输入动画残留与虚拟机状态误报

Samien 1 hónapja
szülő
commit
63922d9

+ 22 - 0
docs/verification/2026-09-20-chat-status/README.md

@@ -0,0 +1,22 @@
+# Bot 输入动画与电脑状态提示修复验证
+
+在本地服务 `http://127.0.0.1:15300` 的既有「瑶儿」单聊验证,基础 Hermes 为 `server`。
+
+## 原因与修复
+
+- 9 月 3 日的一条历史消息和所属运行均已完成,但匿名工具事件残留 `tool.generating`。页面扫描全部历史工具,将这条旧记录当成仍在输出,导致最新回复完成后一直显示「机器人正在输入」。现在以消息终态为准;旧工具在展示时收束状态,真实失败的工具结果仍显示失败,不改写原始聊天记录。
+- 本机记录的「本机虚拟机」Runner 已停用。原代码将节点不存在、停用、未连接以及能力不支持统一报告为需要更新 Runner。现在区分未启用、离线和能力不兼容;虚拟机问题显示在虚拟机区域。服务器画面和权限状态继续独立显示。未启用或升级虚拟机 Runner。
+
+## 验证
+
+- 相关 7 个测试文件、76 项全部通过:workspaceTranscript、workspacePresentation、typingIndicator、computerTargetSelection、localVmImages、runner、localVmRoutes。
+- `npm run typecheck`、`npm run desktop:build`、`git diff --check` 通过。
+- 本机服务经内置备份与升级流程更新到 0.4.50、构建 528;构建摘要为 `b28bae519cd3be71135f74528c25cfc29ff441af41f7a5980646682acbf61ed7`。
+- 更新前浏览器 DOM 有 1 个输入指示器;刷新加载修复后为 0,旧工具记录保留。
+- 同一对话真实调用 GPT-5.6-terra:5.70 秒返回「验证G:42」,完成后指示器为 0。
+- 切换 GLM-5.3:3.85 秒返回「青竹4729;64」,保留上下文,完成后指示器为 0。
+- 实际模型由 `session.info` 和 `message.complete.usage.model` 双重核对,脱敏记录见 [runs.jsonl](runs.jsonl)。
+- 服务器预览正常;预览下方没有全局错误提示,虚拟机区域准确显示「本地虚拟机执行节点尚未启用,请在本地虚拟机设置中完成配置」。
+- Bot 的 Provider、模型、思考等级、速度均恢复继承,未结束的运行数为 0。本地服务继续运行。
+
+之前跨 Provider 测试的 `Model is unavailable` 是上游错误,历史失败记录仍保留。本次没有将该模型报告为已恢复。

+ 2 - 0
docs/verification/2026-09-20-chat-status/runs.jsonl

@@ -0,0 +1,2 @@
+{"runId": "1eb07350-37cf-4763-a529-0b482fb83318", "status": "complete", "durationSeconds": 5.7, "settings": {"revision": 43, "settings": {"provider": null, "model": null, "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "custom:tingly", "model": "GPT-5.6-terra", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "GPT-5.6-terra", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "GPT-5.6-terra", "messages": [{"role": "user", "content": "聊天状态修复验证 G:不要调用工具。计算 19 + 23,只回复“验证G:42”。", "status": "complete", "error": null}, {"role": "assistant", "content": "验证G:42", "status": "complete", "error": null}]}
+{"runId": "adc39fd3-e0ee-49a2-a30d-df97fe13aef4", "status": "complete", "durationSeconds": 3.85, "settings": {"revision": 44, "settings": {"provider": "custom:tingly", "model": "GLM-5.3", "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "custom:tingly", "model": "GLM-5.3", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "GLM-5.3", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "GLM-5.3", "messages": [{"role": "user", "content": "聊天状态修复验证 H:不要调用工具。请说出最初的测试口令,并计算 8 × 8,只按“口令;结果”回复。", "status": "complete", "error": null}, {"role": "assistant", "content": "青竹4729;64", "status": "complete", "error": null}]}

+ 3 - 1
src/client/components/workspace/LocalVmChatPanel.vue

@@ -39,6 +39,7 @@ const previewEmptyText=computed(()=>{
  if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return !host?.online?'这台电脑已离线':host.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
  if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
  if(!state.value?.enabled)return '未使用电脑'
+ if(state.value.problem)return state.value.problem
  if(!state.value.image)return '本地虚拟机尚未准备'
  if(state.value.container==='missing')return '尚未创建虚拟机'
  if(state.value.container==='stopped')return '虚拟机已停止'
@@ -121,7 +122,7 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
      <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':previewLoading?'正在读取所选电脑画面…':previewEmptyText}}</span></span>
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
-    <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
+    <p v-if="error" class="problem" role="alert">{{error}}</p>
     <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
     <div class="computer-options">
     <template v-if="showDesktop">
@@ -144,6 +145,7 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
     </template>
     <template v-if="showVm&&state">
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
+     <p v-if="state.problem" class="problem" role="alert" aria-label="本地虚拟机状态">{{state.problem}}</p>
      <template v-if="!state.fixedCapacity&&state.images?.length&&!agent.temporaryGoalId">
       <label>虚拟机镜像<select :value="state.imageKey??''" :disabled="busy||state.inUse||state.container!=='missing'" @change="run(()=>apiRequest(base()+'/image',{method:'PUT',body:{imageKey:($event.target as HTMLSelectElement).value}}))"><option v-if="!state.imageKey" value="" disabled>{{state.image?'当前保留的镜像':'请选择已准备的镜像'}}</option><option v-for="image in state.images" :key="image.key" :value="image.key" :disabled="!image.ready">{{image.name}}{{image.ready?'':' · 未准备'}}</option></select></label>
       <p class="hint">{{state.mode==='shared'?'同一共享桌面只能使用一个镜像,选择会对所有共享成员生效。':'此桌面的镜像、文件和浏览器资料与其他独立桌面分开。'}}{{state.container!=='missing'?'切换镜像前请先移除实例。':''}}</p>

+ 13 - 6
src/client/components/workspace/viewModels.ts

@@ -1,6 +1,6 @@
 import { visibleMessageText, messageReasoningText } from '@shared/messageFiles'
 import { workspaceConversationTimestamp, workspaceHasUnread } from '@shared/workspace'
-import { toolResultFailed } from '@shared/chatTools'
+import { toolResultFailed, toolStatus } from '@shared/chatTools'
 import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
 import type {
   ApprovalRequest,
@@ -399,11 +399,18 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
       kind: file.mimeType.startsWith('image/') ? 'image' : file.mimeType.startsWith('video/') ? 'video' : file.mimeType.startsWith('audio/') ? 'audio' : 'file',
       url: `/api/app/files/${file.id}/${file.mimeType.startsWith('image/') ? 'preview' : 'download'}`,
     })),
-    tools: message.tools.map((tool, index) => ({
-      id: String(tool.id || index), name: String(tool.name || tool.tool_name || '工具'),
-      status: String(tool.status).includes('error') ? 'error' : String(tool.status).includes('complete') ? 'success' : 'running',
-      input: tool.arguments ?? tool.input, output: tool.result ?? tool.output,
-    })),
+    tools: message.tools.map((tool, index) => {
+      let status = toolStatus(tool.status, tool.result ?? tool.output, tool.error)
+      // Match runtime finalization for older messages whose tools were not settled.
+      if (status === 'running' && ['complete', 'failed', 'interrupted'].includes(message.status)) {
+        status = message.status === 'complete' ? 'completed' : message.status === 'failed' ? 'failed' : 'interrupted'
+      }
+      return {
+        id: String(tool.id || index), name: String(tool.name || tool.tool_name || '工具'),
+        status: status === 'completed' ? 'success' : status === 'failed' ? 'error' : status,
+        input: tool.arguments ?? tool.args ?? tool.input, output: tool.error ?? tool.result ?? tool.output,
+      }
+    }),
   }))
 }
 

+ 4 - 1
src/server/runnerHub.ts

@@ -201,7 +201,10 @@ export class RunnerHub {
   }
   computerRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){
     const record=this.records().find(record=>record.enabled&&record.sourceNodeId===agent.nodeId&&(record.sourceOwner==='_system'||record.sourceOwner===owner))
-    if(!record||!this.online.get(record.id)?.features.includes('computer-control-v1'))throw new HttpError(409,'执行节点不支持电脑查看与控制,请更新 Runner','computer_control_unavailable')
+    if(!record)throw new HttpError(409,'本地虚拟机执行节点尚未启用,请在本地虚拟机设置中完成配置','computer_control_unavailable')
+    const online=this.online.get(record.id)
+    if(!online)throw new HttpError(503,'本地虚拟机执行节点未连接,请检查 Runner 是否运行','runner_offline')
+    if(!online.features.includes('computer-control-v1'))throw new HttpError(409,'本地虚拟机执行节点不支持电脑查看与控制,请启用隔离电脑 Worker 或更新 Runner','computer_control_unavailable')
     if(this.composeDesktops.desktops.length&&!this.online.get(record.id)?.features.includes('compose-desktops-v1'))throw new HttpError(409,'当前部署需要 Compose 桌面执行节点','compose_runner_required')
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'电脑 Profile 未授权','computer_profile_forbidden')
     return record

+ 3 - 8
src/shared/workspace.ts

@@ -207,15 +207,10 @@ export interface WorkspaceMessage {
 }
 
 export function workspaceHasUnfinishedOutput(messages: WorkspaceMessage[]): boolean {
-  const activeStatusParts = ['queued', 'start', 'started', 'running', 'pending', 'progress', 'generating', 'waiting', 'uncertain']
+  // Legacy transcripts may retain tool.generating after the turn has settled.
+  // A terminal message status is authoritative over those stale tool events.
   return messages.some(message => message.visible !== false && message.role === 'assistant'
-    && (
-      ['queued', 'streaming', 'uncertain'].includes(message.status)
-      || message.tools.some(tool => {
-        const status = typeof tool.status === 'string' ? tool.status.toLowerCase() : ''
-        return activeStatusParts.some(part => status.includes(part))
-      })
-    ))
+    && ['queued', 'streaming', 'uncertain'].includes(message.status))
 }
 export interface WorkspaceMessagePatch {
   id: string

+ 20 - 2
tests/client/computerTargetSelection.test.ts

@@ -10,7 +10,7 @@ const route={params:{agentId:'bot'},query:{} as Record<string,unknown>}
 vi.mock('vue-router',()=>({useRoute:()=>route}))
 const agent={id:'bot',name:'竹儿',profile:'default',nodeId:'local'} as any
 const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
-let hosts:any[],readFrame:(key:string)=>unknown
+let hosts:any[],readFrame:(key:string)=>unknown,vmProblem:string
 const screenshot=(key:string)=>({id:key,data:key,width:100,height:80,generation:1})
 const stubs={AppIcon:true,GrokAuthPanel:true,BrowserToolbar:true}
 beforeEach(()=>{
@@ -18,6 +18,7 @@ beforeEach(()=>{
  route.query={}
  hosts=[{id:'local',name:'服务器',online:true,local:permission},{id:'client-mac',name:'Mac Studio',online:true,local:permission}]
  readFrame=screenshot
+ vmProblem=''
  vi.mocked(apiRequest).mockImplementation(async(path,options:any)=>{
   const url=new URL(path,'http://fixture'),key=url.searchParams.get('host')||url.searchParams.get('backend')||'unspecified'
   if(url.pathname==='/api/app/agents')return {agents:[agent]} as any
@@ -25,13 +26,30 @@ beforeEach(()=>{
   if(url.pathname.endsWith('/desktop-environment'))return {hosts:hosts.map(h=>({...h}))} as any
   if(url.pathname.endsWith('/cloud-computer'))return {configured:true,running:true,connected:true} as any
   if(url.pathname.endsWith('/cloud-computer/open'))return {} as any
-  if(url.pathname.endsWith('/local-vm'))return {enabled:true,image:true,container:'running',ready:true} as any
+  if(url.pathname.endsWith('/local-vm'))return {enabled:true,image:!vmProblem,container:vmProblem?'missing':'running',ready:!vmProblem,problem:vmProblem} as any
   if(url.pathname.endsWith('/frame'))return await readFrame(key) as any
   return {mode:options?.method==='POST'?'human':'idle',backend:key==='cloud'?'grok':key==='vm'?'docker':'local',generation:1,hostName:key,controlId:'control',token:'fixture-token'} as any
  })
 })
 afterEach(()=>{vi.restoreAllMocks();vi.clearAllMocks()})
 
+it('scopes an unavailable VM to its own settings while the server preview remains usable',async()=>{
+ vmProblem='本地虚拟机执行节点尚未启用,请在本地虚拟机设置中完成配置'
+ const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+ try{
+  await flushPromises()
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,local')
+  expect(wrapper.find('.panel-body > .problem').exists()).toBe(false)
+  expect(wrapper.get('.computer-options [aria-label="本地虚拟机状态"]').text()).toBe(vmProblem)
+  await wrapper.get('.preview').trigger('click')
+  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'desktop','local'])
+  await wrapper.get('[aria-label="显示的桌面"]').setValue('vm');await flushPromises()
+  expect(wrapper.get('.preview').text()).toContain(vmProblem)
+  expect(wrapper.find('.preview img').exists()).toBe(false)
+  expect(wrapper.get('.preview').attributes('disabled')).toBeDefined()
+ }finally{wrapper.unmount()}
+})
+
 it('refreshes the selected preview immediately and never replaces it with a late frame from another computer',async()=>{
  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  try{

+ 13 - 0
tests/client/workspacePresentation.test.ts

@@ -14,6 +14,19 @@ describe('workspace chat uses the established presentation', () => {
     expect(message?.tools?.[0]).toMatchObject({name:'read_file',status:'success',output:'ok'})
     expect(message?.attachments?.[0]?.url).toBe('/api/app/files/f/download')
   })
+  it.each([
+    ['complete', 'success'], ['failed', 'error'], ['interrupted', 'interrupted'], ['uncertain', 'running'], ['streaming', 'running'],
+  ] as const)('renders stale tools in a %s message without reopening its trace', (status, expected) => {
+    const [message] = workspaceMessagesToUi([{
+      id: 'legacy', conversationId: 'c', seq: 1, role: 'assistant', content: '回复', reasoning: '',
+      status, createdAt: 1, attachments: [], tools: [
+        { id: '', name: 'tool_call', status: 'tool.generating' },
+        { id: 'failed-tool', name: 'read_file', status: 'tool.complete', result: { error: '授权已结束' } },
+      ],
+    }])
+    expect(message?.tools?.[0]?.status).toBe(expected)
+    expect(message?.tools?.[1]?.status).toBe('error')
+  })
   it('keeps peer direction outside the body without presenting it as a user message', () => {
     const communication = { direction: 'incoming' as const, peerId: 'b', peerName: '研究员', peerAvatar: '', peerKind: 'agent' as const, content: '当前为 v2' }
     const [message] = workspaceMessagesToUi([{ id: 'peer', conversationId: 'c', seq: 1, role: 'system', peerMessageId: 'p', communication, content: '来自 Bot「研究员」的回复:当前为 v2', reasoning: '', tools: [], attachments: [], status: 'complete', createdAt: 1 }])

+ 9 - 1
tests/client/workspaceTranscript.test.ts

@@ -61,7 +61,7 @@ describe('Bot transcript cache', () => {
   it('keeps run output active for every executing file-tool phase', () => {
     for (const status of ['tool.start', 'tool.started', 'tool.progress', 'tool.generating', 'running']) {
       expect(workspaceHasUnfinishedOutput([
-        { ...message('file', 3, '文件准备中'), status: 'complete', tools: [{ id: 'file-tool', status }] },
+        { ...message('file', 3, '文件准备中'), status: 'streaming', tools: [{ id: 'file-tool', status }] },
       ])).toBe(true)
     }
     expect(workspaceHasUnfinishedOutput([
@@ -71,6 +71,14 @@ describe('Bot transcript cache', () => {
       { ...message('hidden', 3, '文件准备中'), status: 'complete', visible: false, tools: [{ id: 'file-tool', status: 'tool.generating' }] },
     ])).toBe(false)
   })
+  it.each(['complete', 'failed', 'interrupted'] as const)('does not revive %s history with stale tool events', status => {
+    const old = { ...message('old-tool', 1), status, tools: [{ id: '', status: 'tool.generating' }] }
+    const latest = message('latest-reply', 2)
+    expect(workspaceHasUnfinishedOutput([old, latest])).toBe(false)
+    for (const active of ['queued', 'streaming', 'uncertain'] as const) {
+      expect(workspaceHasUnfinishedOutput([old, { ...latest, status: active }])).toBe(true)
+    }
+  })
   it('does not apply pre-snapshot replay over a newer snapshot', () => {
     const store = new WorkspaceTranscriptStore(), token = store.beginRead()
     store.apply(event(9, message('live', 2, '旧事件')))

+ 12 - 0
tests/server/runner.test.ts

@@ -76,6 +76,18 @@ it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
  const id=randomUUID()
  expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true})).toThrow('不支持同时使用本机和虚拟机')
 })
+it('distinguishes a missing or disabled VM runner, an offline runner and incompatible features',async()=>{
+ const agent=store.createAgent('owner',{name:'虚拟机状态',profile:'default'})
+ expect(()=>hub.computerRunner('owner',agent)).toThrow('请启用隔离电脑 Worker 或更新 Runner')
+ controller.abort();await running
+ const record=hub.records()[0]!
+ hub.remove(record.id)
+ expect(()=>hub.computerRunner('owner',agent)).toThrow('本地虚拟机执行节点尚未启用')
+ store.put('_system','runner',record.id,{...record,enabled:true})
+ expect(()=>hub.computerRunner('owner',agent)).toThrow('本地虚拟机执行节点未连接')
+ store.remove('_system','runner',record.id)
+ expect(()=>hub.computerRunner('owner',agent)).toThrow('本地虚拟机执行节点尚未启用')
+})
 it('rejects Profile collaboration on a Runner that does not advertise the execution backend',()=>{
  const id=randomUUID()
  expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true,profileSession:true})).toThrow('不支持本机协作模式')