问题根因: 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,防止工具调用前的最后文本片段丢失。
206 lines
9.4 KiB
Python
206 lines
9.4 KiB
Python
"""聊天视图:用户/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()
|