Kaynağa Gözat

fix(chat): 等待文件输出完成后再隐藏输入动画

Samien 1 ay önce
ebeveyn
işleme
104cbe94af

+ 2 - 2
src/client/stores/chat.ts

@@ -14,7 +14,7 @@ import { ApiError } from '@/api/client'
 import { encodeAttachment } from '@/utils/attachments'
 import { ScopedCache } from '@/utils/cache'
 import { createId, routeKey } from '@/utils/id'
-import { applyChatEvent, mergeChatMessages, settleChatMessages } from '@/utils/messageReducer'
+import { applyChatEvent, chatHasUnfinishedOutput, mergeChatMessages, settleChatMessages } from '@/utils/messageReducer'
 import { reconcileChatHistory } from '@/utils/chatHistory'
 import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
@@ -162,7 +162,7 @@ export const useChatStore = defineStore('chat', () => {
         latest.liveStatus=value.liveStatus??undefined
         latest.hasMoreBefore=value.hasOlder;latest.historySynced=true;latest.isLoadingHistory=false;latest.error=value.error??undefined
         const wasStreaming=latest.isStreaming
-        latest.isStreaming=value.running??canonical.some(m=>m.isStreaming);latest.isQueued=value.queued??false
+        latest.isStreaming=value.running??canonical.some(m=>m.isStreaming||chatHasUnfinishedOutput([m]));latest.isQueued=value.queued??false
         if(wasStreaming&&!latest.isStreaming)void refreshContextUsage(latest).catch(()=>{})
         if(!latest.isStreaming)latest.liveStatus=undefined
         updateInflightMarker(latest)

+ 22 - 4
src/client/utils/messageReducer.ts

@@ -105,10 +105,17 @@ function lastStreamingAssistant(messages: ChatMessage[], afterLatestUser = false
     .find(message => message.role === 'assistant' && message.isStreaming)
 }
 
-export function settleChatMessages(messages: ChatMessage[], current?: ChatMessage): ChatMessage[] {
+export function chatHasUnfinishedOutput(messages: ChatMessage[]): boolean {
+  return messages.some(message => message.role === 'assistant'
+    && message.toolCalls?.some(tool => ['pending', 'running'].includes(tool.status)))
+}
+
+export function settleChatMessages(messages: ChatMessage[], current?: ChatMessage, interruptUnfinishedTools = true): ChatMessage[] {
   return messages.map(message => message.role === 'assistant' && message !== current
     ? { ...message, stage: message.stage === 'streaming' ? 'settled' : message.stage, isStreaming: false,
-      toolCalls: message.toolCalls?.map(tool => ['running', 'pending'].includes(tool.status) ? { ...tool, status: 'interrupted' } : tool) }
+      toolCalls: message.toolCalls?.map(tool => interruptUnfinishedTools && ['running', 'pending'].includes(tool.status)
+        ? { ...tool, status: 'interrupted' }
+        : tool) }
     : message)
 }
 
@@ -290,8 +297,9 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       const terminalStatus = string(payload.status).trim().toLowerCase()
       const failed = terminalStatus === 'error' || terminalStatus === 'failed' || Boolean(payload.error)
       next.messages = updateStreamingMessage(state, payload, failed ? 'failed' : 'complete')
-      next.messages = settleChatMessages(next.messages)
-      next.isStreaming = false
+      const waitingForOutput = !failed && chatHasUnfinishedOutput(next.messages)
+      next.messages = settleChatMessages(next.messages, undefined, !waitingForOutput)
+      next.isStreaming = waitingForOutput
       next.isQueued = false
       next.liveStatus = undefined
       next.usage = normalizeUsage(payload) ?? next.usage
@@ -328,6 +336,10 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
         break
       }
       const current = lastStreamingAssistant(next.messages)
+        ?? [...next.messages].reverse().find(message => message.role === 'assistant'
+          && (message.toolCalls?.some(tool => tool.id === id)
+            || message.toolCalls?.some(tool => ['pending', 'running'].includes(tool.status))))
+        ?? [...next.messages].reverse().find(message => message.role === 'assistant')
       if (!current) break
       const tool = toolFromEvent(payload, 'running')
       const tools = [...(current.toolCalls ?? [])]
@@ -337,6 +349,7 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
         status: ['completed', 'failed'].includes(tools[index].status) ? tools[index].status : tool.status }
       else tools.push(tool)
       next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
+      next.isStreaming = true
       next.liveStatus = undefined
       break
     }
@@ -354,6 +367,11 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       if (index >= 0) tools[index] = { ...tools[index], ...tool }
       else tools.push(tool)
       next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
+      if (!chatHasUnfinishedOutput(next.messages) && !next.messages.some(message => message.isStreaming)) {
+        next.isStreaming = false
+        next.isQueued = false
+        next.liveStatus = undefined
+      }
       break
     }
     case 'approval.request':

+ 14 - 10
src/client/views/ConversationsView.vue

@@ -45,15 +45,16 @@ import { apiRequest, ApiError } from '@/api/client'
 import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
 import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
 import type { JsonValue } from '@shared/types'
-import type {
-  WorkspaceAgent as Agent,
-  WorkspaceConversation as Conversation,
-  WorkspaceMessage as Message,
-  WorkspaceRun as Run,
-  WorkspaceInteraction as Interaction,
-  WorkspaceSource as Source,
-  WorkspaceFile,
-  WorkspaceEvent,
+import {
+  type WorkspaceAgent as Agent,
+  type WorkspaceConversation as Conversation,
+  type WorkspaceMessage as Message,
+  type WorkspaceRun as Run,
+  type WorkspaceInteraction as Interaction,
+  type WorkspaceSource as Source,
+  type WorkspaceFile,
+  type WorkspaceEvent,
+  workspaceHasUnfinishedOutput,
 } from '@shared/workspace'
 const auth = useAuthStore(),
   theme = useThemeStore(),
@@ -67,6 +68,9 @@ const active = ref<Conversation>(),
   run = ref<Run | null>(null),
   interactions = ref<Interaction[]>([]),
   context = ref<Record<string, unknown> | null>(null)
+const thinking = computed(() =>
+  (!!run.value && ['queued', 'running', 'waiting', 'uncertain'].includes(run.value.status))
+  || workspaceHasUnfinishedOutput(messages.value))
 const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
 const deliveryMode = ref(false)
 const knowledgePanel = ref<InstanceType<typeof WorkspaceKnowledgePanel>>()
@@ -960,7 +964,7 @@ onBeforeUnmount(() => {
       <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
-        :show-tools="showThinking" :allow-branch="false" :thinking="!!run && ['running','waiting','uncertain'].includes(run.status)"
+        :show-tools="showThinking" :allow-branch="false" :thinking="thinking"
         :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
         :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
         :mention-names="members.map(a => a.name)"

+ 12 - 0
src/shared/workspace.ts

@@ -151,6 +151,18 @@ export interface WorkspaceMessage {
   tools: Array<Record<string, unknown>>
   createdAt: number
 }
+
+export function workspaceHasUnfinishedOutput(messages: WorkspaceMessage[]): boolean {
+  const activeStatusParts = ['queued', 'start', 'started', 'running', 'pending', 'progress', 'generating', 'waiting', 'uncertain']
+  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))
+      })
+    ))
+}
 export interface WorkspaceMessagePatch {
   id: string
   conversationId: string

+ 34 - 1
tests/client/chatLifecycle.test.ts

@@ -25,7 +25,7 @@ describe('ordinary tool lifecycle and authoritative history', () => {
     expect(buildMessageTimelineRows(chatMessagesToUi(current.messages)).filter(row => row.kind === 'trace').map(row => row.status)).toEqual(['success'])
   })
 
-  it.each(['message.complete', 'error'])('settles every unfinished tool on %s without inventing success', type => {
+  it.each(['error', 'run.failed'])('settles every unfinished tool on %s without inventing success', type => {
     let current = state()
     for (const [event, payload] of [['message.start', {}], ['tool.start', { tool_id: 'call', name: 'terminal' }], ['message.interim', { text: '稍等' }], [type, { text: '结束' }]] as Array<[string, any]>)
       current = applyChatEvent(current, { type: event, payload })
@@ -34,6 +34,39 @@ describe('ordinary tool lifecycle and authoritative history', () => {
     expect(settleChatMessages(current.messages)).toEqual(current.messages)
   })
 
+  it('keeps a successful terminal turn active until file output tools finish', () => {
+    let current = state()
+    for (const [event, payload] of [
+      ['message.start', {}],
+      ['tool.start', { tool_id: 'file', name: 'write_file' }],
+      ['message.complete', { text: '文件准备中', status: 'complete' }],
+      ['tool.generating', { tool_id: 'file', name: 'write_file' }],
+    ] as Array<[string, any]>) {
+      current = applyChatEvent(current, { type: event, payload })
+    }
+    expect(current.isStreaming).toBe(true)
+    expect(current.messages.flatMap(message => message.toolCalls ?? []).map(tool => tool.status)).toEqual(['running'])
+    expect(current.messages.some(message => message.isStreaming)).toBe(false)
+
+    current = applyChatEvent(current, { type: 'tool.completed', payload: { tool_id: 'file', name: 'write_file', result: { ok: true } } })
+    expect(current.isStreaming).toBe(false)
+    expect(current.messages.flatMap(message => message.toolCalls ?? []).map(tool => tool.status)).toEqual(['completed'])
+  })
+
+  it('reattaches a file tool that is first reported after the terminal message', () => {
+    let current = state()
+    current = applyChatEvent(current, { type: 'message.start', payload: {} })
+    current = applyChatEvent(current, { type: 'message.complete', payload: { text: '正在准备文件', status: 'complete' } })
+    expect(current.isStreaming).toBe(false)
+
+    current = applyChatEvent(current, { type: 'tool.started', payload: { tool_id: 'late-file', name: 'write_file' } })
+    expect(current.isStreaming).toBe(true)
+    expect(current.messages.at(-1)?.toolCalls?.map(tool => tool.status)).toEqual(['running'])
+
+    current = applyChatEvent(current, { type: 'tool.completed', payload: { tool_id: 'late-file', name: 'write_file', result: { ok: true } } })
+    expect(current.isStreaming).toBe(false)
+  })
+
   it('recognizes persisted event status aliases and nonzero tool exit codes', () => {
     const message = normalizeChatMessage({ id: '1', role: 'assistant', tool_calls: [
       { id: 'empty', status: 'tool.complete' }, { id: 'failed', status: 'tool.complete', result: { exit_code: 1, error: null } },

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

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { foldDetail, WorkspaceTranscriptStore, type WorkspaceDetail } from '@/components/workspace/transcriptStore'
-import type { WorkspaceConversation, WorkspaceEvent, WorkspaceMessage } from '@shared/workspace'
+import { workspaceHasUnfinishedOutput, type WorkspaceConversation, type WorkspaceEvent, type WorkspaceMessage } from '@shared/workspace'
 
 const conversation: WorkspaceConversation = { id: 'c', kind: 'direct', name: 'Bot', avatar: '', memberIds: ['a'], instructions: '', administratorId: 'a', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 3, preview: '', createdAt: 1, updatedAt: 1 }
 const message = (id: string, seq: number, content = id): WorkspaceMessage => ({ id, seq, conversationId: 'c', role: 'assistant', content, reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: seq })
@@ -57,6 +57,20 @@ describe('Bot transcript cache', () => {
     const current = store.finishRead(token, detail())
     expect(current.messages.at(-1)!.content).toBe('更新后的内容')
   })
+
+  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 }] },
+      ])).toBe(true)
+    }
+    expect(workspaceHasUnfinishedOutput([
+      { ...message('file', 3, '文件已输出'), status: 'complete', tools: [{ id: 'file-tool', status: 'tool.completed' }] },
+    ])).toBe(false)
+    expect(workspaceHasUnfinishedOutput([
+      { ...message('hidden', 3, '文件准备中'), status: 'complete', visible: false, tools: [{ id: 'file-tool', status: 'tool.generating' }] },
+    ])).toBe(false)
+  })
   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, '旧事件')))