问题根因: 1. ChatView.end_reasoning() 不清空 _reasoning_widget 引用,导致 多轮工具调用时 start_reasoning() 直接 return,所有轮次的思考 内容都被追加到第一个思考区域(在聊天区最顶部)。 2. AI widget 在工具调用期间不重置,导致后续轮次的思考区域被 挂载在AI widget中间,顺序混乱。 修复: 1. end_reasoning()/abort_reasoning() 更新标签后清除引用(widget 保留在DOM显示),下一轮 reasoning_start 创建新的思考区域。 2. 新增 ai_break 协议信号:服务端在工具调用执行前发送,前端 break_ai() 清除AI widget引用(保留已显示内容),后续chunk 创建新的AI widget,确保思考区域按时间顺序插入在两段AI文本之间。 3. ai_break 前flush chunk_buffer,防止工具调用前的最后文本片段丢失。
282 lines
11 KiB
Python
282 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""BotOps — aibox 智能体的现代化 TUI 前端(Textual)。
|
||
|
||
复刻并增强 aibox/client.py:
|
||
- AI 回复流式 Markdown 渲染(Markdown 部件增量追加,无闪烁)
|
||
- 断线指数退避自动重连(/disconnect 抑制)
|
||
- 任务事件独立侧栏(task_process / task_status / task_error)
|
||
- Ctrl+C 中断生成、Ctrl+Q 退出、Ctrl+L 清屏
|
||
|
||
用法:
|
||
python app.py [ws://host:port] # 默认 ws://localhost:8765
|
||
"""
|
||
import sys
|
||
|
||
from textual.app import App, ComposeResult
|
||
from textual.binding import Binding
|
||
from textual.containers import Horizontal
|
||
from textual.widgets import Footer, Header, Input, RichLog
|
||
|
||
from commands import CommandRegistry
|
||
from widgets.chat_view import ChatView
|
||
from widgets.status_bar import StatusBar
|
||
from ws_client import BotWSClient
|
||
|
||
DEFAULT_URI = "ws://localhost:8765"
|
||
|
||
|
||
class BotOpsApp(App):
|
||
TITLE = "BotOps"
|
||
SUB_TITLE = "aibox TUI"
|
||
|
||
CSS = """
|
||
#chat {
|
||
width: 3fr;
|
||
border: solid $primary;
|
||
}
|
||
#tasks {
|
||
width: 1fr;
|
||
border: solid $secondary;
|
||
}
|
||
.msg-user {
|
||
color: cyan;
|
||
padding: 0 1;
|
||
margin-top: 1;
|
||
}
|
||
.msg-ai {
|
||
padding: 0 1;
|
||
}
|
||
.msg-system {
|
||
color: $text-muted;
|
||
padding: 0 1;
|
||
}
|
||
.msg-error {
|
||
color: red;
|
||
padding: 0 1;
|
||
}
|
||
.msg-task {
|
||
color: yellow;
|
||
padding: 0 1;
|
||
}
|
||
.msg-reasoning {
|
||
color: $text-muted;
|
||
background: $surface;
|
||
padding: 0 1;
|
||
margin: 0 1;
|
||
text-style: italic;
|
||
}
|
||
#status {
|
||
height: 1;
|
||
}
|
||
"""
|
||
|
||
BINDINGS = [
|
||
Binding("ctrl+c", "interrupt", "中断", show=True),
|
||
Binding("ctrl+q", "quit", "退出", show=True),
|
||
Binding("ctrl+l", "clear", "清屏", show=True),
|
||
]
|
||
|
||
def __init__(self, uri: str = DEFAULT_URI):
|
||
super().__init__()
|
||
self.ws = BotWSClient(uri)
|
||
self.ws.on_message = self._on_ws_message
|
||
self.ws.on_state = self._on_ws_state
|
||
self.registry = CommandRegistry(self)
|
||
|
||
# ── 布局 ─────────────────────────────────────────────────────────────
|
||
def compose(self) -> 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回复开始状态(不预创建空widget)
|
||
if not await self.ws.send_message(text):
|
||
await self.chat.finish_ai("") # 发送失败:重置AI状态
|
||
await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error")
|
||
|
||
# ── 快捷键 ────────────────────────────────────────────────────────────
|
||
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":
|
||
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
|
||
await self.chat.append_ai(content)
|
||
elif t == "reasoning_start":
|
||
# 开始深度思考,创建思考区域
|
||
await self.chat.start_reasoning(content or "🤔 深度思考中...")
|
||
elif t == "reasoning":
|
||
# 思考内容增量
|
||
stripped = content
|
||
if stripped:
|
||
await self.chat.append_reasoning(stripped)
|
||
elif t == "reasoning_end":
|
||
# 思考结束
|
||
await self.chat.end_reasoning()
|
||
elif t == "line":
|
||
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
|
||
stripped = content.strip()
|
||
if not stripped:
|
||
pass
|
||
elif stripped.startswith((
|
||
"💬 助手", "💬 Assistant", # 助手前缀(聊天区已有对应区域)
|
||
"🤔 思考中", "🤔 Thinking", # 思考提示(聊天区已有思考区域)
|
||
"🤔 深度思考中",
|
||
"🤔 分析工具结果", "🤔 Analyzing", # 工具分析提示(后续有具体工具输出,此为冗余)
|
||
"🔧 需要调用工具", "🔧 Need to call",# 工具调用前置提示(后续有"调用工具: xxx"具体提示)
|
||
)):
|
||
pass # 冗余/有替代展示的提示行,过滤避免重复
|
||
else:
|
||
# 🔧 调用工具: xxx / 工具输出 / 用户中断提示等 → 任务侧栏
|
||
self.tasks.write(stripped)
|
||
elif t == "ai_break":
|
||
# 工具调用前分段:结束当前AI文本段(保留已显示内容),后续chunk创建新widget
|
||
await self.chat.break_ai()
|
||
elif t == "complete":
|
||
# 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", "未知任务")
|
||
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":
|
||
# 服务端确认中断:立即收尾前端状态(更新思考区域为已中断、结束AI回复)
|
||
await self.chat.abort_reasoning()
|
||
await self.chat.finish_ai("")
|
||
await self.notify_system(f"🛑 {content}")
|
||
elif t == "error":
|
||
await self.notify_system(f"❌ {content}", level="error")
|
||
elif t == "info":
|
||
# 过滤掉对话开始这类隐式通知(用户发消息自然知道对话开始了)
|
||
skip_keywords = ("Conversation started", "对话开始", "🎭")
|
||
if not any(k in content for k in skip_keywords):
|
||
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:
|
||
# 未知消息类型不追加到AI聊天区(避免非AI内容污染回复),仅记入侧栏日志
|
||
self.tasks.write(f"[未知消息 type={t}] {str(content)[:200]}")
|
||
|
||
# ── 连接状态回调 ──────────────────────────────────────────────────────
|
||
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()
|