From 039d6acf37e14d017b9e4c6dfe65769d7297e08c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 04:55:12 +0800 Subject: [PATCH] fix(botops): fix reasoning panel appearing after AI response and filter conversation start notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _ai_prefab flag to track pre-created empty AI widget - start_reasoning() now reorders widgets: remove empty AI widget, mount reasoning first, then remount AI widget - This ensures reasoning always appears BEFORE the actual answer - Filter out 'Conversation started'/'对话开始' info notifications (implicit event, no need to show) - Increase reasoning display limit from 2000 to 3000 chars --- BotOps/app.py | 5 +++- BotOps/widgets/chat_view.py | 55 ++++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/BotOps/app.py b/BotOps/app.py index d6348f4..9aa0a54 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -230,7 +230,10 @@ class BotOpsApp(App): elif t == "error": await self.notify_system(f"❌ {content}", level="error") elif t == "info": - await self.notify_system(f"ℹ️ {content}") + # 过滤掉对话开始这类隐式通知(用户发消息自然知道对话开始了) + skip_keywords = ("Conversation started", "对话开始", "🎭") + if not any(k in content for k in skip_keywords): + await self.notify_system(f"ℹ️ {content}") elif t == "command_result": await self.notify_system(content) elif t == "connection_ack": diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 788f4da..a8b7409 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -2,7 +2,7 @@ 服务端协议(aibox/aibox.py output_callback 原样透传 msg_type): - chunk 增量片段(正式回答内容),直接 append 到 AI 消息 - - reasoning_start 思考开始信号,创建思考区域 + - reasoning_start 思考开始信号,创建思考区域(在AI回答之前) - reasoning 思考内容增量片段,追加到思考区域 - reasoning_end 思考结束信号 - complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾; @@ -15,6 +15,7 @@ from textual.widgets import Markdown, Static class ChatView(VerticalScroll): """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 + 顺序保证:思考区域始终在 AI 正式回答之前。 滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长 (流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚, 下一条新消息再重新锚定。 @@ -27,6 +28,7 @@ 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: @@ -45,22 +47,47 @@ class ChatView(VerticalScroll): self._anchor_bottom() async def start_ai(self): - """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" + """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。 + + 注意:此时只创建空widget但不显示内容,如果后续先收到思考内容, + 会在start_reasoning()中重新排列顺序,确保思考在前。 + """ if self._ai_widget is not None: 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): @@ -70,22 +97,23 @@ class ChatView(VerticalScroll): if self._reasoning_widget is None: await self.start_reasoning() self._reasoning_text += delta - # 限制思考内容显示长度(最多显示最后 2000 字符,避免思考过长占满屏幕) + # 限制思考内容显示长度(最多显示最后 3000 字符,避免思考过长占满屏幕) display = self._reasoning_text - if len(display) > 2000: - display = "...(前略)\n" + display[-2000:] + if len(display) > 3000: + display = "...(前略)\n" + display[-3000:] self._reasoning_widget.update(f"💭 思考中...\n{display}") self.call_after_refresh(self.scroll_end, animate=False) async def end_reasoning(self): """结束思考区域,更新提示文字。""" if self._reasoning_widget is not None and self._reasoning_text: - # 思考结束后保持展示,但更新标签 + # 思考结束后保持展示,更新标签为已完成 display = self._reasoning_text - if len(display) > 2000: - display = "...(前略)\n" + display[-2000:] + if len(display) > 3000: + 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 append_ai(self, delta: str): """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" @@ -95,7 +123,12 @@ class ChatView(VerticalScroll): # 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据 return if self._ai_widget is None: - await self.start_ai() + # 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 # 开始有实际内容了 self._ai_widget.append(delta) self._ai_text += delta # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 @@ -138,6 +171,7 @@ class ChatView(VerticalScroll): 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): @@ -147,4 +181,5 @@ class ChatView(VerticalScroll): self._ai_text = "" self._reasoning_text = "" self._ai_finalized = True + self._ai_prefab = False await self.remove_children()