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

149 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BotOps WebSocket 协议层(提炼自 aibox/client.py,增加状态回调与自动重连)。
协议:JSON 消息。
发送:{"type": "message", "content": ...}
{"type": "command", "command": "/..."}
{"type": "interrupt", "content": ...}
接收: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 为**增量片段**(output_callback 发送 chunk_buffer 后即清零),
complete 为完整全文(可用于对齐);line 为过程行(工具提示/输出/前缀)。
"""
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