Files
micro_servers/BotOps/widgets/chat_view.py
T
qing af5cd02663 fix(aibox/botops): 思考区域按时间顺序分布,不再全部堆在顶部
问题根因:
1. ChatView.end_reasoning() 不清空 _reasoning_widget 引用,导致
   多轮工具调用时 start_reasoning() 直接 return,所有轮次的思考
   内容都被追加到第一个思考区域(在聊天区最顶部)。
2. AI widget 在工具调用期间不重置,导致后续轮次的思考区域被
   挂载在AI widget中间,顺序混乱。

修复:
1. end_reasoning()/abort_reasoning() 更新标签后清除引用(widget
   保留在DOM显示),下一轮 reasoning_start 创建新的思考区域。
2. 新增 ai_break 协议信号:服务端在工具调用执行前发送,前端
   break_ai() 清除AI widget引用(保留已显示内容),后续chunk
   创建新的AI widget,确保思考区域按时间顺序插入在两段AI文本之间。
3. ai_break 前flush chunk_buffer,防止工具调用前的最后文本片段丢失。
2026-08-01 21:10:42 +08:00

206 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
- chunk 增量片段(正式回答内容),直接 append 到 AI 消息
- reasoning_start 思考开始信号,在当前末尾创建新的思考区域
- reasoning 思考内容增量片段,追加到当前活跃的思考区域
- reasoning_end 思考结束信号:更新标签后清除引用(widget保留显示),
后续 reasoning_start 会创建新的思考区域
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
顺序保证:不预创建空AI widget,按消息到达顺序自然挂载。
多轮工具调用场景:每轮思考(reasoning_start→reasoning_end)创建独立的思考区域,
按时间顺序分布在对话流中各自的AI回答之前,而非全部堆在顶部。
"""
from textual.containers import VerticalScroll
from textual.widgets import Markdown, Static
class ChatView(VerticalScroll):
"""可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。
不预创建空widget:避免重排复杂性,消息按接收顺序自然挂载,
天然保证思考过程在正式回答之前。
滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长
(流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚,
下一条新消息再重新锚定。
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ai_widget: Markdown | None = None
self._reasoning_widget: Static | None = None
self._ai_text = ""
self._reasoning_text = ""
self._ai_finalized = True # 初始无活跃 AI 消息
@property
def ai_text(self) -> str:
return self._ai_text
def _anchor_bottom(self):
"""锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。"""
self.call_after_refresh(self.anchor)
async def add_user(self, text: str):
await self.mount(Static(f"👤 你: {text}", classes="msg-user", markup=False))
self._anchor_bottom()
async def add_system(self, text: str, css_class: str = "msg-system"):
await self.mount(Static(text, classes=css_class, markup=False))
self._anchor_bottom()
async def start_ai(self):
"""开始AI回复(标记状态,不预创建空widget)。
为保持顺序正确,空widget不在此处创建。第一个chunk到来时才创建AI widget,
此时思考widget(如果有)已经先挂载,顺序自然正确。
"""
if not self._ai_finalized:
return
self._ai_text = ""
self._ai_finalized = False
async def start_reasoning(self, hint: str = "🤔 思考中..."):
"""开始展示思考过程区域。直接挂载在当前末尾位置。"""
if self._reasoning_widget is not None:
return
self._reasoning_text = ""
self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False)
await self.mount(self._reasoning_widget)
self._anchor_bottom()
async def append_reasoning(self, delta: str):
"""追加思考内容增量。"""
if not delta:
return
if self._ai_finalized:
# 已收尾(中断/完成),丢弃残留的思考片段
return
if self._reasoning_widget is None:
await self.start_reasoning()
self._reasoning_text += delta
# 限制思考内容显示长度(最多显示最后 3000 字符,避免思考过长占满屏幕)
display = self._reasoning_text
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):
"""结束当前思考区域,更新标签后清除引用(widget保留在界面上显示)。
清除引用后,后续 reasoning_start 会在当前末尾位置创建新的思考区域,
实现多轮工具调用时思考区域按时间顺序分布,而非全部堆在顶部。
"""
if self._reasoning_widget is not None and self._reasoning_text:
display = self._reasoning_text
if len(display) > 3000:
display = "...(前略)\n" + display[-3000:]
self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}")
# 清除引用但保留widget在DOM中显示——下一轮reasoning_start会创建新widget
self._reasoning_widget = None
self._reasoning_text = ""
async def abort_reasoning(self):
"""中断当前思考区域,更新提示文字后清除引用。"""
if self._reasoning_widget is not None:
if self._reasoning_text:
display = self._reasoning_text
if len(display) > 3000:
display = "...(前略)\n" + display[-3000:]
self._reasoning_widget.update(f"🛑 思考已中断({len(self._reasoning_text)}字符)\n{display}")
else:
self._reasoning_widget.update("🛑 思考已中断")
# 清除引用,已显示的widget保留
self._reasoning_widget = None
self._reasoning_text = ""
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 break_ai(self):
"""分段当前AI回答:清除引用但保留widget在DOM中显示。
用于工具调用场景:工具调用前的AI文字(如"好的让我查一下")保留显示,
后续chunk会创建新的AI widget,保证思考区域按时间顺序插入在两段AI文字之间。
与finish_ai不同:不设置_ai_finalized,对话继续中。
"""
if self._ai_widget is not None:
self._ai_widget = None
self._ai_text = ""
async def append_ai(self, delta: str):
"""追加增量片段(服务端 chunk 为增量语义,累积文本后 update 整个 Markdown)。
使用 update() 而非 Markdown.append():Textual 的 Markdown.append() 在
chunk 边界切断 Markdown 语法(如列表项、代码块)时可能导致重复解析渲染。
累积完整文本后 update() 确保每次都从完整文本重新解析,避免重复。
"""
if not delta:
return
if self._ai_finalized:
# 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据
return
# 第一次收到chunk时创建widget(此时思考widget如果存在已经在前面了)
await self._ensure_ai_widget()
self._ai_text += delta
# 累积文本后整体update,而非增量append,避免Markdown增量解析导致的重复渲染
self._ai_widget.update(self._ai_text)
self.call_after_refresh(self.scroll_end, animate=False)
async def finish_ai(self, full_text: str = ""):
"""收尾一条 AI 消息。
chunk 流式追加是内容权威来源,complete 只做收尾:
- 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载)
- widget 存在且有流式内容:保持已渲染内容不变,仅清空引用
- widget 存在但无内容(异常场景)且有 full_text:用 full_text 填充
- widget 不存在(未收到任何 chunk)且有 full_text:补建
- widget 不存在且无内容:正常无内容场景
"""
if self._ai_finalized:
return
w = self._ai_widget
self._ai_widget = None
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:
# widget存在但没有任何输出:移除空框(正常不会走到这里,因为不预创建)
await w.remove()
else:
# widget不存在(start_ai后没收到任何chunk),有full_text则补建
if full_text:
self._ai_text = full_text
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.call_after_refresh(self.scroll_end, animate=False)
async def reset(self):
"""清空聊天区(/clear)。"""
self._ai_widget = None
self._reasoning_widget = None
self._ai_text = ""
self._reasoning_text = ""
self._ai_finalized = True
await self.remove_children()