From 1b9e6f6b3aa40f93c2a98c070ef16eff4052db42 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 02:56:29 +0800 Subject: [PATCH] fix(botops): resolve duplicate AI response rendering in ChatView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: aibox.py think_and_respond recursively calls itself after tool execution, and each recursive call's complete callback carries only the final-round full_response. The old finish_ai logic treated complete as a full-text alignment signal — when full_text != self._ai_text (which always happened after recursive tool calls because _ai_text accumulated all chunks while complete only held the last round), it triggered remove+remount, causing visible duplicate/flash of AI responses. A secondary issue was dialogue_ended arriving before complete, which made a second finish_ai call remount the message a second time. Changes: - Remove the full-text replacement branch in finish_ai; chunk streaming is now the authoritative content source, complete is a finalization signal only - Add _ai_finalized flag: start_ai resets it, finish_ai sets it; repeated finish_ai calls (dialogue_ended + complete) return early to prevent remount - append_ai drops late chunks after finalization to avoid dirty appends - Empty pre-built widget without chunks still gets filled via w.update(full_text) - Full lost-frame fallback (no widget + full_text) is preserved - Update BotOps/README.md protocol section to reflect new complete semantics --- BotOps/README.md | 7 +++++- BotOps/widgets/chat_view.py | 50 +++++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/BotOps/README.md b/BotOps/README.md index 3de9e43..9ca5ec1 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -58,4 +58,9 @@ BotOps/ 与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): - 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` -- 接收:`chunk`(**增量片段**,服务端 `chunk_buffer` 发送后即清零,前端直接拼接)/ `complete`(**完整全文**,用于对齐已显示内容)/ `line`(过程行:🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏)/ `reasoning`(思考流,仅服务端 debug_level≥3 发送)/ `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong` +- 接收: + - `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源 + - `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾;仅在完全丢帧(未收到任何 chunk)时用 full_text 补建消息,**不再做全文替换对齐**(避免服务端递归调用工具后 complete 只含最后一轮内容导致重复渲染) + - `line`(过程行):🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏;助手前缀/思考提示在聊天区已由 AI 消息框承载,不重复展示 + - `reasoning`(思考流):仅服务端 debug_level≥3 发送,记入任务侧栏 + - `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong` diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 718ef2c..445b295 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -1,8 +1,9 @@ """聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。 -服务端协议(aibox/main.py output_callback 原样透传 msg_type): - - chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append - - complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序) +服务端协议(aibox/aibox.py output_callback 原样透传 msg_type): + - chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append + - complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾; + 仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建 """ from textual.containers import VerticalScroll from textual.widgets import Markdown, Static @@ -20,6 +21,7 @@ class ChatView(VerticalScroll): super().__init__(**kwargs) self._ai_widget: Markdown | None = None self._ai_text = "" + self._ai_finalized = True # 初始无活跃 AI 消息 @property def ai_text(self) -> str: @@ -42,6 +44,7 @@ class ChatView(VerticalScroll): if self._ai_widget is not None: return self._ai_text = "" + self._ai_finalized = False self._ai_widget = Markdown("", classes="msg-ai") await self.mount(self._ai_widget) self._anchor_bottom() @@ -50,6 +53,9 @@ class ChatView(VerticalScroll): """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" if not delta: return + if self._ai_finalized: + # 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据 + return if self._ai_widget is None: await self.start_ai() self._ai_widget.append(delta) @@ -60,31 +66,43 @@ class ChatView(VerticalScroll): async def finish_ai(self, full_text: str = ""): """收尾一条 AI 消息。 - - full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐; - - full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。 + chunk 流式追加是内容权威来源,complete 只做收尾: + - 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载) + - widget 存在且有流式内容:保持已渲染内容不变,仅清空引用 + - widget 存在但无内容(预建空框)且有 full_text:用 full_text 填充 + - widget 存在但无内容也无 full_text:移除空框 + - widget 不存在(流式丢帧/start_ai 未调用)且有 full_text:补建 """ + if self._ai_finalized: + return w = self._ai_widget self._ai_widget = None - if w is None: - # 流式期间丢帧,只在 complete 收到全文:补建完整消息 + + if w is not None: + if self._ai_text: + # 有流式内容,chunk 已完整渲染,直接保留,不做替换 + pass + elif full_text: + # 预建了空 widget 但没收到任何 chunk:用 full_text 填充 + w.update(full_text) + self._ai_text = full_text + else: + # 预建后没有任何输出:移除空框 + await w.remove() + else: + # widget 不存在(start_ai 未调用),有 full_text 则补建 if full_text: self._ai_text = full_text await self.mount(Markdown(full_text, classes="msg-ai")) self._anchor_bottom() - return - if full_text and full_text != self._ai_text: - # 已显示内容与全文不一致(丢字/乱序/丢帧),整体替换对齐 - await w.remove() - self._ai_text = full_text - await self.mount(Markdown(full_text, classes="msg-ai")) - elif not full_text and not self._ai_text: - # 预建后没有任何输出:移除空框 - await w.remove() + self._ai_text = "" + self._ai_finalized = True self.call_after_refresh(self.scroll_end, animate=False) async def reset(self): """清空聊天区(/clear)。""" self._ai_widget = None self._ai_text = "" + self._ai_finalized = True await self.remove_children()