diff --git a/server/chat_flow_stream_loop.py b/server/chat_flow_stream_loop.py index f55c605b..8d100528 100644 --- a/server/chat_flow_stream_loop.py +++ b/server/chat_flow_stream_loop.py @@ -41,6 +41,14 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien last_finish_reason = None tool_call_stream_active = False + # 通知前端:API 请求已发出、尚未收到首个响应(每次重试前都会重新触发)。 + # 前端据此驱动状态形象的「等待 API 响应…」文案;响应开始(thinking_start/ + # text_start/tool_preparing)或 error/任务终结时由前端清除。 + sender('api_request_start', { + 'attempt': api_attempt + 1, + 'max_attempts': max_api_retries + 1, + }) + # 收集流式响应 async for chunk in web_terminal.api_client.chat(messages, tools, stream=True): chunk_count += 1 diff --git a/static/src/app.ts b/static/src/app.ts index 1faa4a01..f8adf3bb 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -92,7 +92,8 @@ const appOptions = { chatAppendTextChunk: 'appendTextChunk', chatCompleteTextAction: 'completeText', chatAddSystemMessage: 'addSystemMessage', - chatEnsureAssistantMessage: 'ensureAssistantMessage' + chatEnsureAssistantMessage: 'ensureAssistantMessage', + chatClearStreamingResidualState: 'clearStreamingResidualState' }), ...mapActions(useInputStore, { inputSetFocused: 'setInputFocused', diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 91157726..3d6c90fb 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -367,9 +367,11 @@ export const computed = { const intentEnabled = !!usePersonalizationStore().form?.tool_intent_enabled; - // 1) 思考中 + // 1) 思考中(防御:仅在任务仍运行时采信流式字段——异常中断后字段可能残留, + // 虽有各终结路径的统一清理兑底,这里再加一层保险避免永久卡「思考中」) const isThinking = !!lastAssistant && + (this.taskInProgress || this.streamingMessage) && (lastAssistant.currentStreamingType === 'thinking' || lastAssistant.activeThinkingId != null); // 2) 工具执行中(取最后一段并行工具,过滤未完成的) @@ -438,6 +440,11 @@ export const computed = { if (running) { // 工作态:等 API / 后台运行 / 流式输出正文 let text = bgText; + // 「等待 API 响应…」优先于随机等待文案:后端已发出请求、尚未开始回复 + // (api_request_start 事件驱动,覆盖首轮与工具轮次间的每一次等待) + if (!text && this.apiRequestPending) { + return { mode: 'work', toolKeys: [], toolTexts: [], text: '等待 API 响应…', tracking: false, apiWaiting: true }; + } if (!text) { const awaitingMsg = lastAssistant && lastAssistant.awaitingFirstContent ? lastAssistant : null; if (awaitingMsg) { diff --git a/static/src/app/methods/conversation/state.ts b/static/src/app/methods/conversation/state.ts index 346ecd8b..1b032bc4 100644 --- a/static/src/app/methods/conversation/state.ts +++ b/static/src/app/methods/conversation/state.ts @@ -25,6 +25,11 @@ export const stateMethods = { }); // 重置消息和流状态 + // 先清消息内残留的流式字段(currentStreamingType/activeThinkingId/streaming + // action),否则异常中断后状态头像会一直卡在「思考中」(avatarStatus 的 + // isThinking 只认这两个字段,与任务标志无关) + this.chatClearStreamingResidualState(); + this.apiRequestPending = false; this.streamingMessage = false; this.currentMessageIndex = -1; this.stopRequested = false; diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index d4696828..37c3d6f4 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -183,8 +183,28 @@ export const lifecycleMethods = { debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData); + // 「等待 API 响应」状态维护:api_request_start 置位;任何「响应开始」 + // (thinking_start/text_start/tool_preparing)或任务终结信号都清除。 + // 历史事件重放时按序经过此处,最终状态自然收敛正确。 + if (eventType === 'api_request_start') { + this.apiRequestPending = true; + } else if ( + eventType === 'thinking_start' || + eventType === 'text_start' || + eventType === 'tool_preparing' || + eventType === 'task_complete' || + eventType === 'task_stopped' || + eventType === 'error' + ) { + this.apiRequestPending = false; + } + // 根据事件类型调用对应的处理方法 switch (eventType) { + case 'api_request_start': + // 状态已在上方统一维护,无其他处理 + break; + case 'ai_message_start': this.handleAiMessageStart(eventData, eventIdx); break; @@ -426,6 +446,9 @@ export const lifecycleMethods = { // 同步处理状态更新 this.streamingMessage = false; this.stopRequested = false; + // 兜底清理可能残留的流式状态(正常流程 thinking_end/text_end 已清,此处幂等) + this.chatClearStreamingResidualState?.(); + this.apiRequestPending = false; if (!hasRunningSubAgents && !hasRunningMultiAgent) { this.markLatestUserWorkCompleted(); } @@ -526,6 +549,8 @@ export const lifecycleMethods = { }); this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped'); + this.chatClearStreamingResidualState?.(); + this.apiRequestPending = false; this.streamingMessage = false; this.stopRequested = false; @@ -668,7 +693,13 @@ export const lifecycleMethods = { duration: 8000 }); - // 清理状态 + // 清理状态(顺序:先清依赖 awaitingFirstContent 判定的空占位,再清残留流式字段) + this.cleanupTrailingEmptyAssistantPlaceholder?.('task_error'); + this.chatClearStreamingResidualState?.(); + if (typeof this.clearPendingTools === 'function') { + this.clearPendingTools('task_error'); + } + this.apiRequestPending = false; this.markLatestUserWorkCompleted(); this.streamingMessage = false; this.taskInProgress = false; diff --git a/static/src/app/methods/taskPolling/probe.ts b/static/src/app/methods/taskPolling/probe.ts index 21cc52c0..eb981a0f 100644 --- a/static/src/app/methods/taskPolling/probe.ts +++ b/static/src/app/methods/taskPolling/probe.ts @@ -146,6 +146,9 @@ export const probeMethods = { this.stopRequested = false; this.waitingForSubAgent = false; this.waitingForBackgroundCommand = false; + this.apiRequestPending = false; + // 同步清理消息内残留的流式状态(思考/文本),避免状态头像卡在「思考中」 + this.chatClearStreamingResidualState?.(); if (typeof this.clearPendingTools === 'function') { this.clearPendingTools('reconcile_auto_clear'); } diff --git a/static/src/app/state.ts b/static/src/app/state.ts index b28a4d46..b8e8c4f8 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -80,6 +80,10 @@ export function dataState() { toolStacks: new Map(), // 当前任务是否仍在进行中(用于保持输入区的"停止"状态) taskInProgress: false, + // 等待 API 响应:对话运行期间后端已发出 API 请求、尚未收到首个响应事件 + // (由后端 api_request_start 事件置位,thinking_start/text_start/tool_preparing/ + // error/任务终结时清除),用于状态头像显示「等待 API 响应…」 + apiRequestPending: false, // 对话运行状态对账定时器(事件为主、2.5s 对账纠偏,冲突以对账为准) runningStateReconcileTimer: null, // 对账清理方向的连续空闲确认计数(防 notice/idle dispatch 间隙误清) diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 75c8ca0d..1b3162d1 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -818,6 +818,8 @@ const props = defineProps<{ toolTexts?: string[]; text: string; tracking?: boolean; + /** true 时表示当前文案是「等待 API 响应…」(work 模式下仅此类文案显示在头像旁) */ + apiWaiting?: boolean; } | null; }>(); @@ -1074,6 +1076,11 @@ const composerAvatarText = computed(() => { if (props.avatarStatus.mode === 'tool' || props.avatarStatus.mode === 'think') { return props.avatarStatus.text || ''; } + // work 模式仅显示「等待 API 响应…」(apiWaiting 标记); + // 后台计数/随机等待文案不在头像旁重复显示(后者已在消息区等待动画中体现) + if (props.avatarStatus.mode === 'work' && props.avatarStatus.apiWaiting) { + return props.avatarStatus.text || ''; + } return ''; }); @@ -1083,6 +1090,7 @@ const composerAvatarTextKey = computed(() => { const s = props.avatarStatus; if (!s) return 'avatar-none'; if (s.mode === 'tool') return `avatar-tool-${s.toolKeys?.[0] || ''}`; + if (s.mode === 'work' && s.apiWaiting) return 'avatar-work-api-waiting'; return `avatar-${s.mode}`; }); diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 1f86dce4..bca2f670 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -1127,6 +1127,18 @@ export async function initializeLegacySocket(ctx: any) { } }); + // API 请求已发出、尚未收到首个响应:状态头像显示「等待 API 响应…」 + ctx.socket.on('api_request_start', (data) => { + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + return; + } + // 轮询模式下该状态由任务事件流维护 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + ctx.apiRequestPending = true; + }); + // 思考流开始 ctx.socket.on('thinking_start', (data) => { // 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。 @@ -1140,6 +1152,8 @@ export async function initializeLegacySocket(ctx: any) { return; } socketLog('思考开始'); + // 响应已开始,清除「等待 API 响应」状态(含 ignoreThinking 分支,故放最前) + ctx.apiRequestPending = false; const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false; streamingState.ignoreThinking = ignoreThinking; if (ignoreThinking) { @@ -1227,6 +1241,7 @@ export async function initializeLegacySocket(ctx: any) { return; } socketLog('文本开始'); + ctx.apiRequestPending = false; logStreamingDebug('socket:text_start'); finalizeStreamingText({ force: true }); resetStreamingBuffer(); @@ -1354,6 +1369,8 @@ export async function initializeLegacySocket(ctx: any) { socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id); return; } + // 工具流式收集即 API 响应已开始,清除「等待 API 响应」状态 + ctx.apiRequestPending = false; const msg = ctx.chatEnsureAssistantMessage(); if (!msg) { return; @@ -1861,7 +1878,9 @@ export async function initializeLegacySocket(ctx: any) { } } if (shouldRetry) { - // 错误后保持停止按钮态,用户可手动停止或等待自动重试 + // 错误后保持停止按钮态,用户可手动停止或等待自动重试; + // 重试间隔不算「等待 API 响应」,重试发起时后端会重新发 api_request_start + ctx.apiRequestPending = false; ctx.stopRequested = false; ctx.taskInProgress = true; ctx.streamingMessage = true; @@ -1874,15 +1893,13 @@ export async function initializeLegacySocket(ctx: any) { ); } - // 最后一次报错:恢复输入状态并清理提示动画 - const msgIndex = typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : -1; - if (msgIndex >= 0 && Array.isArray(ctx.messages)) { - const currentMessage = ctx.messages[msgIndex]; - if (currentMessage && currentMessage.role === 'assistant') { - currentMessage.awaitingFirstContent = false; - currentMessage.generatingLabel = ''; - } + // 最后一次报错:恢复输入状态并清理提示动画/流式残留 + // (统一清理 currentStreamingType/activeThinkingId 等字段, + // 否则 avatarStatus 的 isThinking 永久为真,状态头像卡在「思考中」) + if (typeof ctx.chatClearStreamingResidualState === 'function') { + ctx.chatClearStreamingResidualState(); } + ctx.apiRequestPending = false; if (typeof ctx.chatClearThinkingLocks === 'function') { ctx.chatClearThinkingLocks(); } diff --git a/static/src/stores/chat.ts b/static/src/stores/chat.ts index 54656482..8dd25b92 100644 --- a/static/src/stores/chat.ts +++ b/static/src/stores/chat.ts @@ -388,6 +388,35 @@ export const useChatStore = defineStore('chat', { msg.currentStreamingType = null; delete (msg as any).__splitByShowHtml; }, + // 清理异常中断(断网 / API 错误 / 任务停止)残留的流式状态。 + // 正常流程由 completeThinking/completeText 收尾;异常路径不会有 + // thinking_end/text_end,残留的 currentStreamingType/activeThinkingId + // 会让状态头像(avatarStatus)的 isThinking 永久为真,卡在「思考中」。 + // 注意:若需同时清理空占位 assistant 消息,必须先调 + // cleanupTrailingEmptyAssistantPlaceholder(它依赖 awaitingFirstContent + // 判定占位),再调本方法。 + clearStreamingResidualState() { + for (const msg of this.messages) { + if (!msg || msg.role !== 'assistant') continue; + msg.currentStreamingType = null; + msg.activeThinkingId = null; + msg.streamingThinking = ''; + msg.streamingText = ''; + msg.awaitingFirstContent = false; + msg.generatingLabel = ''; + if (Array.isArray(msg.actions)) { + for (const action of msg.actions) { + if ( + action && + (action.type === 'thinking' || action.type === 'text') && + action.streaming === true + ) { + action.streaming = false; + } + } + } + } + }, addSystemMessage(content: string, meta: any = null) { // 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染, // 避免运行时与刷新后在垂直间距上不一致。