第五轮审计(J1~J16)全量修复 + 子代理 max_turns 钳制: - 子代理 max_turns 钳制(0/None 回退默认 10,负数钳制 1)+ 14 项回归测试 - J1 任务系统投毒:add_memo 入库校验(时间/间隔/类型)、调度器预检与触发事务 防御、展示路径兜底、register 移入 try、add_user_task 补 repeat_end_time 透传 - J2/J13 前端 XSS:三页面 marked 输出经 DOMPurify 净化、entry1 系统消息/任务 分隔条改 textContent、CDN 脚本补 SRI - J3 FTS 删除换序(先 fts 后内容表,索引不再残留) - J5 原子写推广:新增 common/atomic.py(mkstemp+fsync+os.replace),config/prompt/ history 全部接入;损坏配置备份后回退 - J6 单例初始化加锁(PromptManager/ConfigManager 并发首例不双重加载) - J7 broadcast 并发发送 + wait_for 超时回收僵死连接 - J4/J8/J9~J12/J14~J16:验证异常脱敏、交互输入容错、MemoDatabase WAL+busy_timeout、 memo 批量删除、/new 竞态、skills 引号转义、voice_client 清理、死代码移除、 skill_manager 同名键/help 截断
687 lines
26 KiB
Python
687 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
AI智能助手 - 语音命令行前端
|
||
支持WebSocket直连、语音输入、语音输出、命令系统
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import sys
|
||
import os
|
||
import threading
|
||
import warnings
|
||
from typing import Optional, Dict, Any
|
||
import websockets
|
||
from colorama import init, Fore, Style as ColoramaStyle
|
||
|
||
init(autoreset=True)
|
||
|
||
warnings.filterwarnings('ignore')
|
||
|
||
try:
|
||
import speech_recognition as sr
|
||
except ImportError:
|
||
print("❌ 缺少语音识别库,请安装: pip install SpeechRecognition pyaudio")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
import pyttsx3
|
||
except ImportError:
|
||
print("❌ 缺少文本转语音库,请安装: pip install pyttsx3")
|
||
sys.exit(1)
|
||
|
||
class Colors:
|
||
USER = Fore.CYAN
|
||
AI = Fore.GREEN
|
||
TASK = Fore.YELLOW
|
||
SYSTEM = Fore.BLUE
|
||
ERROR = Fore.RED
|
||
INFO = Fore.MAGENTA
|
||
WARNING = Fore.LIGHTYELLOW_EX
|
||
RESET = ColoramaStyle.RESET_ALL
|
||
BOLD = ColoramaStyle.BRIGHT
|
||
|
||
class TextToSpeech:
|
||
def __init__(self):
|
||
self.engine = None
|
||
self.available = False
|
||
old_stderr = None
|
||
try:
|
||
old_stderr = os.dup(2)
|
||
null_fd = os.open('/dev/null', os.O_WRONLY)
|
||
os.dup2(null_fd, 2)
|
||
os.close(null_fd)
|
||
|
||
self.engine = pyttsx3.init()
|
||
self.engine.setProperty('rate', 150)
|
||
voices = self.engine.getProperty('voices')
|
||
|
||
chinese_voice = None
|
||
for voice in voices:
|
||
try:
|
||
voice_name = str(voice.name).lower() if hasattr(voice, 'name') else ''
|
||
lang_attr = ''
|
||
if hasattr(voice, 'language'):
|
||
lang_attr = str(voice.language).lower()
|
||
elif hasattr(voice, 'languages'):
|
||
lang_attr = str(voice.languages[0]).lower() if voice.languages else ''
|
||
|
||
if 'chinese' in voice_name or 'zh' in lang_attr or 'mandarin' in voice_name:
|
||
if 'mandarin' in voice_name and 'pinyin' in voice_name:
|
||
chinese_voice = voice
|
||
break
|
||
elif chinese_voice is None:
|
||
chinese_voice = voice
|
||
except Exception:
|
||
continue
|
||
|
||
if chinese_voice:
|
||
self.engine.setProperty('voice', chinese_voice.id)
|
||
print(f"{Colors.INFO}🔊 已选择语音: {chinese_voice.name}{Colors.RESET}")
|
||
|
||
try:
|
||
self.engine.say("测试")
|
||
self.engine.runAndWait()
|
||
self.available = True
|
||
except Exception:
|
||
self.available = False
|
||
|
||
except Exception:
|
||
self.available = False
|
||
finally:
|
||
if old_stderr is not None:
|
||
os.dup2(old_stderr, 2)
|
||
os.close(old_stderr)
|
||
|
||
def speak(self, text: str):
|
||
if not self.available or not self.engine:
|
||
return
|
||
old_stderr = None
|
||
try:
|
||
old_stderr = os.dup(2)
|
||
null_fd = os.open('/dev/null', os.O_WRONLY)
|
||
os.dup2(null_fd, 2)
|
||
os.close(null_fd)
|
||
self.engine.say(text)
|
||
self.engine.runAndWait()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
if old_stderr is not None:
|
||
os.dup2(old_stderr, 2)
|
||
os.close(old_stderr)
|
||
|
||
class StreamingMessage:
|
||
def __init__(self):
|
||
self.content = ""
|
||
self.is_active = False
|
||
|
||
def add_chunk(self, chunk: str):
|
||
self.content += chunk
|
||
self.is_active = True
|
||
|
||
def get_content(self) -> str:
|
||
return self.content
|
||
|
||
def clear(self):
|
||
self.content = ""
|
||
self.is_active = False
|
||
|
||
class WebSocketClient:
|
||
def __init__(self, uri: str):
|
||
self.uri = uri
|
||
self.websocket = None
|
||
self.connected = False
|
||
self.streaming_messages: Dict[str, StreamingMessage] = {
|
||
'chat': StreamingMessage(),
|
||
'tasks': {}
|
||
}
|
||
self.running = True
|
||
|
||
async def connect(self):
|
||
try:
|
||
self.websocket = await websockets.connect(self.uri)
|
||
self.connected = True
|
||
return True
|
||
except Exception as e:
|
||
print(f"{Colors.ERROR}❌ 连接失败: {e}{Colors.RESET}")
|
||
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:
|
||
print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}")
|
||
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:
|
||
print(f"{Colors.ERROR}❌ 发送失败: {e}{Colors.RESET}")
|
||
return False
|
||
|
||
async def receive_messages(self, tts: TextToSpeech):
|
||
try:
|
||
async for message in self.websocket:
|
||
try:
|
||
data = json.loads(message)
|
||
await self.handle_message(data, tts)
|
||
except json.JSONDecodeError:
|
||
await self.handle_stream_chunk(message, 'chat', tts)
|
||
except websockets.exceptions.ConnectionClosed:
|
||
self.connected = False
|
||
print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}")
|
||
except Exception as e:
|
||
# J14:异常退出同样标记断开(此前 connected 恒 True,用户继续
|
||
# "成功"发送但永远收不到回复,成僵尸连接)
|
||
self.connected = False
|
||
print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}")
|
||
|
||
async def handle_message(self, data: dict, tts: TextToSpeech):
|
||
msg_type = data.get('type', '')
|
||
content = data.get('content', '')
|
||
|
||
if msg_type == 'chunk':
|
||
await self.handle_stream_chunk(content, 'chat', tts)
|
||
|
||
elif msg_type == 'complete':
|
||
await self.handle_stream_complete('chat', tts)
|
||
|
||
elif msg_type == 'dialogue_ended':
|
||
await self.handle_dialogue_ended(tts)
|
||
|
||
elif msg_type == 'task_process':
|
||
msg_subtype = data.get('msg_type', '')
|
||
task_content = data.get('content', '')
|
||
task_id = data.get('task_id', 'unknown')
|
||
execution_id = data.get('execution_id', 'unknown')
|
||
task_title = data.get('task_title', '未知任务')
|
||
|
||
if msg_subtype == 'chunk':
|
||
await self.handle_stream_chunk(
|
||
task_content, 'task', tts,
|
||
{'task_id': task_id, 'execution_id': execution_id, 'title': task_title}
|
||
)
|
||
elif msg_subtype == 'complete':
|
||
await self.handle_stream_complete('task', tts, execution_id)
|
||
|
||
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, '📋')
|
||
|
||
print(f"{Colors.TASK}{status_icon} [任务 {task_id}] {title}: {message}{Colors.RESET}")
|
||
|
||
elif msg_type == 'task_error':
|
||
task_id = data.get('task_id', 'unknown')
|
||
execution_id = data.get('execution_id', 'unknown')
|
||
task_title = data.get('task_title', '未知')
|
||
error = data.get('error', '未知错误')
|
||
|
||
print(f"{Colors.ERROR}❌ [任务 {task_id}] {task_title}: {error}{Colors.RESET}")
|
||
if tts:
|
||
tts.speak(f"任务 {task_id} 出错了,错误是 {error}")
|
||
|
||
if execution_id:
|
||
await self.handle_stream_complete('task', tts, execution_id)
|
||
|
||
elif msg_type == 'error':
|
||
print(f"{Colors.ERROR}❌ {content}{Colors.RESET}")
|
||
if tts:
|
||
tts.speak(f"错误,{content}")
|
||
|
||
elif msg_type == 'info':
|
||
print(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
|
||
if tts:
|
||
tts.speak(content)
|
||
|
||
elif msg_type == 'command_result':
|
||
print(f"{Colors.SYSTEM}📋 {content}{Colors.RESET}")
|
||
|
||
elif msg_type == 'connection_ack':
|
||
print(f"{Colors.AI}✅ {content}{Colors.RESET}")
|
||
|
||
elif msg_type == 'notification':
|
||
print(f"{Colors.INFO}🔔 {content}{Colors.RESET}")
|
||
if tts:
|
||
tts.speak(content)
|
||
|
||
else:
|
||
if content:
|
||
await self.handle_stream_chunk(content, 'chat', tts)
|
||
|
||
async def handle_stream_chunk(self, content: str, msg_type: str = 'chat',
|
||
tts: TextToSpeech = None, task_info: dict = None):
|
||
if msg_type == 'chat':
|
||
stream = self.streaming_messages['chat']
|
||
# J14(审计 2026-08-19):前缀判断必须在 add_chunk 之前——
|
||
# add_chunk 会置 is_active=True,原顺序使 "AI助手:" 前缀永不打印
|
||
if not stream.is_active:
|
||
print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True)
|
||
stream.is_active = True
|
||
|
||
stream.add_chunk(content)
|
||
print(content, end='', flush=True)
|
||
|
||
elif msg_type == 'task' and task_info:
|
||
execution_id = task_info.get('execution_id', 'unknown')
|
||
task_id = task_info.get('task_id', 'unknown')
|
||
title = task_info.get('title', '未知任务')
|
||
|
||
if execution_id not in self.streaming_messages['tasks']:
|
||
self.streaming_messages['tasks'][execution_id] = StreamingMessage()
|
||
print(f"\n{Colors.TASK}⚡ 定时任务 #{task_id} - {title}{Colors.RESET}")
|
||
print(f"{Colors.TASK}📋 ", end='', flush=True)
|
||
|
||
stream = self.streaming_messages['tasks'][execution_id]
|
||
stream.add_chunk(content)
|
||
print(content, end='', flush=True)
|
||
|
||
async def handle_stream_complete(self, msg_type: str = 'chat',
|
||
tts: TextToSpeech = None, execution_id: str = None):
|
||
if msg_type == 'chat':
|
||
stream = self.streaming_messages['chat']
|
||
print()
|
||
if tts and getattr(tts, 'available', True) and stream.content:
|
||
tts.speak(stream.content)
|
||
stream.clear()
|
||
|
||
elif msg_type == 'task' and execution_id:
|
||
if execution_id in self.streaming_messages['tasks']:
|
||
print()
|
||
del self.streaming_messages['tasks'][execution_id]
|
||
|
||
async def handle_dialogue_ended(self, tts: TextToSpeech):
|
||
print(f"\n{Colors.SYSTEM}✅ 对话已结束,可以开始新对话{Colors.RESET}")
|
||
if tts:
|
||
tts.speak("对话已结束")
|
||
self.streaming_messages['chat'].clear()
|
||
self.streaming_messages['tasks'].clear()
|
||
|
||
class VoiceInterface:
|
||
def __init__(self):
|
||
self.client: Optional[WebSocketClient] = None
|
||
self.tts = None
|
||
self.recognizer = None
|
||
self.microphone = None
|
||
self.whisper_model = None
|
||
self.has_audio = False
|
||
self.has_tts = False
|
||
self.has_microphone = False
|
||
|
||
try:
|
||
self.tts = TextToSpeech()
|
||
self.has_tts = self.tts.available
|
||
if self.has_tts:
|
||
print(f"{Colors.INFO}🔊 语音输出已就绪{Colors.RESET}")
|
||
else:
|
||
print(f"{Colors.WARNING}⚠️ 语音输出不可用{Colors.RESET}")
|
||
except Exception as e:
|
||
self.tts = None
|
||
self.has_tts = False
|
||
print(f"{Colors.WARNING}⚠️ 语音输出不可用: {e}{Colors.RESET}")
|
||
|
||
try:
|
||
self.recognizer = sr.Recognizer()
|
||
|
||
print(f"{Colors.INFO}🎤 尝试检测音频设备...{Colors.RESET}")
|
||
|
||
import subprocess
|
||
import tempfile
|
||
|
||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
||
test_file = f.name
|
||
|
||
result = subprocess.run(
|
||
['arecord', '-f', 'cd', '-d', '1', test_file],
|
||
capture_output=True, text=True
|
||
)
|
||
|
||
os.unlink(test_file)
|
||
|
||
if result.returncode == 0:
|
||
print(f"{Colors.INFO}🎤 arecord 检测到设备,将使用 arecord 录音{Colors.RESET}")
|
||
self.has_microphone = True
|
||
self.use_arecord = True
|
||
|
||
print(f"{Colors.INFO}📥 加载 Whisper 模型(离线语音识别)...{Colors.RESET}")
|
||
try:
|
||
import whisper
|
||
self.whisper_model = whisper.load_model("medium", device="cpu")
|
||
print(f"{Colors.INFO}✅ Whisper 模型加载成功 (medium 模型){Colors.RESET}")
|
||
except Exception as e:
|
||
print(f"{Colors.WARNING}⚠️ Whisper 模型加载失败: {e}{Colors.RESET}")
|
||
self.whisper_model = None
|
||
|
||
print(f"{Colors.INFO}🎤 麦克风已就绪{Colors.RESET}")
|
||
else:
|
||
devices = sr.Microphone.list_microphone_names()
|
||
if devices:
|
||
print(f"{Colors.INFO}🎤 检测到 {len(devices)} 个音频设备{Colors.RESET}")
|
||
self.microphone = sr.Microphone()
|
||
with self.microphone as source:
|
||
self.recognizer.adjust_for_ambient_noise(source, duration=0.1)
|
||
self.has_microphone = True
|
||
self.use_arecord = False
|
||
print(f"{Colors.INFO}🎤 麦克风已就绪 (使用 speech_recognition){Colors.RESET}")
|
||
else:
|
||
raise RuntimeError("没有检测到音频设备")
|
||
except Exception as e:
|
||
print(f"{Colors.WARNING}⚠️ 麦克风不可用: {e}{Colors.RESET}")
|
||
self.has_microphone = False
|
||
self.use_arecord = False
|
||
|
||
self.has_audio = self.has_tts or self.has_microphone
|
||
|
||
if not self.has_audio:
|
||
print(f"{Colors.INFO}ℹ️ 将以纯文本模式运行{Colors.RESET}")
|
||
|
||
self.commands = {
|
||
'/help': '显示帮助信息',
|
||
'/connect': '连接到服务器',
|
||
'/disconnect': '断开连接',
|
||
'/quit': '退出程序',
|
||
'/clear': '清屏',
|
||
'/status': '显示连接状态',
|
||
'/list': '列出可用命令',
|
||
'/memories': '查看记忆',
|
||
'/memos': '查看备忘录',
|
||
'/check': '检查提醒',
|
||
'/config': '查看配置',
|
||
'/new': '新建对话',
|
||
'/tasks': '查看任务列表',
|
||
}
|
||
|
||
def print_banner(self):
|
||
banner = f"""
|
||
{Colors.BOLD}{Colors.AI}
|
||
╔══════════════════════════════════════════╗
|
||
║ AI智能助手 - 语音前端 ║
|
||
║ WebSocket 直连版本 ║
|
||
╚══════════════════════════════════════════╝
|
||
{Colors.RESET}
|
||
"""
|
||
print(banner)
|
||
print(f"{Colors.INFO}输入 /help 查看可用命令")
|
||
print(f"输入语音开始对话,或输入文本命令{Colors.RESET}")
|
||
print()
|
||
|
||
def listen_voice(self) -> Optional[str]:
|
||
"""监听语音输入"""
|
||
if not self.has_microphone:
|
||
print(f"{Colors.WARNING}⚠️ 麦克风不可用,请使用文本输入{Colors.RESET}")
|
||
return None
|
||
|
||
if not self.client or not self.client.connected:
|
||
print(f"{Colors.ERROR}❌ 未连接到服务器,请先连接{Colors.RESET}")
|
||
return None
|
||
|
||
print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}")
|
||
temp_file = None # J14:录音临时文件,任何异常路径统一清理
|
||
|
||
try:
|
||
if self.use_arecord:
|
||
import subprocess
|
||
import tempfile
|
||
import wave
|
||
import numpy as np
|
||
import whisper
|
||
|
||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
||
temp_file = f.name
|
||
|
||
result = subprocess.run(
|
||
['arecord', '-f', 'cd', '-d', '5', temp_file],
|
||
capture_output=True, text=True,
|
||
timeout=10
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
print(f"{Colors.ERROR}❌ 录音失败: {result.stderr}{Colors.RESET}")
|
||
if os.path.exists(temp_file):
|
||
os.unlink(temp_file)
|
||
return None
|
||
|
||
if not os.path.exists(temp_file) or os.path.getsize(temp_file) == 0:
|
||
print(f"{Colors.WARNING}⚠️ 录音文件为空{Colors.RESET}")
|
||
if os.path.exists(temp_file):
|
||
os.unlink(temp_file)
|
||
return None
|
||
|
||
if self.whisper_model:
|
||
with wave.open(temp_file, 'rb') as wav_file:
|
||
frames = wav_file.readframes(wav_file.getnframes())
|
||
audio_data = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||
|
||
os.unlink(temp_file)
|
||
|
||
audio_padded = whisper.pad_or_trim(audio_data)
|
||
mel = whisper.log_mel_spectrogram(audio_padded, n_mels=80)
|
||
|
||
options = whisper.DecodingOptions(language='zh', without_timestamps=True)
|
||
result = whisper.decode(self.whisper_model, mel, options)
|
||
|
||
text = result.text.strip()
|
||
if text:
|
||
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
|
||
return text
|
||
else:
|
||
print(f"{Colors.INFO}⚠️ 未识别到语音内容{Colors.RESET}")
|
||
return None
|
||
else:
|
||
with sr.AudioFile(temp_file) as source:
|
||
audio = self.recognizer.record(source)
|
||
os.unlink(temp_file)
|
||
text = self.recognizer.recognize_google(audio, language='zh-CN')
|
||
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
|
||
return text
|
||
|
||
else:
|
||
with self.microphone as source:
|
||
self.recognizer.adjust_for_ambient_noise(source)
|
||
audio = self.recognizer.listen(source, timeout=15)
|
||
|
||
text = self.recognizer.recognize_google(audio, language='zh-CN')
|
||
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
|
||
return text
|
||
|
||
except sr.WaitTimeoutError:
|
||
print(f"{Colors.INFO}⏱️ 等待超时,请重试{Colors.RESET}")
|
||
return None
|
||
except sr.UnknownValueError:
|
||
print(f"{Colors.INFO}😕 无法识别语音,请重试{Colors.RESET}")
|
||
return None
|
||
except sr.RequestError as e:
|
||
print(f"{Colors.ERROR}❌ 语音识别服务错误: {e}{Colors.RESET}")
|
||
return None
|
||
except Exception as e:
|
||
print(f"{Colors.ERROR}❌ 语音输入错误: {e}{Colors.RESET}")
|
||
# J14:录音临时文件(含用户语音)任何异常路径都清理——
|
||
# 此前 wave.open/whisper 解析失败、arecord 超时等滞留 /tmp
|
||
if temp_file and os.path.exists(temp_file):
|
||
try:
|
||
os.unlink(temp_file)
|
||
except OSError:
|
||
pass
|
||
return None
|
||
|
||
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:
|
||
print(f"{Colors.ERROR}用法: /connect ws://host:port{Colors.RESET}")
|
||
return
|
||
|
||
uri = parts[1]
|
||
if self.client and self.client.connected:
|
||
await self.client.disconnect()
|
||
|
||
self.client = WebSocketClient(uri)
|
||
if await self.client.connect():
|
||
print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}")
|
||
if self.tts and self.has_tts:
|
||
self.tts.speak(f"已连接到 {uri}")
|
||
asyncio.create_task(self.client.receive_messages(self.tts))
|
||
else:
|
||
self.client = None
|
||
|
||
elif cmd == '/disconnect':
|
||
if self.client:
|
||
await self.client.disconnect()
|
||
self.client = None
|
||
print(f"{Colors.SYSTEM}已断开连接{Colors.RESET}")
|
||
if self.tts and self.has_tts:
|
||
self.tts.speak("已断开连接")
|
||
else:
|
||
print(f"{Colors.ERROR}未连接到服务器{Colors.RESET}")
|
||
|
||
elif cmd == '/status':
|
||
if self.client and self.client.connected:
|
||
print(f"{Colors.AI}✅ 已连接: {self.client.uri}{Colors.RESET}")
|
||
if self.tts and self.has_tts:
|
||
self.tts.speak(f"已连接到 {self.client.uri}")
|
||
else:
|
||
print(f"{Colors.SYSTEM}未连接{Colors.RESET}")
|
||
if self.tts and self.has_tts:
|
||
self.tts.speak("未连接")
|
||
|
||
elif cmd == '/clear':
|
||
os.system('clear' if os.name == 'posix' else 'cls')
|
||
self.print_banner()
|
||
|
||
elif cmd == '/quit':
|
||
print(f"{Colors.SYSTEM}再见!{Colors.RESET}")
|
||
if self.tts:
|
||
self.tts.speak("再见")
|
||
if self.client:
|
||
await self.client.disconnect()
|
||
sys.exit(0)
|
||
|
||
elif cmd in self.commands:
|
||
if self.client and self.client.connected:
|
||
await self.client.send_message(command)
|
||
else:
|
||
print(f"{Colors.ERROR}未连接到服务器,请先使用 /connect 连接{Colors.RESET}")
|
||
|
||
else:
|
||
print(f"{Colors.ERROR}未知命令: {cmd}{Colors.RESET}")
|
||
|
||
def show_help(self):
|
||
help_text = f"""
|
||
{Colors.BOLD}可用命令:{Colors.RESET}
|
||
|
||
{Colors.USER}本地命令:{Colors.RESET}
|
||
/connect <url> 连接到WebSocket服务器
|
||
/disconnect 断开连接
|
||
/status 显示连接状态
|
||
/clear 清屏
|
||
/quit 退出程序
|
||
/help 显示此帮助
|
||
|
||
{Colors.AI}服务器命令 (需要先连接):{Colors.RESET}
|
||
"""
|
||
for cmd, desc in self.commands.items():
|
||
if cmd not in ['/help', '/connect', '/disconnect', '/quit', '/clear', '/status']:
|
||
help_text += f" {cmd:<15} {desc}\n"
|
||
|
||
print(help_text)
|
||
|
||
async def send_user_message(self, message: str):
|
||
if not self.client or not self.client.connected:
|
||
print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}")
|
||
return
|
||
|
||
print(f"\n{Colors.USER}👤 用户: {message}{Colors.RESET}")
|
||
await self.client.send_message(message)
|
||
|
||
async def run(self):
|
||
self.print_banner()
|
||
|
||
prompt = f"{Colors.USER}>>> {Colors.RESET}"
|
||
|
||
while True:
|
||
try:
|
||
user_input = await asyncio.to_thread(
|
||
lambda: input(prompt)
|
||
)
|
||
user_input = user_input.strip()
|
||
|
||
if not user_input:
|
||
if self.has_microphone:
|
||
print(f"{Colors.INFO}🎤 开始语音输入...{Colors.RESET}")
|
||
voice_text = self.listen_voice()
|
||
if voice_text:
|
||
await self.send_user_message(voice_text)
|
||
else:
|
||
print(f"{Colors.WARNING}⚠️ 麦克风不可用,请输入文本{Colors.RESET}")
|
||
continue
|
||
|
||
if user_input.startswith('/'):
|
||
await self.handle_command(user_input)
|
||
else:
|
||
await self.send_user_message(user_input)
|
||
|
||
except KeyboardInterrupt:
|
||
print(f"\n{Colors.INFO}⏹️ 已取消{Colors.RESET}")
|
||
continue
|
||
except EOFError:
|
||
print(f"\n{Colors.SYSTEM}再见!{Colors.RESET}")
|
||
if self.tts:
|
||
self.tts.speak("再见")
|
||
if self.client:
|
||
await self.client.disconnect()
|
||
break
|
||
except Exception as e:
|
||
print(f"{Colors.ERROR}错误: {e}{Colors.RESET}")
|
||
|
||
async def main():
|
||
cli = VoiceInterface()
|
||
|
||
default_uri = "ws://localhost:8765"
|
||
uri = sys.argv[1] if len(sys.argv) > 1 else default_uri
|
||
|
||
cli.client = WebSocketClient(uri)
|
||
if await cli.client.connect():
|
||
print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}")
|
||
if cli.tts and cli.has_tts:
|
||
cli.tts.speak(f"已连接到 {uri}")
|
||
asyncio.create_task(cli.client.receive_messages(cli.tts))
|
||
else:
|
||
print(f"{Colors.WARNING}⚠️ 无法连接到 {uri}{Colors.RESET}")
|
||
print(f"{Colors.INFO}ℹ️ 你可以通过命令行参数指定地址: python voice_client.py ws://your-server:8765{Colors.RESET}")
|
||
print(f"{Colors.INFO}ℹ️ 或者使用 /connect 命令手动连接{Colors.RESET}")
|
||
|
||
try:
|
||
await cli.run()
|
||
except asyncio.CancelledError:
|
||
pass
|
||
finally:
|
||
if cli.client:
|
||
await cli.client.disconnect()
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
asyncio.run(main())
|
||
except KeyboardInterrupt:
|
||
print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}")
|