fix(BotOps): 修正 aibox 协议语义错配导致的输出丢失

- 服务端 chunk 为增量片段(chunk_buffer 发送后即清零),原 _delta
  按累计全文假设计算,相邻 chunk 前缀撞字时静默丢字符;改为直接拼接
- complete 携带完整全文,finish_ai 改为全文对齐(不一致时整体替换,
  可自愈丢帧/乱序),修复结尾全文重复 append
- 新增 line/reasoning/task_added/dialogue_reset/pong 消息路由:
  line(🔧 工具调用提示、工具输出)不再落入兜底污染聊天区,改投任务侧栏
- 发送失败时移除预建的空 AI 消息框并提示,避免空等回复
- 断线提示补充重连窗口期服务端输出丢失说明
- 同步修正 ws_client/README 中的协议描述
This commit is contained in:
qing committed 2026-07-29 09:32:30 +08:00
1 parent f9cf408c82
commit b86b089634
4 files changed
+76 -43

No files matched your search

+1 -1
View File
@@ -43,4 +43,4 @@ BotOps/
与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): 与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释):
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` - 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
- 接收:`chunk`(**累计全文**,增量由前端计算)/ `complete` / `dialogue_ended` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` - 接收:`chunk`(**增量片段**,服务端 `chunk_buffer` 发送后即清零,前端直接拼接)/ `complete`(**完整全文**,用于对齐已显示内容)/ `line`(过程行:🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏)/ `reasoning`(思考流,仅服务端 debug_level≥3 发送)/ `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong`
+30 -8
View File
@@ -141,7 +141,9 @@ class BotOpsApp(App):
return return
await self.chat.add_user(text) await self.chat.add_user(text)
await self.chat.start_ai() # 预建 AI 消息框,即时反馈 await self.chat.start_ai() # 预建 AI 消息框,即时反馈
await self.ws.send_message(text) if not await self.ws.send_message(text):
await self.chat.finish_ai("") # 发送失败:移除预建的空消息框
await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error")
# ── 快捷键 ──────────────────────────────────────────────────────────── # ── 快捷键 ────────────────────────────────────────────────────────────
async def action_interrupt(self): async def action_interrupt(self):
@@ -159,18 +161,37 @@ class BotOpsApp(App):
content = data.get("content", "") content = data.get("content", "")
if t == "chunk": if t == "chunk":
if content.startswith("🔧 调用工具"): # 服务端 chunk 为增量片段(发送后即清零),直接拼接
await self.notify_system(content, level="task") await self.chat.append_ai(content)
elif content.startswith(("✅ 对话记忆已保存", "💾 保存记忆", "❌", "⚠️")): elif t == "line":
await self.notify_system(content, level="task") # 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
stripped = content.strip()
if not stripped:
pass
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")):
pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示
else: else:
await self.chat.append_ai(content) # 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
self.tasks.write(stripped)
elif t == "reasoning":
# 思考流(服务端 debug_level>=3 才发送),记入任务侧栏
stripped = content.strip()
if stripped:
self.tasks.write(f"💭 {stripped[-200:]}")
elif t == "complete": elif t == "complete":
await self.chat.finish_ai(content or self.chat.ai_text) # complete 携带完整全文,用于对齐已显示内容
await self.chat.finish_ai(content)
elif t == "dialogue_ended": elif t == "dialogue_ended":
await self.chat.finish_ai(self.chat.ai_text) await self.chat.finish_ai(self.chat.ai_text)
if content: if content:
await self.notify_system(f"✅ {content}") await self.notify_system(f"✅ {content}")
elif t == "task_added":
await self.notify_system(f"➕ {content}")
elif t == "dialogue_reset":
if content:
await self.notify_system(f"🔄 {content}")
elif t == "pong":
pass
elif t == "task_process": elif t == "task_process":
sub = data.get("msg_type", "") sub = data.get("msg_type", "")
title = data.get("task_title", "未知任务") title = data.get("task_title", "未知任务")
@@ -213,7 +234,8 @@ class BotOpsApp(App):
await self.notify_system(f"✅ 已连接到 {self.ws.uri}") await self.notify_system(f"✅ 已连接到 {self.ws.uri}")
elif state == "disconnected": elif state == "disconnected":
self._refresh_status("") self._refresh_status("")
await self.notify_system(f"❌ {detail}") await self.notify_system(
f"❌ {detail}(中断期间的服务端输出已丢失,如回复不完整请重发)")
elif state == "reconnecting": elif state == "reconnecting":
self._refresh_status(detail) self._refresh_status(detail)
elif state == "error": elif state == "error":
+40 -31
View File
@@ -1,20 +1,13 @@
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。""" """聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
服务端协议(aibox/main.py output_callback 原样透传 msg_type):
- chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append
- complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序)
"""
from textual.containers import VerticalScroll from textual.containers import VerticalScroll
from textual.widgets import Markdown, Static from textual.widgets import Markdown, Static
def _delta(prev: str, new: str) -> str:
"""计算累计文本的增量(服务端 chunk 为累计全文;前缀失配时取最长公共前缀后的部分)。"""
if new.startswith(prev):
return new[len(prev):]
i = 0
for a, b in zip(prev, new):
if a != b:
break
i += 1
return new[i:]
class ChatView(VerticalScroll): class ChatView(VerticalScroll):
"""可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。
@@ -26,11 +19,11 @@ class ChatView(VerticalScroll):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._ai_widget: Markdown | None = None self._ai_widget: Markdown | None = None
self._ai_prev = "" self._ai_text = ""
@property @property
def ai_text(self) -> str: def ai_text(self) -> str:
return self._ai_prev return self._ai_text
def _anchor_bottom(self): def _anchor_bottom(self):
"""锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。""" """锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。"""
@@ -48,34 +41,50 @@ class ChatView(VerticalScroll):
"""预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。"""
if self._ai_widget is not None: if self._ai_widget is not None:
return return
self._ai_prev = "" self._ai_text = ""
self._ai_widget = Markdown("", classes="msg-ai") self._ai_widget = Markdown("", classes="msg-ai")
await self.mount(self._ai_widget) await self.mount(self._ai_widget)
self._anchor_bottom() self._anchor_bottom()
async def append_ai(self, full_text: str): async def append_ai(self, delta: str):
"""以累计全文增量渲染(服务端 chunk 为累计文本)。""" """追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
if not delta:
return
if self._ai_widget is None: if self._ai_widget is None:
await self.start_ai() await self.start_ai()
d = _delta(self._ai_prev, full_text) self._ai_widget.append(delta)
if d: self._ai_text += delta
self._ai_widget.append(d) # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次
self._ai_prev = full_text self.call_after_refresh(self.scroll_end, animate=False)
# anchor 状态下容器会自动吸底;保险起见刷新后再滚一次
self.call_after_refresh(self.scroll_end, animate=False)
async def finish_ai(self, full_text: str = ""): async def finish_ai(self, full_text: str = ""):
"""收尾:用最终全文对齐一次(防中途丢帧),随后关闭本条 AI 消息。""" """收尾一条 AI 消息。
if self._ai_widget is None and full_text:
await self.start_ai() - full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐;
if full_text and full_text != self._ai_prev: - full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。
await self.append_ai(full_text) """
w = self._ai_widget
self._ai_widget = None self._ai_widget = None
self._ai_prev = "" if w is None:
# 流式期间丢帧,只在 complete 收到全文:补建完整消息
if full_text:
self._ai_text = full_text
await self.mount(Markdown(full_text, classes="msg-ai"))
self._anchor_bottom()
return
if full_text and full_text != self._ai_text:
# 已显示内容与全文不一致(丢字/乱序/丢帧),整体替换对齐
await w.remove()
self._ai_text = full_text
await self.mount(Markdown(full_text, classes="msg-ai"))
elif not full_text and not self._ai_text:
# 预建后没有任何输出:移除空框
await w.remove()
self._ai_text = ""
self.call_after_refresh(self.scroll_end, animate=False) self.call_after_refresh(self.scroll_end, animate=False)
async def reset(self): async def reset(self):
"""清空聊天区(/clear)。""" """清空聊天区(/clear)。"""
self._ai_widget = None self._ai_widget = None
self._ai_prev = "" self._ai_text = ""
await self.remove_children() await self.remove_children()
+5 -3
View File
@@ -4,10 +4,12 @@
发送:{"type": "message", "content": ...} 发送:{"type": "message", "content": ...}
{"type": "command", "command": "/..."} {"type": "command", "command": "/..."}
{"type": "interrupt", "content": ...} {"type": "interrupt", "content": ...}
接收:chunk / complete / dialogue_ended / task_process / task_status / task_error / 接收:chunk / line / reasoning / complete / dialogue_ended / dialogue_reset /
interrupt_ack / error / info / command_result / connection_ack / notification task_process / task_status / task_error / task_added / interrupt_ack /
error / info / command_result / connection_ack / notification / pong
说明:服务端 chunk 为**累计全文**(与 aibox/client.py 前缀匹配逻辑一致)。 说明:服务端 chunk 为**增量片段**(output_callback 发送 chunk_buffer 后即清零),
complete 为完整全文(可用于对齐);line 为过程行(工具提示/输出/前缀)。
""" """
import asyncio import asyncio
import json import json