844 lines
33 KiB
Python
844 lines
33 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
AI智能助手 - 命令行前端
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import sys
|
||
import os
|
||
import threading
|
||
import hashlib
|
||
from collections import OrderedDict
|
||
from datetime import datetime
|
||
from typing import Optional, Dict, Any, List
|
||
import websockets
|
||
from colorama import init, Fore, Style as ColoramaStyle
|
||
|
||
# rich 为可选依赖:有则流式渲染 Markdown,无则回退纯文本流式输出
|
||
try:
|
||
from rich.console import Console
|
||
from rich.live import Live
|
||
from rich.markdown import Markdown
|
||
_RICH = True
|
||
except ImportError:
|
||
_RICH = False
|
||
|
||
init(autoreset=True)
|
||
|
||
class Colors:
|
||
USER = Fore.CYAN
|
||
AI = Fore.GREEN
|
||
TASK = Fore.YELLOW
|
||
SYSTEM = Fore.BLUE
|
||
ERROR = Fore.RED
|
||
INFO = Fore.MAGENTA
|
||
RESET = ColoramaStyle.RESET_ALL
|
||
BOLD = ColoramaStyle.BRIGHT
|
||
|
||
GREEN = Fore.GREEN
|
||
RED = Fore.RED
|
||
|
||
class WebSocketClient:
|
||
def __init__(self, uri: str, callback):
|
||
self.uri = uri
|
||
self.websocket = None
|
||
self.connected = False
|
||
self.callback = callback
|
||
|
||
async def connect(self):
|
||
try:
|
||
self.websocket = await websockets.connect(self.uri)
|
||
self.connected = True
|
||
return True
|
||
except Exception as e:
|
||
self.callback("error", f"连接失败: {e}")
|
||
return False
|
||
|
||
async def disconnect(self):
|
||
if self.websocket:
|
||
await self.websocket.close()
|
||
self.connected = False
|
||
|
||
async def send_message(self, message: str):
|
||
if not self.websocket or not self.connected:
|
||
return False
|
||
|
||
try:
|
||
if message.startswith('/'):
|
||
data = {'type': 'command', 'command': message}
|
||
else:
|
||
data = {'type': 'message', 'content': message}
|
||
|
||
await self.websocket.send(json.dumps(data))
|
||
return True
|
||
except Exception as e:
|
||
self.callback("error", f"发送失败: {e}")
|
||
return False
|
||
|
||
async def receive_messages(self):
|
||
try:
|
||
async for message in self.websocket:
|
||
try:
|
||
data = json.loads(message)
|
||
await self._handle_message(data)
|
||
except json.JSONDecodeError:
|
||
self.callback("chunk", message)
|
||
except websockets.exceptions.ConnectionClosed:
|
||
pass
|
||
except Exception as e:
|
||
self.callback("error", f"接收消息错误: {e}")
|
||
finally:
|
||
# 统一在此通知断线;手动 disconnect() 已把 connected 置 False,不会误触发自动重连
|
||
was_connected = self.connected
|
||
self.connected = False
|
||
if was_connected:
|
||
self.callback("disconnected", "❌ 连接已断开")
|
||
|
||
async def _handle_message(self, data: dict):
|
||
msg_type = data.get('type', '')
|
||
content = data.get('content', '')
|
||
|
||
if msg_type == 'chunk':
|
||
self.callback("chunk", content)
|
||
elif msg_type == 'complete':
|
||
self.callback("complete", content)
|
||
elif msg_type == 'dialogue_ended':
|
||
self.callback("dialogue_ended", content)
|
||
elif msg_type == 'task_process':
|
||
msg_subtype = data.get('msg_type', '')
|
||
task_content = data.get('content', '')
|
||
task_title = data.get('task_title', '未知任务')
|
||
if msg_subtype == 'chunk':
|
||
self.callback("task_chunk", task_content, task_title)
|
||
elif msg_subtype == 'complete':
|
||
self.callback("task_complete", task_title)
|
||
elif msg_type == 'task_status':
|
||
task_id = data.get('task_id', 'unknown')
|
||
title = data.get('title', '未知')
|
||
status = data.get('status', 'unknown')
|
||
message = data.get('message', content)
|
||
status_icon = {'executing': '⚙️', 'complete': '✅', 'failed': '❌', 'added': '➕', 'batch_start': '🚀', 'batch_complete': '🏁'}.get(status, '📋')
|
||
self.callback("info", f"{status_icon} [任务 {task_id}] {title}: {message}")
|
||
elif msg_type == 'task_error':
|
||
self.callback("error", f"[任务 {data.get('task_id', 'unknown')}] {data.get('task_title', '未知')}: {data.get('error', '未知错误')}")
|
||
elif msg_type == 'interrupt_ack':
|
||
self.callback("info", f"🛑 {content}")
|
||
elif msg_type == 'error':
|
||
self.callback("error", content)
|
||
elif msg_type == 'info':
|
||
self.callback("info", content)
|
||
elif msg_type == 'command_result':
|
||
self.callback("system", content)
|
||
elif msg_type == 'connection_ack':
|
||
self.callback("system", f"✅ {content}")
|
||
elif msg_type == 'notification':
|
||
self.callback("info", f"🔔 {content}")
|
||
else:
|
||
if content:
|
||
self.callback("chunk", content)
|
||
|
||
class SimpleCLI:
|
||
def __init__(self):
|
||
self.client: Optional[WebSocketClient] = None
|
||
self.connected = False
|
||
self.running = True
|
||
|
||
# AI消息缓冲
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
|
||
# 已显示消息的去重(LRU 上限,防长对话内存无限增长)
|
||
self.DISPLAYED_MAX = 500
|
||
self.displayed_messages: OrderedDict = OrderedDict()
|
||
|
||
# 锁和状态
|
||
self.lock = threading.Lock()
|
||
self.current_input = ""
|
||
|
||
# 标准输入:单守护线程阻塞读 + asyncio.Queue 中转(避免 Ctrl+C 泄漏线程)
|
||
self._input_queue: asyncio.Queue = asyncio.Queue()
|
||
self._stdin_thread: Optional[threading.Thread] = None
|
||
|
||
# 自动重连状态
|
||
self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连
|
||
self._reconnect_task: Optional[asyncio.Task] = None
|
||
|
||
# Markdown 流式渲染状态(rich 可用时启用)
|
||
self._console = Console() if _RICH else None
|
||
self._md_live = None # rich.Live 实例(渲染进行中)
|
||
self._md_buffer = "" # 当前累计的 AI 回复全文
|
||
|
||
# 流式输出状态
|
||
self.streaming_line = False # 是否正在流式输出行
|
||
self.in_tool_phase = False # 是否在工具调用阶段
|
||
self.waiting_for_response = False # 是否在等待AI响应
|
||
self.prompt_shown = True # 提示符是否已显示
|
||
|
||
# 命令列表
|
||
self.commands = {
|
||
'/help': '显示帮助信息',
|
||
'/connect': '连接到服务器',
|
||
'/disconnect': '断开连接',
|
||
'/quit': '退出程序',
|
||
'/clear': '清屏',
|
||
'/status': '显示连接状态',
|
||
'/interrupt': '发送中断信号(或按 Ctrl+C)',
|
||
}
|
||
|
||
def _get_prompt(self) -> str:
|
||
status = "● 已连接" if self.connected else "○ 未连接"
|
||
color = Colors.GREEN if self.connected else Colors.RED
|
||
return f"{color}{status}{Colors.RESET} >>> "
|
||
|
||
def _print_line(self, line: str = "", end: str = "\n"):
|
||
"""打印一行,并恢复提示符"""
|
||
self._md_stream_stop() # 打印普通行前,先定格进行中的 Markdown 渲染
|
||
with self.lock:
|
||
current = self.current_input
|
||
sys.stdout.write('\r' + ' ' * 120 + '\r')
|
||
sys.stdout.write(line + end)
|
||
sys.stdout.flush()
|
||
if not self.waiting_for_response and not self.in_tool_phase:
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
self.prompt_shown = True
|
||
self.streaming_line = False
|
||
|
||
def _print_prompt(self, clear_input: bool = False):
|
||
"""显示提示符"""
|
||
with self.lock:
|
||
if clear_input:
|
||
self.current_input = ""
|
||
self.waiting_for_response = False
|
||
self.in_tool_phase = False
|
||
self.prompt_shown = True
|
||
sys.stdout.write('\r' + ' ' * 120 + '\r')
|
||
sys.stdout.write(self._get_prompt() + self.current_input)
|
||
sys.stdout.flush()
|
||
self.streaming_line = False
|
||
|
||
def _print_stream(self, text: str):
|
||
"""流式打印文本(不换行,不显示提示符)"""
|
||
with self.lock:
|
||
# 保存当前输入
|
||
current = self.current_input
|
||
|
||
if not self.streaming_line:
|
||
# 首次流式输出,先换行再打印前缀
|
||
sys.stdout.write('\n')
|
||
sys.stdout.write(f"{Colors.AI}🤖 AI助手: ")
|
||
self.streaming_line = True
|
||
self.prompt_shown = False
|
||
|
||
# 打印文本
|
||
sys.stdout.write(text)
|
||
sys.stdout.flush()
|
||
|
||
# 注意:不恢复提示符,保持在同一行
|
||
|
||
def _finish_stream(self):
|
||
"""完成流式输出,换行并恢复提示符"""
|
||
with self.lock:
|
||
if self.streaming_line:
|
||
sys.stdout.write('\n')
|
||
self.streaming_line = False
|
||
if not self.in_tool_phase and not self.waiting_for_response:
|
||
current = self.current_input
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
self.prompt_shown = True
|
||
|
||
def _clean_content(self, content: str) -> str:
|
||
if not content:
|
||
return ""
|
||
if '💬 助手:' in content:
|
||
content = content.split('💬 助手:', 1)[1].strip()
|
||
content = content.lstrip()
|
||
return content
|
||
|
||
def _show_streaming(self, content: str):
|
||
"""流式显示增量内容"""
|
||
if not content:
|
||
return
|
||
|
||
# 清理内容
|
||
clean = self._clean_content(content)
|
||
if not clean:
|
||
return
|
||
|
||
key = clean[:200]
|
||
if key in self.displayed_messages:
|
||
return
|
||
|
||
# rich 可用:累计文本直接整体渲染为 Markdown(服务端 chunk 为累计全文)
|
||
if _RICH:
|
||
with self.lock:
|
||
self.waiting_for_response = True
|
||
self.prompt_shown = False
|
||
self.streaming_line = True
|
||
self._md_stream_update(clean)
|
||
self.ai_previous = clean
|
||
self.ai_full = clean
|
||
return
|
||
|
||
# 检测增量
|
||
if self.ai_previous and clean.startswith(self.ai_previous):
|
||
new_part = clean[len(self.ai_previous):]
|
||
if new_part:
|
||
# 如果是首次流式输出,显示前缀
|
||
if not self.ai_prefix_shown:
|
||
self._print_stream("")
|
||
self.ai_prefix_shown = True
|
||
self._print_stream(new_part)
|
||
self.ai_previous = clean
|
||
self.ai_full = clean
|
||
return
|
||
|
||
if self.ai_previous and self.ai_previous in clean:
|
||
idx = clean.find(self.ai_previous) + len(self.ai_previous)
|
||
new_part = clean[idx:]
|
||
if new_part:
|
||
if not self.ai_prefix_shown:
|
||
self._print_stream("")
|
||
self.ai_prefix_shown = True
|
||
self._print_stream(new_part)
|
||
self.ai_previous = clean
|
||
self.ai_full = clean
|
||
return
|
||
|
||
# 全新内容 - 显示前缀和内容
|
||
if not self.ai_prefix_shown:
|
||
self._print_stream("")
|
||
self.ai_prefix_shown = True
|
||
self._print_stream(clean)
|
||
self.ai_previous = clean
|
||
self.ai_full = clean
|
||
|
||
# ── Markdown 流式渲染(rich)─────────────────────────────────────────
|
||
def _md_stream_start(self):
|
||
"""开启 Live 渲染区域(若 rich 不可用或已开启则忽略)。"""
|
||
if not _RICH or self._md_live is not None:
|
||
return
|
||
self._md_buffer = ""
|
||
# transient=False:结束后保留最终渲染结果;vertical_overflow=visible 不裁剪长文
|
||
self._md_live = Live(Markdown(""), console=self._console,
|
||
refresh_per_second=8, vertical_overflow="visible")
|
||
self._md_live.start()
|
||
|
||
def _md_stream_update(self, full_text: str):
|
||
"""用累计全文刷新渲染(服务端 chunk 即累计文本)。"""
|
||
if not _RICH:
|
||
return
|
||
self._md_stream_start()
|
||
self._md_buffer = full_text
|
||
self._md_live.update(Markdown(full_text))
|
||
|
||
def _md_stream_stop(self):
|
||
"""结束渲染:最终刷新一次后停住(内容定格保留在终端)。"""
|
||
if self._md_live is not None:
|
||
try:
|
||
if self._md_buffer:
|
||
self._md_live.update(Markdown(self._md_buffer))
|
||
self._md_live.stop()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self._md_live = None
|
||
|
||
def _remember_displayed(self, key: str):
|
||
"""记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。"""
|
||
self.displayed_messages[key] = None
|
||
self.displayed_messages.move_to_end(key)
|
||
if len(self.displayed_messages) > self.DISPLAYED_MAX:
|
||
self.displayed_messages.popitem(last=False)
|
||
|
||
def display(self, msg_type: str, content: str, extra: Any = None):
|
||
"""显示消息"""
|
||
if msg_type == 'chunk':
|
||
self._handle_chunk(content)
|
||
elif msg_type == 'complete':
|
||
self._handle_complete(content)
|
||
elif msg_type == 'task_chunk':
|
||
self._handle_task_chunk(content, extra)
|
||
elif msg_type == 'task_complete':
|
||
self._handle_task_complete(extra)
|
||
elif msg_type == 'dialogue_ended':
|
||
self._md_stream_stop()
|
||
with self.lock:
|
||
if self.streaming_line:
|
||
sys.stdout.write('\n')
|
||
self.streaming_line = False
|
||
self.in_tool_phase = False
|
||
self.waiting_for_response = False
|
||
self.prompt_shown = True
|
||
self._print_line(f"{Colors.SYSTEM}✅ {content}{Colors.RESET}")
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
self.displayed_messages.clear()
|
||
self.streaming_line = False
|
||
elif msg_type == 'system':
|
||
self._finish_stream()
|
||
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
|
||
elif msg_type == 'disconnected':
|
||
self.connected = False
|
||
self._finish_stream()
|
||
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
|
||
self._schedule_reconnect()
|
||
elif msg_type == 'info':
|
||
self._finish_stream()
|
||
self._print_line(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
|
||
elif msg_type == 'error':
|
||
self._finish_stream()
|
||
self._print_line(f"{Colors.ERROR}❌ {content}{Colors.RESET}")
|
||
|
||
def _handle_chunk(self, content: str):
|
||
"""处理流式块"""
|
||
# 检查是否是工具调用相关的消息
|
||
if content.startswith('🔧 调用工具'):
|
||
self._md_stream_stop() # 工具消息与 Markdown 渲染区域交替,先定格
|
||
with self.lock:
|
||
self.in_tool_phase = True
|
||
self.prompt_shown = False
|
||
# 打印工具调用信息
|
||
if self.streaming_line:
|
||
sys.stdout.write('\n')
|
||
self.streaming_line = False
|
||
sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n")
|
||
sys.stdout.flush()
|
||
return
|
||
|
||
# 检查是否是工具结果
|
||
if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')):
|
||
self._md_stream_stop()
|
||
with self.lock:
|
||
# 打印工具结果
|
||
sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n")
|
||
sys.stdout.flush()
|
||
return
|
||
|
||
# 如果之前在工具阶段,现在退出工具阶段
|
||
if self.in_tool_phase:
|
||
with self.lock:
|
||
self.in_tool_phase = False
|
||
# 工具结束后不立即显示提示符,等待AI继续响应
|
||
self.prompt_shown = False
|
||
|
||
# 系统消息
|
||
if content.startswith(('📩', '[', '📁', '🔧', '📝', '✅', 'ℹ️', '⏰')):
|
||
self._finish_stream()
|
||
self._print_line(f"{Colors.INFO}{content}{Colors.RESET}")
|
||
return
|
||
|
||
# 流式显示
|
||
self._show_streaming(content)
|
||
|
||
def _handle_complete(self, content: str):
|
||
"""处理完成事件 - 只负责结束流式输出,不重复显示内容"""
|
||
# rich 渲染路径:最终全文已在 Live 区域定格,只需收尾并恢复提示符
|
||
if _RICH:
|
||
clean = self._clean_content(content) if content else ""
|
||
if clean:
|
||
self._md_stream_update(clean)
|
||
self._remember_displayed(clean[:200])
|
||
self._md_stream_stop()
|
||
with self.lock:
|
||
self.in_tool_phase = False
|
||
self.waiting_for_response = False
|
||
self.streaming_line = False
|
||
self.prompt_shown = True
|
||
current = self.current_input
|
||
sys.stdout.write('\r' + ' ' * 120 + '\r')
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
return
|
||
|
||
# 如果之前在工具阶段,现在退出
|
||
if self.in_tool_phase:
|
||
with self.lock:
|
||
self.in_tool_phase = False
|
||
self.waiting_for_response = False
|
||
self.prompt_shown = True
|
||
current = self.current_input
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
self.streaming_line = False
|
||
return
|
||
|
||
# 如果流式输出正在进行,结束它
|
||
if self.streaming_line:
|
||
self._finish_stream()
|
||
|
||
# 如果内容不为空,检查是否已经通过流式显示了
|
||
if content and content.strip():
|
||
clean = self._clean_content(content)
|
||
if clean:
|
||
# 检查是否已通过流式显示
|
||
if self.ai_full and (clean in self.ai_full or self.ai_full in clean):
|
||
# 已经流式显示过了,不重复打印
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
self.streaming_line = False
|
||
# 确保提示符显示
|
||
with self.lock:
|
||
self.waiting_for_response = False
|
||
self.prompt_shown = True
|
||
current = self.current_input
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
return
|
||
|
||
# 检查是否已经在 displayed_messages 中
|
||
key = clean[:200]
|
||
if key in self.displayed_messages:
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
self.streaming_line = False
|
||
# 确保提示符显示
|
||
with self.lock:
|
||
self.waiting_for_response = False
|
||
self.prompt_shown = True
|
||
current = self.current_input
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
return
|
||
|
||
# 只有当内容没有被流式显示时才显示完整消息
|
||
self._remember_displayed(key)
|
||
# 如果之前没有流式输出,现在显示完整消息
|
||
self._print_line(f"{Colors.AI}🤖 AI助手: {clean}")
|
||
|
||
# 重置状态
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
self.streaming_line = False
|
||
self.waiting_for_response = False
|
||
# 确保提示符显示
|
||
with self.lock:
|
||
self.prompt_shown = True
|
||
current = self.current_input
|
||
sys.stdout.write('\r' + ' ' * 120 + '\r')
|
||
sys.stdout.write(self._get_prompt() + current)
|
||
sys.stdout.flush()
|
||
|
||
def _handle_task_chunk(self, content: str, task_title: str):
|
||
"""处理任务流式块"""
|
||
if not hasattr(self, '_task_buffers'):
|
||
self._task_buffers = {}
|
||
|
||
if task_title not in self._task_buffers:
|
||
self._task_buffers[task_title] = ""
|
||
self._finish_stream()
|
||
self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: ", end="")
|
||
self.streaming_line = True
|
||
|
||
prev = self._task_buffers[task_title]
|
||
if content.startswith(prev):
|
||
new_part = content[len(prev):]
|
||
if new_part:
|
||
with self.lock:
|
||
sys.stdout.write(new_part)
|
||
sys.stdout.flush()
|
||
self._task_buffers[task_title] = content
|
||
elif prev in content:
|
||
idx = content.find(prev) + len(prev)
|
||
new_part = content[idx:]
|
||
if new_part:
|
||
with self.lock:
|
||
sys.stdout.write(new_part)
|
||
sys.stdout.flush()
|
||
self._task_buffers[task_title] = content
|
||
else:
|
||
with self.lock:
|
||
sys.stdout.write(content)
|
||
sys.stdout.flush()
|
||
self._task_buffers[task_title] = content
|
||
|
||
def _handle_task_complete(self, task_title: str):
|
||
"""处理任务完成"""
|
||
self._finish_stream()
|
||
if hasattr(self, '_task_buffers') and task_title in self._task_buffers:
|
||
content = self._task_buffers[task_title]
|
||
if content:
|
||
clean = content.strip()
|
||
key = clean[:200]
|
||
if key not in self.displayed_messages:
|
||
self._remember_displayed(key)
|
||
self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: {clean}")
|
||
del self._task_buffers[task_title]
|
||
self.streaming_line = False
|
||
|
||
def print_banner(self):
|
||
banner = f"""
|
||
{Colors.BOLD}{Colors.AI}
|
||
╔══════════════════════════════════════════════════════════╗
|
||
║ AI智能助手 - 命令行前端 ║
|
||
║ WebSocket 直连 + CTRL+C 中断 ║
|
||
╚══════════════════════════════════════════════════════════╝
|
||
{Colors.RESET}
|
||
"""
|
||
self._print_line(banner)
|
||
self._print_line(f"{Colors.INFO}📌 输入 /help 查看可用命令")
|
||
self._print_line(f"{Colors.INFO}📌 按 Ctrl+C 发送中断信号 (而非退出)")
|
||
self._print_line(f"{Colors.INFO}📌 按 Ctrl+D 退出程序{Colors.RESET}")
|
||
self._print_line("")
|
||
self._print_line("=" * 60)
|
||
self._print_line("")
|
||
self._print_prompt(clear_input=True)
|
||
|
||
def show_help(self):
|
||
self._print_line("")
|
||
self._print_line(f"{Colors.BOLD}可用命令:{Colors.RESET}")
|
||
self._print_line("")
|
||
self._print_line(f"{Colors.USER}本地命令:{Colors.RESET}")
|
||
self._print_line(" /connect <url> 连接到WebSocket服务器")
|
||
self._print_line(" /disconnect 断开连接")
|
||
self._print_line(" /status 显示连接状态")
|
||
self._print_line(" /clear 清屏")
|
||
self._print_line(" /interrupt 发送中断信号 (或按 Ctrl+C)")
|
||
self._print_line(" /quit 退出程序 (或按 Ctrl+D)")
|
||
self._print_line(" /help 显示此帮助")
|
||
self._print_line("")
|
||
self._print_line(f"{Colors.AI}服务器命令 (需要先连接):{Colors.RESET}")
|
||
for cmd, desc in self.commands.items():
|
||
if cmd not in ['/help', '/connect', '/disconnect', '/quit', '/clear', '/status', '/interrupt']:
|
||
self._print_line(f" {cmd:<15} {desc}")
|
||
self._print_prompt()
|
||
|
||
async def handle_command(self, command: str):
|
||
parts = command.split()
|
||
cmd = parts[0].lower()
|
||
|
||
if cmd == '/help':
|
||
self.show_help()
|
||
elif cmd == '/connect':
|
||
if len(parts) < 2:
|
||
self.display("error", "用法: /connect ws://host:port")
|
||
return
|
||
uri = parts[1]
|
||
# 切换服务器:抑制旧连接的自动重连
|
||
self._manual_disconnect = True
|
||
if self._reconnect_task and not self._reconnect_task.done():
|
||
self._reconnect_task.cancel()
|
||
if self.client and self.client.connected:
|
||
await self.client.disconnect()
|
||
self.client = WebSocketClient(uri, self.display)
|
||
if await self.client.connect():
|
||
self._manual_disconnect = False
|
||
self.connected = True
|
||
self.display("system", f"✅ 已连接到 {uri}")
|
||
asyncio.create_task(self.client.receive_messages())
|
||
else:
|
||
self.client = None
|
||
self.connected = False
|
||
elif cmd == '/disconnect':
|
||
self._manual_disconnect = True # 主动断开,不自动重连
|
||
if self._reconnect_task and not self._reconnect_task.done():
|
||
self._reconnect_task.cancel()
|
||
if self.client:
|
||
await self.client.disconnect()
|
||
self.client = None
|
||
self.connected = False
|
||
self.display("system", "已断开连接")
|
||
else:
|
||
self.display("error", "未连接到服务器")
|
||
elif cmd == '/status':
|
||
if self.connected:
|
||
self.display("system", f"✅ 已连接: {self.client.uri}")
|
||
else:
|
||
self.display("system", "未连接")
|
||
elif cmd == '/clear':
|
||
self._md_stream_stop()
|
||
os.system('clear' if os.name == 'posix' else 'cls')
|
||
self.displayed_messages.clear()
|
||
self.current_input = ""
|
||
self.print_banner()
|
||
elif cmd == '/interrupt':
|
||
if self.client and self.client.connected:
|
||
await self._send_interrupt()
|
||
self.display("info", "🛑 已发送中断信号")
|
||
else:
|
||
self.display("error", "未连接到服务器")
|
||
elif cmd == '/quit':
|
||
self.display("system", "👋 再见!")
|
||
self.running = False
|
||
self._manual_disconnect = True
|
||
if self._reconnect_task and not self._reconnect_task.done():
|
||
self._reconnect_task.cancel()
|
||
if self.client:
|
||
await self.client.disconnect()
|
||
return
|
||
elif cmd in self.commands:
|
||
if self.client and self.client.connected:
|
||
await self.client.send_message(command)
|
||
self.display("system", f"📤 发送命令: {command}")
|
||
else:
|
||
self.display("error", "未连接到服务器")
|
||
else:
|
||
self.display("error", f"未知命令: {cmd}")
|
||
self._print_prompt(clear_input=True)
|
||
|
||
def _schedule_reconnect(self):
|
||
"""启动自动重连(若未手动断开且当前无进行中的重连任务)。"""
|
||
if self._manual_disconnect or not self.running or not self.client:
|
||
return
|
||
if self._reconnect_task and not self._reconnect_task.done():
|
||
return
|
||
try:
|
||
self._reconnect_task = asyncio.create_task(self._auto_reconnect_loop(self.client))
|
||
except RuntimeError:
|
||
pass # 事件循环正在关闭(程序退出中),放弃重连
|
||
|
||
async def _auto_reconnect_loop(self, client: WebSocketClient):
|
||
"""断线自动重连:指数退避 2s→30s 封顶,直到成功、/disconnect 或退出。"""
|
||
delay = 2
|
||
while self.running and not self._manual_disconnect and self.client is client:
|
||
self.display("info", f"🔄 {delay}s 后自动重连 {client.uri} …(/disconnect 取消)")
|
||
try:
|
||
await asyncio.sleep(delay)
|
||
except asyncio.CancelledError:
|
||
return
|
||
if not self.running or self._manual_disconnect or self.client is not client:
|
||
return
|
||
if await client.connect():
|
||
self.connected = True
|
||
self.display("system", f"✅ 已自动重连到 {client.uri}")
|
||
asyncio.create_task(client.receive_messages())
|
||
return
|
||
delay = min(delay * 2, 30)
|
||
|
||
async def _send_interrupt(self):
|
||
if self.client and self.client.connected:
|
||
interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}
|
||
try:
|
||
await self.client.websocket.send(json.dumps(interrupt_msg))
|
||
except Exception as e:
|
||
self.display("error", f"发送中断失败: {e}")
|
||
|
||
async def send_user_message(self, message: str):
|
||
if not self.client or not self.client.connected:
|
||
self.display("error", "未连接到服务器")
|
||
return
|
||
|
||
self._md_stream_stop() # 发送新消息前定格上一段渲染
|
||
# 重置流式状态
|
||
with self.lock:
|
||
if self.streaming_line:
|
||
sys.stdout.write('\n')
|
||
self.streaming_line = False
|
||
self.waiting_for_response = True
|
||
self.in_tool_phase = False
|
||
self.prompt_shown = False
|
||
# 【关键】清除当前输入,避免显示之前输入的内容
|
||
self.current_input = ""
|
||
|
||
self.ai_previous = ""
|
||
self.ai_full = ""
|
||
self.ai_prefix_shown = False
|
||
|
||
self.display("system", f"{Colors.USER}👤 用户: {message}{Colors.RESET}")
|
||
await self.client.send_message(message)
|
||
# 不在这里显示提示符,等待AI响应结束后再显示
|
||
|
||
def _stdin_reader(self, loop: asyncio.AbstractEventLoop):
|
||
"""独立守护线程:阻塞读 stdin,经 call_soon_threadsafe 投入队列。
|
||
|
||
进程生命周期只启动一次——替代原 run_in_executor 每行一个线程的方案,
|
||
避免 Ctrl+C 取消 await 后旧线程仍阻塞在 readline 上无限泄漏。
|
||
"""
|
||
def _put(item):
|
||
try:
|
||
loop.call_soon_threadsafe(self._input_queue.put_nowait, item)
|
||
except RuntimeError:
|
||
pass # 事件循环已关闭(程序退出中)
|
||
|
||
while self.running:
|
||
try:
|
||
line = sys.stdin.readline()
|
||
except Exception:
|
||
break
|
||
if not line: # EOF (Ctrl+D)
|
||
_put(None)
|
||
break
|
||
_put(line)
|
||
|
||
async def run(self):
|
||
self.print_banner()
|
||
self._task_buffers = {}
|
||
|
||
loop = asyncio.get_running_loop()
|
||
if self._stdin_thread is None:
|
||
self._stdin_thread = threading.Thread(
|
||
target=self._stdin_reader, args=(loop,),
|
||
daemon=True, name="stdin-reader")
|
||
self._stdin_thread.start()
|
||
|
||
while self.running:
|
||
try:
|
||
line = await self._input_queue.get()
|
||
if line is None: # EOF (Ctrl+D)
|
||
break
|
||
|
||
self.current_input = line.rstrip('\n')
|
||
line = line.strip()
|
||
|
||
if not line:
|
||
self._print_prompt(clear_input=True)
|
||
continue
|
||
|
||
if line.startswith('/'):
|
||
await self.handle_command(line)
|
||
else:
|
||
await self.send_user_message(line)
|
||
|
||
except KeyboardInterrupt:
|
||
if self.client and self.client.connected:
|
||
await self._send_interrupt()
|
||
self.display("info", "🛑 已发送中断信号")
|
||
else:
|
||
self.display("error", "未连接到服务器")
|
||
self._print_prompt(clear_input=True)
|
||
continue
|
||
except Exception as e:
|
||
if self.running:
|
||
self.display("error", f"错误: {e}")
|
||
break
|
||
|
||
async def main():
|
||
default_uri = "ws://localhost:8765"
|
||
uri = sys.argv[1] if len(sys.argv) > 1 else default_uri
|
||
|
||
cli = SimpleCLI()
|
||
|
||
cli.client = WebSocketClient(uri, cli.display)
|
||
if await cli.client.connect():
|
||
cli.connected = True
|
||
cli.display("system", f"✅ 已连接到 {uri}")
|
||
asyncio.create_task(cli.client.receive_messages())
|
||
else:
|
||
cli.display("error", f"无法连接到 {uri}")
|
||
cli.display("info", f"你可以通过命令行参数指定地址: python client.py ws://your-server:8765")
|
||
|
||
try:
|
||
await cli.run()
|
||
except KeyboardInterrupt:
|
||
print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}")
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
asyncio.run(main())
|
||
except KeyboardInterrupt:
|
||
print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}")
|
||
except Exception as e:
|
||
print(f"\n{Colors.ERROR}错误: {e}{Colors.RESET}") |