diff --git a/BotOps/README.md b/BotOps/README.md index ff6303f..6b1c119 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -43,4 +43,4 @@ BotOps/ 与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): - 发送:`{"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` diff --git a/BotOps/app.py b/BotOps/app.py index 62fdf10..645528d 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -141,7 +141,9 @@ class BotOpsApp(App): return await self.chat.add_user(text) 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): @@ -159,18 +161,37 @@ class BotOpsApp(App): content = data.get("content", "") if t == "chunk": - if content.startswith("🔧 调用工具"): - await self.notify_system(content, level="task") - elif content.startswith(("✅ 对话记忆已保存", "💾 保存记忆", "❌", "⚠️")): - await self.notify_system(content, level="task") + # 服务端 chunk 为增量片段(发送后即清零),直接拼接 + await self.chat.append_ai(content) + elif t == "line": + # 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀 + stripped = content.strip() + if not stripped: + pass + elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")): + pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示 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": - await self.chat.finish_ai(content or self.chat.ai_text) + # complete 携带完整全文,用于对齐已显示内容 + await self.chat.finish_ai(content) elif t == "dialogue_ended": await self.chat.finish_ai(self.chat.ai_text) if 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": sub = data.get("msg_type", "") title = data.get("task_title", "未知任务") @@ -213,7 +234,8 @@ class BotOpsApp(App): await self.notify_system(f"✅ 已连接到 {self.ws.uri}") elif state == "disconnected": self._refresh_status("") - await self.notify_system(f"❌ {detail}") + await self.notify_system( + f"❌ {detail}(中断期间的服务端输出已丢失,如回复不完整请重发)") elif state == "reconnecting": self._refresh_status(detail) elif state == "error": diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 5b83857..718ef2c 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -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.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): """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 @@ -26,11 +19,11 @@ class ChatView(VerticalScroll): def __init__(self, **kwargs): super().__init__(**kwargs) self._ai_widget: Markdown | None = None - self._ai_prev = "" + self._ai_text = "" @property def ai_text(self) -> str: - return self._ai_prev + return self._ai_text def _anchor_bottom(self): """锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。""" @@ -48,34 +41,50 @@ class ChatView(VerticalScroll): """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" if self._ai_widget is not None: return - self._ai_prev = "" + self._ai_text = "" self._ai_widget = Markdown("", classes="msg-ai") await self.mount(self._ai_widget) self._anchor_bottom() - async def append_ai(self, full_text: str): - """以累计全文增量渲染(服务端 chunk 为累计文本)。""" + async def append_ai(self, delta: str): + """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" + if not delta: + return if self._ai_widget is None: await self.start_ai() - d = _delta(self._ai_prev, full_text) - if d: - self._ai_widget.append(d) - self._ai_prev = full_text - # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 - self.call_after_refresh(self.scroll_end, animate=False) + self._ai_widget.append(delta) + self._ai_text += delta + # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 + self.call_after_refresh(self.scroll_end, animate=False) async def finish_ai(self, full_text: str = ""): - """收尾:用最终全文对齐一次(防中途丢帧),随后关闭本条 AI 消息。""" - if self._ai_widget is None and full_text: - await self.start_ai() - if full_text and full_text != self._ai_prev: - await self.append_ai(full_text) + """收尾一条 AI 消息。 + + - full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐; + - full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。 + """ + w = self._ai_widget 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) async def reset(self): """清空聊天区(/clear)。""" self._ai_widget = None - self._ai_prev = "" + self._ai_text = "" await self.remove_children() diff --git a/BotOps/ws_client.py b/BotOps/ws_client.py index 01b3d83..61719e0 100644 --- a/BotOps/ws_client.py +++ b/BotOps/ws_client.py @@ -4,10 +4,12 @@ 发送:{"type": "message", "content": ...} {"type": "command", "command": "/..."} {"type": "interrupt", "content": ...} - 接收:chunk / complete / dialogue_ended / task_process / task_status / task_error / - interrupt_ack / error / info / command_result / connection_ack / notification + 接收:chunk / line / reasoning / complete / dialogue_ended / dialogue_reset / + 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 json