From bf2b5d31f79737f0ed1cdefcf32a0bf1a5de138e Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 05:00:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(botops):=20v1.2=20-=20=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=80=9D=E8=80=83=E5=8C=BA=E5=9F=9F=E4=B8=8E?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E5=9B=9E=E7=AD=94=E9=A1=BA=E5=BA=8F=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因:之前预创建空 AI widget,思考 widget 挂载在其后,即使动态重排也可能因 Textual 组件树更新时序导致顺序不稳定。 修复方案: - 移除 prefab 空 AI widget 预创建逻辑 - start_ai() 仅标记状态,不创建任何 widget - _ensure_ai_widget() 延迟到第一次 chunk 到来时才创建 AI widget - 按消息到达顺序自然挂载:reasoning_start → reasoning → chunk → end_reasoning/finish_ai - 思考 widget 必然先于 AI widget 挂载,顺序天然正确 同时: - 简化 app.py 发送失败处理,不再需要移除空 widget - 更新 README v1.2 说明 --- BotOps/README.md | 10 +++-- BotOps/app.py | 4 +- BotOps/widgets/chat_view.py | 78 ++++++++++++++----------------------- 3 files changed, 38 insertions(+), 54 deletions(-) diff --git a/BotOps/README.md b/BotOps/README.md index 0a57119..938f9c6 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -63,20 +63,22 @@ BotOps/ - `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程 - `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 3000 字符) - `reasoning_end`(**思考结束**):思考结束,切换到正式回答 - - **顺序保证**:通过动态重排 widget,思考区域始终显示在 AI 正式回答**之前** - - `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾;仅在完全丢帧(未收到任何 chunk)时用 full_text 补建消息,**不再做全文替换对齐**(避免服务端递归调用工具后 complete 只含最后一轮内容导致重复渲染) + - **顺序保证**:不预创建空 AI widget,按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),思考区域必然在 AI 正式回答**之前** + - `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾(重置状态) - `line`(过程行):🔧 工具调用提示、工具输出,路由到任务侧栏;助手前缀/思考提示在聊天区已由对应区域承载,不重复展示 - `info`(系统信息):过滤"Conversation started"/"对话开始"等隐式通知,避免污染聊天流 - `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `command_result` / `connection_ack` / `notification` / `pong` ## 更新日志 +### v1.2 +- ✅ **彻底修复思考区域顺序问题**:移除预创建空 AI widget 的逻辑,改为第一次收到 `chunk` 时才创建 widget。按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),从根本上保证思考区域始终在正式回答之前 +- ✅ 简化 `complete` 处理逻辑:仅作为收尾重置状态信号,不再进行补建逻辑 + ### v1.1 - ✅ 支持 DeepSeek 思考模式展示:新增 `reasoning_start`/`reasoning`/`reasoning_end` 消息处理,思考内容以灰色斜体区域展示 -- ✅ **修复思考区域顺序问题**:通过 widget 动态重排,保证思考过程始终显示在 AI 正式回答之前(而非之后) - ✅ 超长思考内容自动折叠(显示最后 3000 字符) - ✅ 过滤"Conversation started"/"对话开始"等隐式系统通知,避免在回答末尾显示无用信息 -- ✅ 即使服务端未显式启用思考模式,若模型返回 reasoning_content 也能自动展示思考过程 ### v1.0 - 初始版本:Markdown 增量流式渲染、任务独立侧栏、自动重连、快捷键支持 diff --git a/BotOps/app.py b/BotOps/app.py index 9aa0a54..5ea6049 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -147,9 +147,9 @@ class BotOpsApp(App): await self.notify_system("未连接到服务器,先 /connect", level="error") return await self.chat.add_user(text) - await self.chat.start_ai() # 预建 AI 消息框,即时反馈 + await self.chat.start_ai() # 标记AI回复开始状态(不预创建空widget) if not await self.ws.send_message(text): - await self.chat.finish_ai("") # 发送失败:移除预建的空消息框 + await self.chat.finish_ai("") # 发送失败:重置AI状态 await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error") # ── 快捷键 ──────────────────────────────────────────────────────────── diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index a8b7409..2dcf3a4 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -7,6 +7,11 @@ - reasoning_end 思考结束信号 - complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾; 仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建 + +顺序保证:不预创建空AI widget,按消息到达顺序自然挂载。 + - 先到 reasoning_start → 思考区域挂在最后 + - 后到 chunk → AI回答挂在思考区域之后 + - 无思考直接 chunk → AI回答挂在最后 """ from textual.containers import VerticalScroll from textual.widgets import Markdown, Static @@ -15,7 +20,8 @@ from textual.widgets import Markdown, Static class ChatView(VerticalScroll): """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 - 顺序保证:思考区域始终在 AI 正式回答之前。 + 不预创建空widget:避免重排复杂性,消息按接收顺序自然挂载, + 天然保证思考过程在正式回答之前。 滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长 (流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚, 下一条新消息再重新锚定。 @@ -28,7 +34,6 @@ class ChatView(VerticalScroll): self._ai_text = "" self._reasoning_text = "" self._ai_finalized = True # 初始无活跃 AI 消息 - self._ai_prefab = False # AI widget是预创建的空widget(可重排) @property def ai_text(self) -> str: @@ -47,47 +52,23 @@ class ChatView(VerticalScroll): self._anchor_bottom() async def start_ai(self): - """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。 + """开始AI回复(标记状态,不预创建空widget)。 - 注意:此时只创建空widget但不显示内容,如果后续先收到思考内容, - 会在start_reasoning()中重新排列顺序,确保思考在前。 + 为保持顺序正确,空widget不在此处创建。第一个chunk到来时才创建AI widget, + 此时思考widget(如果有)已经先挂载,顺序自然正确。 """ - if self._ai_widget is not None: + if not self._ai_finalized: return self._ai_text = "" self._ai_finalized = False - self._ai_prefab = True - self._ai_widget = Markdown("", classes="msg-ai") - await self.mount(self._ai_widget) - self._anchor_bottom() async def start_reasoning(self, hint: str = "🤔 思考中..."): - """开始展示思考过程区域。 - - 如果AI widget已经预创建(空的),先移除它,等思考widget挂载后 - 再重新挂载AI widget,保证思考在回答之前。 - """ + """开始展示思考过程区域。直接挂载在当前末尾位置。""" if self._reasoning_widget is not None: return - - # 如果AI widget已经存在且是空的预创建状态,重新排列顺序 - need_remount_ai = False - ai_saved_text = self._ai_text - if self._ai_widget is not None and self._ai_prefab: - await self._ai_widget.remove() - self._ai_widget = None - need_remount_ai = True - - # 先挂载思考widget self._reasoning_text = "" self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False) await self.mount(self._reasoning_widget) - - # 如果之前有AI内容(理论上预创建时不会有,但保险处理),重新挂载AI widget - if need_remount_ai: - self._ai_widget = Markdown(ai_saved_text, classes="msg-ai") - await self.mount(self._ai_widget) - self._anchor_bottom() async def append_reasoning(self, delta: str): @@ -113,7 +94,13 @@ class ChatView(VerticalScroll): display = "...(前略)\n" + display[-3000:] self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}") # 思考区域保留显示,不删除 - self._ai_prefab = False # AI widget顺序已固定,不再重排 + + async def _ensure_ai_widget(self): + """确保AI widget存在(第一次chunk到来时创建)。""" + if self._ai_widget is None: + self._ai_widget = Markdown("", classes="msg-ai") + await self.mount(self._ai_widget) + self._anchor_bottom() async def append_ai(self, delta: str): """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" @@ -122,13 +109,8 @@ class ChatView(VerticalScroll): if self._ai_finalized: # 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据 return - if self._ai_widget is None: - # AI widget还没创建,先创建 - self._ai_text = "" - self._ai_prefab = False - self._ai_widget = Markdown("", classes="msg-ai") - await self.mount(self._ai_widget) - self._ai_prefab = False # 开始有实际内容了 + # 第一次收到chunk时创建widget(此时思考widget如果存在已经在前面了) + await self._ensure_ai_widget() self._ai_widget.append(delta) self._ai_text += delta # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 @@ -140,9 +122,9 @@ class ChatView(VerticalScroll): chunk 流式追加是内容权威来源,complete 只做收尾: - 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载) - widget 存在且有流式内容:保持已渲染内容不变,仅清空引用 - - widget 存在但无内容(预建空框)且有 full_text:用 full_text 填充 - - widget 存在但无内容也无 full_text:移除空框 - - widget 不存在(流式丢帧/start_ai 未调用)且有 full_text:补建 + - widget 存在但无内容(异常场景)且有 full_text:用 full_text 填充 + - widget 不存在(未收到任何 chunk)且有 full_text:补建 + - widget 不存在且无内容:正常无内容场景 """ if self._ai_finalized: return @@ -154,24 +136,25 @@ class ChatView(VerticalScroll): # 有流式内容,chunk 已完整渲染,直接保留,不做替换 pass elif full_text: - # 预建了空 widget 但没收到任何 chunk:用 full_text 填充 + # widget存在但没收到任何chunk(异常):用full_text填充 w.update(full_text) self._ai_text = full_text else: - # 预建后没有任何输出:移除空框 + # widget存在但没有任何输出:移除空框(正常不会走到这里,因为不预创建) await w.remove() else: - # widget 不存在(start_ai 未调用),有 full_text 则补建 + # widget不存在(start_ai后没收到任何chunk),有full_text则补建 if full_text: self._ai_text = full_text - await self.mount(Markdown(full_text, classes="msg-ai")) + self._ai_widget = Markdown(full_text, classes="msg-ai") + await self.mount(self._ai_widget) + self._ai_widget = None self._anchor_bottom() self._ai_text = "" self._reasoning_widget = None self._reasoning_text = "" self._ai_finalized = True - self._ai_prefab = False self.call_after_refresh(self.scroll_end, animate=False) async def reset(self): @@ -181,5 +164,4 @@ class ChatView(VerticalScroll): self._ai_text = "" self._reasoning_text = "" self._ai_finalized = True - self._ai_prefab = False await self.remove_children()