From e90610ed050afe0d282ba19cbaac9d823e6f2678 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 22 Jun 2026 20:35:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=90=8EAI=E7=AD=89=E5=BE=85=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=B8=8E=E7=BB=88=E7=AB=AF=E4=BE=A7=E8=BE=B9=E6=A0=8F=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复模型调用工具后刷新页面,AI回复头部等待提示文字再次出现的bug - restoreTaskState 根据 hasAssistantContentEvent 精确设置 awaitingFirstContent - handleAiMessageStart 从头重建时保留 restoreTaskState 的设置 - handleToolStart 兜底清除 awaitingFirstContent - 修复刷新页面后终端侧边栏已有内容重复显示绿色箭头与输入内容的bug - 后端 get_snapshot/get_terminal_output 返回 last_event_time - 前端 TerminalPanel 基于 last_event_time 对 terminal_output 事件去重 --- modules/persistent_terminal/lifecycle.py | 9 +++++++- modules/terminal_manager.py | 3 ++- server/socket_handlers.py | 3 ++- .../src/app/methods/taskPolling/aiStream.ts | 7 +++++- .../app/methods/taskPolling/compression.ts | 9 ++++++-- static/src/app/methods/taskPolling/tool.ts | 12 ++++++++++ .../src/components/panels/TerminalPanel.vue | 23 +++++++++++++++++++ 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/modules/persistent_terminal/lifecycle.py b/modules/persistent_terminal/lifecycle.py index 6935b60..5d5a229 100644 --- a/modules/persistent_terminal/lifecycle.py +++ b/modules/persistent_terminal/lifecycle.py @@ -130,9 +130,16 @@ class LifecycleMixin: "seconds_since_last_output": self._seconds_since_last_output(), "last_command": self.last_command, "buffer_size": self.total_output_size, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), + "last_event_time": self._get_last_event_time() } + def _get_last_event_time(self) -> Optional[float]: + """获取 io_history 中最后一条事件的时间戳,用于前端去重""" + if self.io_history: + return self.io_history[-1][1] + return None + def get_status(self) -> Dict: """获取终端状态""" return { diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index 993e4db..4e6ea24 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -672,7 +672,8 @@ class TerminalManager: "seconds_since_last_output": snapshot.get("seconds_since_last_output", terminal._seconds_since_last_output()), "echo_loop_detected": snapshot.get("echo_loop_detected", terminal.echo_loop_detected), "lines_returned": snapshot.get("lines_returned"), - "truncated": snapshot.get("truncated", False) + "truncated": snapshot.get("truncated", False), + "last_event_time": snapshot.get("last_event_time") } def get_terminal_snapshot( diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 81b8a84..69cad18 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -228,7 +228,8 @@ def handle_get_terminal_output(data): 'session': session_name, 'output': result['output'], 'is_interactive': result.get('is_interactive', False), - 'last_command': result.get('last_command', '') + 'last_command': result.get('last_command', ''), + 'last_event_time': result.get('last_event_time') }) else: emit('error', {'message': result['error']}) diff --git a/static/src/app/methods/taskPolling/aiStream.ts b/static/src/app/methods/taskPolling/aiStream.ts index 87c2165..d0da4e0 100644 --- a/static/src/app/methods/taskPolling/aiStream.ts +++ b/static/src/app/methods/taskPolling/aiStream.ts @@ -101,7 +101,12 @@ export const aiStreamMethods = { // 确保 awaitingFirstContent 被正确设置 const hasContent = lastMessage.actions && lastMessage.actions.length > 0; - if (!hasContent) { + if (this._rebuildingFromScratch) { + // 从头重建时,restoreTaskState 已根据事件流分析(hasAssistantContentEvent) + // 正确设置了 awaitingFirstContent,此处不覆盖。 + // actions 被清空不代表没有内容,事件重放会恢复。 + console.log('[AiMessageStart] 从头重建,保留 awaitingFirstContent:', lastMessage.awaitingFirstContent); + } else if (!hasContent) { lastMessage.awaitingFirstContent = true; lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; console.log('[AiMessageStart] 设置等待动画', { diff --git a/static/src/app/methods/taskPolling/compression.ts b/static/src/app/methods/taskPolling/compression.ts index 3038945..0fd089d 100644 --- a/static/src/app/methods/taskPolling/compression.ts +++ b/static/src/app/methods/taskPolling/compression.ts @@ -365,9 +365,14 @@ export const compressionMethods = { // 让 handleAiMessageStart 检测到 isRefreshRestore 并复用现有消息容器。 // 这样避免视觉闪回(消息不会突然消失)+ 新事件通过轮询增量追加,不会一次性同步处理几百个事件导致卡死。 if (isAssistantMessage) { - debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建'); + debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建', { + hasAssistantContentEvent + }); lastMessage.actions = []; - lastMessage.awaitingFirstContent = true; + // 如果事件流中已经有内容事件(tool/thinking/text),说明回复已经开始过, + // 重放事件会恢复内容,不显示等待提示。 + // 如果还没有内容事件,说明回复还没开始,应该显示等待提示。 + lastMessage.awaitingFirstContent = !hasAssistantContentEvent; lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; // 清理旧的流式标记,确保新事件能正确设置 if (typeof lastMessage.streaming === 'boolean') { diff --git a/static/src/app/methods/taskPolling/tool.ts b/static/src/app/methods/taskPolling/tool.ts index 8c0de9d..a89de13 100644 --- a/static/src/app/methods/taskPolling/tool.ts +++ b/static/src/app/methods/taskPolling/tool.ts @@ -80,6 +80,18 @@ export const toolMethods = { return; } + // 兜底:工具开始执行时关闭等待动画(正常流程由 tool_preparing 关闭, + // 但从头重建等场景可能跳过 tool_preparing 直接收到 tool_start) + const msgForAwaiting = this.chatEnsureAssistantMessage(); + if (msgForAwaiting && msgForAwaiting.awaitingFirstContent) { + console.log('[ToolStart] 关闭等待动画', { + beforeAwaitingFirstContent: msgForAwaiting.awaitingFirstContent, + toolName: data.name + }); + msgForAwaiting.awaitingFirstContent = false; + msgForAwaiting.generatingLabel = ''; + } + let action = null; if (data.preparing_id && this.preparingTools.has(data.preparing_id)) { action = this.preparingTools.get(data.preparing_id); diff --git a/static/src/components/panels/TerminalPanel.vue b/static/src/components/panels/TerminalPanel.vue index e460851..5dd86ff 100644 --- a/static/src/components/panels/TerminalPanel.vue +++ b/static/src/components/panels/TerminalPanel.vue @@ -69,6 +69,8 @@ let term: Terminal | null = null; let themeObserver: MutationObserver | null = null; let resizeObserver: ResizeObserver | null = null; const _historyReady = ref>({}); +// 历史加载完成后记录最后一条事件的时间戳,用于去重刷新后回放的旧 terminal_output 事件 +const _historyLastEventTime = ref>({}); const sessionKeys = computed(() => Object.keys(sessions.value)); @@ -211,6 +213,9 @@ function switchToSession(name: string) { activeSession.value = name; if (!sessionHydrated.value[name]) { _historyReady.value = { ..._historyReady.value, [name]: false }; + const updatedTimes = { ..._historyLastEventTime.value }; + delete updatedTimes[name]; + _historyLastEventTime.value = updatedTimes; if (socket?.connected) { console.log('[TerminalPanel] requesting history for:', name); socket.emit('get_terminal_output', { session: name, lines: 0 }); @@ -246,6 +251,7 @@ watch(() => props.workspaceId, (newId, oldId) => { sessionLogs.value = {}; sessionHydrated.value = {}; _historyReady.value = {}; + _historyLastEventTime.value = {}; if (term) term.clear(); initSocket(); } @@ -333,6 +339,17 @@ async function initSocket() { const s = data.session; if (!s) return; const delta = (data.data || '') as string; + + // 去重:刷新页面后后端会回放历史快照(terminal_output_history) + // 同时可能回放最近的 terminal_output 事件,导致已有内容被重复写入。 + // 用历史快照的 last_event_time 作为截止线,跳过更早的事件。 + const historyTime = _historyLastEventTime.value[s] || 0; + const eventTime = typeof data.timestamp === 'number' ? data.timestamp : 0; + if (historyTime > 0 && eventTime > 0 && eventTime <= historyTime) { + console.log('[TerminalPanel] terminal_output DEDUP skip:', s, 'eventTime:', eventTime, '<= historyTime:', historyTime); + return; + } + // 先把输出暂存到 sessionLogs;历史加载完成后再写入终端, // 避免新终端历史为空时 _historyReady 始终为 false 导致永远写不进去。 sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta; @@ -369,6 +386,9 @@ async function initSocket() { if (target) { sessionLogs.value = { ...sessionLogs.value, [target]: '' }; sessionHydrated.value = { ...sessionHydrated.value, [target]: true }; + const updatedTimes = { ..._historyLastEventTime.value }; + delete updatedTimes[target]; + _historyLastEventTime.value = updatedTimes; if (target === activeSession.value) renderSessionLog(); } }); @@ -397,6 +417,9 @@ async function initSocket() { // _historyReady 一直为 false,所有实时输出都会被跳过,终端 seemingly 卡住。 sessionHydrated.value = { ...sessionHydrated.value, [s]: true }; _historyReady.value = { ..._historyReady.value, [s]: true }; + // 记录历史快照最后一条事件的时间戳,用于后续 terminal_output 去重 + const lastEventTime = typeof data.last_event_time === 'number' ? data.last_event_time : 0; + _historyLastEventTime.value = { ..._historyLastEventTime.value, [s]: lastEventTime }; console.log('[TerminalPanel] handleHistory SAVED:', s, 'finalLen:', payload.length, 'calling render:', s === activeSession.value); if (s === activeSession.value) { renderSessionLog();