fix(botops): v1.2 - 彻底修复思考区域与正式回答顺序问题

问题根因:之前预创建空 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 说明
This commit is contained in:
qing committed 2026-08-01 05:00:56 +08:00
1 parent 9815ff44af
commit bf2b5d31f7
3 files changed
+38 -54

No files matched your search

+30 -48
View File
@@ -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()