From f91d4166d6e5b562018f61034b08e6ee2254e4e7 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:41:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20BotOps=20=E5=AD=90?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E2=80=94=E2=80=94aibox=20=E7=9A=84=20Textual?= =?UTF-8?q?=20TUI=20=E5=89=8D=E7=AB=AF=EF=BC=88=E6=B5=81=E5=BC=8F=20Markdo?= =?UTF-8?q?wn=20=E6=B8=B2=E6=9F=93+=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BF=9E+?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=BE=A7=E6=A0=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/README.md | 46 ++++ BotOps/__pycache__/app.cpython-312.pyc | Bin 0 -> 14011 bytes BotOps/__pycache__/commands.cpython-312.pyc | Bin 0 -> 5067 bytes BotOps/__pycache__/ws_client.cpython-312.pyc | Bin 0 -> 9315 bytes BotOps/app.py | 230 ++++++++++++++++++ BotOps/commands.py | 62 +++++ BotOps/requirements.txt | 2 + BotOps/widgets/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 116 bytes .../__pycache__/chat_view.cpython-312.pyc | Bin 0 -> 4469 bytes .../__pycache__/status_bar.cpython-312.pyc | Bin 0 -> 978 bytes BotOps/widgets/chat_view.py | 69 ++++++ BotOps/widgets/status_bar.py | 14 ++ BotOps/ws_client.py | 146 +++++++++++ 14 files changed, 569 insertions(+) create mode 100644 BotOps/README.md create mode 100644 BotOps/__pycache__/app.cpython-312.pyc create mode 100644 BotOps/__pycache__/commands.cpython-312.pyc create mode 100644 BotOps/__pycache__/ws_client.cpython-312.pyc create mode 100644 BotOps/app.py create mode 100644 BotOps/commands.py create mode 100644 BotOps/requirements.txt create mode 100644 BotOps/widgets/__init__.py create mode 100644 BotOps/widgets/__pycache__/__init__.cpython-312.pyc create mode 100644 BotOps/widgets/__pycache__/chat_view.cpython-312.pyc create mode 100644 BotOps/widgets/__pycache__/status_bar.cpython-312.pyc create mode 100644 BotOps/widgets/chat_view.py create mode 100644 BotOps/widgets/status_bar.py create mode 100644 BotOps/ws_client.py diff --git a/BotOps/README.md b/BotOps/README.md new file mode 100644 index 0000000..ff6303f --- /dev/null +++ b/BotOps/README.md @@ -0,0 +1,46 @@ +# BotOps — aibox 智能体 TUI 前端 + +复刻并增强 [aibox/client.py](../aibox/client.py),基于 [Textual](https://textual.textualize.io/) 的现代化终端 UI,连接 aibox 微服务(WebSocket,默认 `ws://localhost:8765`)。 + +## 相比 client.py 的增强 + +| 能力 | client.py | BotOps | +|------|-----------|--------| +| AI 回复渲染 | 纯文本流式 / 一次性静态渲染 | **Markdown 增量流式渲染**(无闪烁) | +| 终端管理 | 手搓光标/提示符 | Textual 部件框架(多面板布局) | +| 任务事件 | 混在对话流里 | **独立侧栏**(task_process / task_status / task_error) | +| 自动重连 | ✅(已移植) | ✅ 指数退避 2s→30s,`/disconnect` 抑制 | +| 快捷键 | Ctrl+C 中断 | Ctrl+C 中断 / Ctrl+Q 退出 / Ctrl+L 清屏 | + +## 安装与运行 + +```bash +cd BotOps +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +python app.py # 默认 ws://localhost:8765 +python app.py ws://your-host:8765 # 指定 aibox 地址 +``` + +## 命令 + +- 本地命令(不发给服务器):`/connect` `/disconnect` `/interrupt` `/clear` `/status` `/help` `/quit` +- 其余 `/` 命令透传给 aibox 服务器(与 client.py 行为一致) + +## 结构 + +``` +BotOps/ +├── app.py # Textual App:布局 + 消息路由 + 快捷键 +├── ws_client.py # WebSocket 协议层(自动重连) +├── commands.py # 斜杠命令注册表 +└── widgets/ + ├── chat_view.py # 聊天区:AI 回复 Markdown 增量流式渲染 + └── status_bar.py # 底部状态栏(连接状态) +``` + +## 协议说明 + +与 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` diff --git a/BotOps/__pycache__/app.cpython-312.pyc b/BotOps/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dca1f4d4d0d6e633913b8d39896906927ee0ff50 GIT binary patch literal 14011 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 literal 0 HcmV?d00001 diff --git a/BotOps/__pycache__/ws_client.cpython-312.pyc b/BotOps/__pycache__/ws_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14441e9afd67bd8c8138b1d6c79c17d6928146e4 GIT binary patch literal 9315 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 ComposeResult: + yield Header() + with Horizontal(): + yield ChatView(id="chat") + yield RichLog(id="tasks", max_lines=1000) + yield Input(placeholder="输入消息,/help 查看命令,Enter 发送", id="input") + yield StatusBar(id="status") + yield Footer() + + async def on_mount(self): + self.query_one("#input", Input).focus() + self._refresh_status("") + self.run_worker(self.connect_to(self.ws.uri)) + + @property + def chat(self) -> ChatView: + return self.query_one("#chat", ChatView) + + @property + def tasks(self) -> RichLog: + return self.query_one("#tasks", RichLog) + + # ── 连接管理 ────────────────────────────────────────────────────────── + async def connect_to(self, uri: str): + if self.ws.connected: + await self.ws.disconnect() + self.ws.uri = uri + await self.notify_system(f"正在连接 {uri} …") + await self.ws.connect() + + async def disconnect(self): + await self.ws.disconnect() + + async def send_interrupt(self): + if await self.ws.send_interrupt(): + await self.notify_system("🛑 已发送中断信号") + else: + await self.notify_system("未连接到服务器", level="error") + + # ── UI 操作 ─────────────────────────────────────────────────────────── + async def notify_system(self, text: str, level: str = "info"): + css = {"info": "msg-system", "error": "msg-error", "task": "msg-task"}.get(level, "msg-system") + await self.chat.add_system(text, css) + + async def clear_chat(self): + await self.chat.reset() + + def _refresh_status(self, extra: str): + self.query_one("#status", StatusBar).set_state( + self.ws.connected, self.ws.uri if self.ws.connected else "", extra) + + # ── 输入处理 ────────────────────────────────────────────────────────── + async def on_input_submitted(self, event: Input.Submitted): + text = event.value.strip() + event.input.value = "" + if not text: + return + if text.startswith("/") and await self.registry.execute(text): + return + if not self.ws.connected: + await self.notify_system("未连接到服务器,先 /connect", level="error") + return + await self.chat.add_user(text) + await self.chat.start_ai() # 预建 AI 消息框,即时反馈 + await self.ws.send_message(text) + + # ── 快捷键 ──────────────────────────────────────────────────────────── + async def action_interrupt(self): + await self.send_interrupt() + + async def action_clear(self): + await self.clear_chat() + + def action_quit(self): + self.exit() + + # ── aibox 协议消息路由(对应 client.py 的 _handle_message)───────────── + async def _on_ws_message(self, data: dict): + t = data.get("type", "") + 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") + else: + await self.chat.append_ai(content) + elif t == "complete": + await self.chat.finish_ai(content or self.chat.ai_text) + elif t == "dialogue_ended": + await self.chat.finish_ai(self.chat.ai_text) + if content: + await self.notify_system(f"✅ {content}") + elif t == "task_process": + sub = data.get("msg_type", "") + title = data.get("task_title", "未知任务") + if sub == "chunk": + tail = str(data.get("content", ""))[-200:] + if tail: + self.tasks.write(f"⚡ {title}: {tail}") + elif sub == "complete": + self.tasks.write(f"✅ 任务完成: {title}") + elif t == "task_status": + icon = {"executing": "⚙️", "complete": "✅", "failed": "❌", + "added": "➕", "batch_start": "🚀", "batch_complete": "🏁" + }.get(data.get("status", ""), "📋") + self.tasks.write( + f"{icon} [{data.get('task_id', '?')}] {data.get('title', '')}: " + f"{data.get('message', content)}") + elif t == "task_error": + self.tasks.write( + f"❌ [{data.get('task_id', '?')}] {data.get('task_title', '?')}: " + f"{data.get('error', '未知错误')}") + elif t == "interrupt_ack": + await self.notify_system(f"🛑 {content}") + elif t == "error": + await self.notify_system(f"❌ {content}", level="error") + elif t == "info": + await self.notify_system(f"ℹ️ {content}") + elif t == "command_result": + await self.notify_system(content) + elif t == "connection_ack": + await self.notify_system(f"✅ {content}") + elif t == "notification": + await self.notify_system(f"🔔 {content}") + elif content: + await self.chat.append_ai(content) + + # ── 连接状态回调 ────────────────────────────────────────────────────── + async def _on_ws_state(self, state: str, detail: str): + if state == "connected": + self._refresh_status("") + await self.notify_system(f"✅ 已连接到 {self.ws.uri}") + elif state == "disconnected": + self._refresh_status("") + await self.notify_system(f"❌ {detail}") + elif state == "reconnecting": + self._refresh_status(detail) + elif state == "error": + self._refresh_status("") + await self.notify_system(detail, level="error") + + +def main(): + uri = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URI + BotOpsApp(uri).run() + + +if __name__ == "__main__": + main() diff --git a/BotOps/commands.py b/BotOps/commands.py new file mode 100644 index 0000000..02599d0 --- /dev/null +++ b/BotOps/commands.py @@ -0,0 +1,62 @@ +"""斜杠命令注册表:/connect /disconnect /interrupt /clear /status /help /quit。 + +本地命令在此拦截处理;未注册的 / 命令透传给 aibox 服务器(与 client.py 行为一致)。 +""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app import BotOpsApp + + +class CommandRegistry: + def __init__(self, app: "BotOpsApp"): + self.app = app + self._commands = { + "/connect": ("连接服务器:/connect ws://host:port", self._connect), + "/disconnect": ("断开连接(不自动重连)", self._disconnect), + "/interrupt": ("中断当前生成(或按 Ctrl+C)", self._interrupt), + "/clear": ("清空聊天区", self._clear), + "/status": ("显示连接状态", self._status), + "/help": ("显示帮助", self._help), + "/quit": ("退出 BotOps", self._quit), + } + + async def execute(self, line: str) -> bool: + """是本地命令则执行并返回 True;否则返回 False(调用方应透传服务器)。""" + cmd = line.split()[0].lower() + entry = self._commands.get(cmd) + if entry is None: + return False + await entry[1](line) + return True + + async def _connect(self, line: str): + parts = line.split() + if len(parts) < 2: + await self.app.notify_system("用法: /connect ws://host:port", level="error") + return + await self.app.connect_to(parts[1]) + + async def _disconnect(self, _line: str): + await self.app.disconnect() + + async def _interrupt(self, _line: str): + await self.app.send_interrupt() + + async def _clear(self, _line: str): + await self.app.clear_chat() + + async def _status(self, _line: str): + if self.app.ws.connected: + await self.app.notify_system(f"✅ 已连接: {self.app.ws.uri}") + else: + await self.app.notify_system("○ 未连接") + + async def _help(self, _line: str): + lines = ["可用命令:"] + [f" {k:<14} {v[0]}" for k, v in self._commands.items()] + lines.append(" 其余 / 命令将透传给 aibox 服务器") + await self.app.notify_system("\n".join(lines)) + + async def _quit(self, _line: str): + await self.app.ws.disconnect() + self.app.exit() diff --git a/BotOps/requirements.txt b/BotOps/requirements.txt new file mode 100644 index 0000000..f89aaf5 --- /dev/null +++ b/BotOps/requirements.txt @@ -0,0 +1,2 @@ +textual>=0.60.0 +websockets>=11.0 diff --git a/BotOps/widgets/__init__.py b/BotOps/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/BotOps/widgets/__pycache__/__init__.cpython-312.pyc b/BotOps/widgets/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d10886c249b308805ea18f38a26856a5ea9c672 GIT binary patch literal 116 zcmX@j%ge<81X24kvOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!5-!h7Nlz^)){l?R p%*!l^kJl@xyv1RYo1apelWJGQ3RJ@g#Kj=SM`lJw#v*1Q3jpCP7X$zR literal 0 HcmV?d00001 diff --git a/BotOps/widgets/__pycache__/chat_view.cpython-312.pyc b/BotOps/widgets/__pycache__/chat_view.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5501fc8416f2d3406d1d4f923c49d4dd0e25c9b GIT binary patch literal 4469 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 u5_*jBUG)Ax(VBmu)d_gqr2ZGp6QRri literal 0 HcmV?d00001 diff --git a/BotOps/widgets/__pycache__/status_bar.cpython-312.pyc b/BotOps/widgets/__pycache__/status_bar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b41b6597370ca1e4617eac7b9945acee4e878eb GIT binary patch literal 978 zcmZ8gO-vI(6rTOrXwpvIUO4+Nvpq#V4JkWJYc5K_80yBnohlA=8n zj8YGVMZlDUNtJ^?2Q|iscQ0@-p_6zKTjeCCcrhNF*{w)?hxfksy_xrBzS;dyTU!ID z^}f40@|y!-gC&1Ws)EXK3T6QWF#tKpC%92Q#&duN7XTD80E&oT7W}m_VO)yHb~nvR zZ|-m2Dtw)OSx!%u^E2PyKKQosuzdHC&!`=0dEriJdZ{#5C@nlIEv6~6IhQF-=fA8h zl_#HWq;GDro)M17SFE@-tn2;=-v_EmrImtNfIy6+=i{LaIV3D zN{%!SpzMg$2fT_xE5UiNC{M@^j}=FTH4aa6K2-|<q@U)_-1~I!eX3}DXIwC;BB9d+-3`|6eie$2y>f5iT>%(veS!S2%39Ajn zap%}L37W{#7===eJCb+qkZgp>o-z%J&FU1&qpVOoc=TSN(4Ljn8}{Z-<&L=x?g_Wu zwVt0@y8OOlwJo|@*P9iKp*`#2`rLrq=t{XuYvIG$o?@t;QOG^-p3e2Ih1;?{>q>Yg zG!@GAd{hop=ok=<@C4xf6h&mBXj(EpiZqSXXxgX&?F7SNO}l2t6V-}1U>FG^m=?zS z=&r{AH_?C_nPb0%=NK>#QI`QfctvW-pWFg86m&1VHn&;kZ)8iO5s?Hm4H`NfSI4BJ z^E#^nV)m|nYu54KIHH{O&v*_Wp#}Cen9bB}2^_~2!NpIYbz2T{EjjropuyYt2eC&M AV*mgE literal 0 HcmV?d00001 diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py new file mode 100644 index 0000000..59b8517 --- /dev/null +++ b/BotOps/widgets/chat_view.py @@ -0,0 +1,69 @@ +"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。""" +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,实现流式渲染。""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._ai_widget: Markdown | None = None + self._ai_prev = "" + + @property + def ai_text(self) -> str: + return self._ai_prev + + async def add_user(self, text: str): + await self.mount(Static(f"👤 你: {text}", classes="msg-user", markup=False)) + + async def add_system(self, text: str, css_class: str = "msg-system"): + await self.mount(Static(text, classes=css_class, markup=False)) + + async def start_ai(self): + """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" + if self._ai_widget is not None: + return + self._ai_prev = "" + self._ai_widget = Markdown("", classes="msg-ai") + await self.mount(self._ai_widget) + self.scroll_end(animate=False) + + async def append_ai(self, full_text: str): + """以累计全文增量渲染(服务端 chunk 为累计文本)。""" + 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 + 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) + self._ai_widget = None + self._ai_prev = "" + self.scroll_end(animate=False) + + async def reset(self): + """清空聊天区(/clear)。""" + self._ai_widget = None + self._ai_prev = "" + await self.remove_children() diff --git a/BotOps/widgets/status_bar.py b/BotOps/widgets/status_bar.py new file mode 100644 index 0000000..04ddd2a --- /dev/null +++ b/BotOps/widgets/status_bar.py @@ -0,0 +1,14 @@ +"""底部状态栏:连接状态 + 服务器地址 + 附加信息。""" +from textual.widgets import Static + + +class StatusBar(Static): + def set_state(self, connected: bool, uri: str = "", extra: str = ""): + dot = "●" if connected else "○" + color = "green" if connected else "red" + text = f"[{color}]{dot}[/] {'已连接' if connected else '未连接'}" + if uri: + text += f" {uri}" + if extra: + text += f" | {extra}" + self.update(text) diff --git a/BotOps/ws_client.py b/BotOps/ws_client.py new file mode 100644 index 0000000..01b3d83 --- /dev/null +++ b/BotOps/ws_client.py @@ -0,0 +1,146 @@ +"""BotOps WebSocket 协议层(提炼自 aibox/client.py,增加状态回调与自动重连)。 + +协议:JSON 消息。 + 发送:{"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 为**累计全文**(与 aibox/client.py 前缀匹配逻辑一致)。 +""" +import asyncio +import json +from typing import Awaitable, Callable, Optional + +import websockets + +MessageHandler = Callable[[dict], Awaitable[None]] +StateHandler = Callable[[str, str], Awaitable[None]] # (state, detail) + + +class BotWSClient: + """aibox 协议 WebSocket 客户端:断线指数退避自动重连,手动断开则抑制。""" + + def __init__(self, uri: str): + self.uri = uri + self.ws = None + self.connected = False + self._manual_disconnect = False + self._listen_task: Optional[asyncio.Task] = None + self._reconnect_task: Optional[asyncio.Task] = None + self.on_message: Optional[MessageHandler] = None + self.on_state: Optional[StateHandler] = None + + async def _emit_state(self, state: str, detail: str = ""): + if self.on_state: + await self.on_state(state, detail) + + async def connect(self) -> bool: + """连接并开始监听;成功返回 True。""" + await self._close_ws() + try: + self.ws = await websockets.connect(self.uri) + except Exception as e: + await self._emit_state("error", f"连接失败: {e}") + return False + self.connected = True + self._manual_disconnect = False + self._listen_task = asyncio.create_task(self._listen()) + await self._emit_state("connected", self.uri) + return True + + async def disconnect(self): + """手动断开(抑制自动重连)。""" + self._manual_disconnect = True + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + self.connected = False + await self._close_ws() + if self._listen_task and not self._listen_task.done(): + self._listen_task.cancel() + await self._emit_state("disconnected", "已手动断开") + + async def _close_ws(self): + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + self.ws = None + + async def send_message(self, text: str) -> bool: + """发送用户消息(/ 开头按 command 发送,与 aibox 协议一致)。""" + if not self.connected or not self.ws: + return False + data = ({"type": "command", "command": text} if text.startswith("/") + else {"type": "message", "content": text}) + try: + await self.ws.send(json.dumps(data, ensure_ascii=False)) + return True + except Exception as e: + await self._emit_state("error", f"发送失败: {e}") + return False + + async def send_interrupt(self) -> bool: + """发送中断信号(对应 client.py 的 Ctrl+C 行为)。""" + if not self.connected or not self.ws: + return False + try: + await self.ws.send(json.dumps( + {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}, + ensure_ascii=False)) + return True + except Exception: + return False + + async def _listen(self): + """接收循环:断线时在 finally 中触发自动重连。""" + try: + async for raw in self.ws: + try: + data = json.loads(raw) + except json.JSONDecodeError: + data = {"type": "chunk", "content": raw} + if self.on_message: + await self.on_message(data) + except websockets.exceptions.ConnectionClosed: + pass + except asyncio.CancelledError: + raise + except Exception as e: + await self._emit_state("error", f"接收错误: {e}") + finally: + was_connected = self.connected + self.connected = False + if was_connected: + await self._emit_state("disconnected", "连接已断开") + self._schedule_reconnect() + + def _schedule_reconnect(self): + if self._manual_disconnect: + return + if self._reconnect_task and not self._reconnect_task.done(): + return + self._reconnect_task = asyncio.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self): + """指数退避 2s→30s 封顶,直到成功或手动断开。""" + delay = 2 + while not self._manual_disconnect and not self.connected: + await self._emit_state("reconnecting", f"{delay}s 后自动重连 {self.uri} …") + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + if self._manual_disconnect: + return + try: + self.ws = await websockets.connect(self.uri) + except Exception: + delay = min(delay * 2, 30) + continue + self.connected = True + self._listen_task = asyncio.create_task(self._listen()) + await self._emit_state("connected", self.uri) + return