Files
blackbird/aibox/voice_client.py
T
qing 457bb900fb fix(aibox): round-5 audit batch K - max_turns clamp, task input validation, XSS hardening, atomic writes, broadcast timeout
第五轮审计(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 截断
2026-08-19 07:01:13 +08:00

687 lines
26 KiB
Python
Raw 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.
#!/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}")