1. 完善了服务端web部分,使用Vue编写(见serve_view)
2. 增加了对嵌入式设备完全自定义控制的功能
This commit is contained in:
@@ -83,3 +83,7 @@
|
|||||||
本项目当前并不完善,还有很多需要优化的地方,并且尚未接入Yosuga_embedded。
|
本项目当前并不完善,还有很多需要优化的地方,并且尚未接入Yosuga_embedded。
|
||||||
|
|
||||||
欢迎大家为本项目贡献代码。
|
欢迎大家为本项目贡献代码。
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#Misakityan/Yosuga_server&Date)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from openai import OpenAI # 确保 pip install openai
|
||||||
|
|
||||||
|
from src.server_core.yosuga_embedded_server.device_manager import DeviceManager, DeviceInfo
|
||||||
|
from src.server_core.yosuga_embedded_server.function_registry import FunctionRegistry
|
||||||
|
from src.server_core.yosuga_embedded_server.ai_prompt import AIPromptBuilder
|
||||||
|
from src.server_core.yosuga_embedded_server.json_rpc import JSONRPCHandler, RPCError
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceConnection:
|
||||||
|
"""管理一个 TCP 设备连接"""
|
||||||
|
def __init__(self, sock, addr, server):
|
||||||
|
self.sock = sock
|
||||||
|
self.addr = addr
|
||||||
|
self.server = server
|
||||||
|
self.device_id: Optional[str] = None
|
||||||
|
|
||||||
|
def send_msg(self, data: str):
|
||||||
|
"""发送带长度前缀的消息"""
|
||||||
|
encoded = data.encode('utf-8')
|
||||||
|
self.sock.sendall(struct.pack('!I', len(encoded)))
|
||||||
|
self.sock.sendall(encoded)
|
||||||
|
|
||||||
|
def recv_msg(self) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
# 读满 4 字节长度前缀
|
||||||
|
raw_len = b''
|
||||||
|
while len(raw_len) < 4:
|
||||||
|
chunk = self.sock.recv(4 - len(raw_len))
|
||||||
|
if not chunk: # 对方关闭连接
|
||||||
|
return None
|
||||||
|
raw_len += chunk
|
||||||
|
msg_len = struct.unpack('!I', raw_len)[0]
|
||||||
|
|
||||||
|
# 读消息体
|
||||||
|
data = b''
|
||||||
|
while len(data) < msg_len:
|
||||||
|
chunk = self.sock.recv(msg_len - len(data))
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
data += chunk
|
||||||
|
return data.decode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"recv error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
try:
|
||||||
|
# 只接收第一条能力广告
|
||||||
|
caps_str = self.recv_msg()
|
||||||
|
if not caps_str:
|
||||||
|
return
|
||||||
|
print(f"[{self.addr}] Capabilities received")
|
||||||
|
device = self.server.register_device(caps_str)
|
||||||
|
self.device_id = device.device_id
|
||||||
|
self.server.set_connection(device.device_id, self)
|
||||||
|
print(f"[{self.addr}] Registered as {device.name} (id={device.device_id})")
|
||||||
|
|
||||||
|
# 此处直接返回,线程结束。后续通信由 call_device 接管。
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Registration error: {e}")
|
||||||
|
finally:
|
||||||
|
# 注意:不要关闭 socket!它还要用于后续 call_device
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class YosugaServer:
|
||||||
|
"""整合设备管理、函数注册、AI 交互的服务端"""
|
||||||
|
def __init__(self, deepseek_api_key: str):
|
||||||
|
self.device_manager = DeviceManager()
|
||||||
|
self.function_registry = FunctionRegistry()
|
||||||
|
self.ai_prompt = AIPromptBuilder()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._call_id_counter = 0
|
||||||
|
|
||||||
|
# 关联设备变更
|
||||||
|
self.device_manager.on_device_change = self._on_device_change
|
||||||
|
self.function_registry.on_change = self._on_functions_change
|
||||||
|
|
||||||
|
# DeepSeek 客户端
|
||||||
|
self.ai_client = OpenAI(
|
||||||
|
api_key=deepseek_api_key,
|
||||||
|
base_url="https://api.deepseek.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设备连接表:device_id -> DeviceConnection
|
||||||
|
self._connections: dict[str, DeviceConnection] = {}
|
||||||
|
|
||||||
|
def _on_device_change(self, event: str, device: DeviceInfo):
|
||||||
|
print(f"Device event: {event} {device.device_id}")
|
||||||
|
if event == "removed":
|
||||||
|
self.function_registry.remove_device_functions(device.device_id)
|
||||||
|
conn = self._connections.pop(device.device_id, None)
|
||||||
|
if conn and conn.sock:
|
||||||
|
try:
|
||||||
|
conn.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif event in ("added", "updated"):
|
||||||
|
if device.state.value == "registered":
|
||||||
|
self.function_registry.add_device_functions(
|
||||||
|
device.device_id,
|
||||||
|
device.name,
|
||||||
|
device.functions or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_functions_change(self):
|
||||||
|
print(f"Functions updated, total: {self.function_registry.function_count()}")
|
||||||
|
|
||||||
|
def register_device(self, caps_json: str) -> DeviceInfo:
|
||||||
|
data = json.loads(caps_json)
|
||||||
|
return self.device_manager.register_from_json(data)
|
||||||
|
|
||||||
|
def remove_device(self, device_id: str):
|
||||||
|
self.device_manager.remove_device(device_id)
|
||||||
|
|
||||||
|
def set_connection(self, device_id: str, conn: DeviceConnection):
|
||||||
|
with self._lock:
|
||||||
|
self._connections[device_id] = conn
|
||||||
|
|
||||||
|
def call_device(self, device_id: str, method: str, params: dict, call_id: int) -> dict:
|
||||||
|
conn = self._connections.get(device_id)
|
||||||
|
if not conn:
|
||||||
|
return {"error": {"code": RPCError.DEVICE_NOT_FOUND,
|
||||||
|
"message": f"Device {device_id} not connected"}}
|
||||||
|
|
||||||
|
req = JSONRPCHandler.build_call(method, params, call_id)
|
||||||
|
try:
|
||||||
|
conn.send_msg(req)
|
||||||
|
resp_str = conn.recv_msg_with_timeout(timeout=5.0)
|
||||||
|
if resp_str is None:
|
||||||
|
# 超时或连接断开
|
||||||
|
self.remove_device(device_id) # 自动清理
|
||||||
|
return {"error": {"code": RPCError.TIMEOUT, "message": "Device timeout or disconnected"}}
|
||||||
|
|
||||||
|
resp = JSONRPCHandler.parse_response(resp_str)
|
||||||
|
if resp and resp.is_success():
|
||||||
|
return {"result": resp.result}
|
||||||
|
elif resp and resp.error:
|
||||||
|
return {"error": resp.error.to_dict()}
|
||||||
|
else:
|
||||||
|
return {"error": {"code": RPCError.PARSE_ERROR, "message": "Invalid response"}}
|
||||||
|
except Exception as e:
|
||||||
|
self.remove_device(device_id)
|
||||||
|
return {"error": {"code": RPCError.DEVICE_ERROR, "message": str(e)}}
|
||||||
|
|
||||||
|
def process_device_message(self, device_id: str, message: str) -> str:
|
||||||
|
"""处理从设备主动发来的消息(如 RPC 响应)"""
|
||||||
|
# 在这个架构中,设备不会主动发消息,所有交互都由服务端发起
|
||||||
|
# 这里仅作为占位,返回空
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def handle_user_request(self, user_input: str) -> str:
|
||||||
|
"""处理用户自然语言请求:调用 AI,执行函数,返回最终答案"""
|
||||||
|
# 1. 构建系统提示(包含当前所有函数)
|
||||||
|
func_list = self.function_registry.to_function_list()
|
||||||
|
system_prompt = self.ai_prompt.build_system_prompt(func_list)
|
||||||
|
|
||||||
|
# 2. 调用 DeepSeek
|
||||||
|
print("Calling DeepSeek...")
|
||||||
|
try:
|
||||||
|
response = self.ai_client.chat.completions.create(
|
||||||
|
model="deepseek-chat",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_input}
|
||||||
|
],
|
||||||
|
temperature=0.0, # 确保输出稳定
|
||||||
|
)
|
||||||
|
ai_text = response.choices[0].message.content.strip()
|
||||||
|
print(f"AI response:\n{ai_text}")
|
||||||
|
except Exception as e:
|
||||||
|
return f"AI error: {e}"
|
||||||
|
|
||||||
|
# 3. 解析 AI 返回的调用
|
||||||
|
calls = self.ai_prompt.parse_ai_response(ai_text)
|
||||||
|
if not calls:
|
||||||
|
return "Failed to parse AI response as JSON-RPC calls"
|
||||||
|
|
||||||
|
# 4. 逐个执行调用
|
||||||
|
results = []
|
||||||
|
for idx, call in enumerate(calls):
|
||||||
|
method = call.get("method")
|
||||||
|
params = call.get("params", {})
|
||||||
|
call_id = idx + 1 # 简单自增 ID
|
||||||
|
|
||||||
|
# 找到该函数所属设备
|
||||||
|
func_info = self.function_registry.get_function(method)
|
||||||
|
if not func_info:
|
||||||
|
results.append(f"❌ Unknown function: {method}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_id = func_info.device_id
|
||||||
|
print(f"Routing '{method}' to device {device_id}")
|
||||||
|
|
||||||
|
# 发送给设备
|
||||||
|
dev_resp = self.call_device(device_id, method, params, call_id)
|
||||||
|
if "error" in dev_resp:
|
||||||
|
results.append(f"❌ {method}: {dev_resp['error']['message']}")
|
||||||
|
else:
|
||||||
|
results.append(f"✅ {method}: {dev_resp.get('result', 'ok')}")
|
||||||
|
|
||||||
|
# 5. 汇总结果返回
|
||||||
|
summary = "\n".join(results)
|
||||||
|
print(f"Task result:\n{summary}")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 修改 DeviceConnection 的 recv_msg 支持超时 ==========
|
||||||
|
|
||||||
|
def recv_msg_with_timeout(self, timeout: float = 5.0) -> Optional[str]:
|
||||||
|
"""接收带长度前缀的消息,带超时"""
|
||||||
|
self.sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
return self.recv_msg()
|
||||||
|
except socket.timeout:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
self.sock.settimeout(None)
|
||||||
|
|
||||||
|
DeviceConnection.recv_msg_with_timeout = recv_msg_with_timeout # monkey-patch
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 主函数 ==========
|
||||||
|
def main():
|
||||||
|
|
||||||
|
|
||||||
|
server = YosugaServer("")
|
||||||
|
|
||||||
|
# 启动 TCP 监听,接收设备连接
|
||||||
|
def accept_connections():
|
||||||
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
listener.bind(('0.0.0.0', 9555))
|
||||||
|
listener.listen(5)
|
||||||
|
print("Server listening on port 9555...")
|
||||||
|
while True:
|
||||||
|
sock, addr = listener.accept()
|
||||||
|
print(f"New connection from {addr}")
|
||||||
|
conn = DeviceConnection(sock, addr, server)
|
||||||
|
# 在连接注册后,关联 device_id 需要等到能力广告,暂存一下
|
||||||
|
# 我们在 handle 中收到能力后再设置 server.set_connection
|
||||||
|
threading.Thread(target=conn.handle, daemon=True).start()
|
||||||
|
|
||||||
|
threading.Thread(target=accept_connections, daemon=True).start()
|
||||||
|
|
||||||
|
# 简单交互循环
|
||||||
|
print("\nEnter your requests (type 'quit' to exit):")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("> ")
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if user_input.lower() in ('quit', 'exit'):
|
||||||
|
break
|
||||||
|
if not user_input.strip():
|
||||||
|
continue
|
||||||
|
result = server.handle_user_request(user_input)
|
||||||
|
print("---\n" + result + "\n---")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -41,5 +41,5 @@ if __name__ == "__main__":
|
|||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nYosuga服务器已停止喵~~~")
|
print("\nYosuga服务端已停止喵~~~")
|
||||||
|
|
||||||
@@ -7,10 +7,15 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiohttp>=3.13.3",
|
"aiohttp>=3.13.3",
|
||||||
|
"eventlet>=0.40.4",
|
||||||
"fastapi>=0.128.0",
|
"fastapi>=0.128.0",
|
||||||
"faster-whisper>=1.2.1",
|
"faster-whisper>=1.2.1",
|
||||||
|
"flask>=3.1.3",
|
||||||
|
"flask-cors>=6.0.2",
|
||||||
|
"flask-socketio>=5.6.1",
|
||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
"openai>=2.16.0",
|
"openai>=2.16.0",
|
||||||
|
"psutil>=7.2.2",
|
||||||
"pyautogui>=0.9.54",
|
"pyautogui>=0.9.54",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pydantic-settings>=2.12.0",
|
"pydantic-settings>=2.12.0",
|
||||||
|
|||||||
+23
-7
@@ -29,7 +29,7 @@ class AIConfig:
|
|||||||
model_name: str = "qwen/qwen3-4b-2507"
|
model_name: str = "qwen/qwen3-4b-2507"
|
||||||
timeout: int = 30
|
timeout: int = 30
|
||||||
temperature: float = 0.4
|
temperature: float = 0.4
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 4096
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -98,12 +98,22 @@ class AppConfig:
|
|||||||
# 基础字段
|
# 基础字段
|
||||||
self.version = version
|
self.version = version
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.ai = ai if ai is not None else AIConfig()
|
|
||||||
self.tts = tts if tts is not None else TTSConfig()
|
# self.ai = ai if ai is not None else AIConfig()
|
||||||
self.asr = asr if asr is not None else ASRConfig()
|
# self.tts = tts if tts is not None else TTSConfig()
|
||||||
self.auto_agent = auto_agent if auto_agent is not None else AutoAgentConfig()
|
# self.asr = asr if asr is not None else ASRConfig()
|
||||||
self.llm_core = llm_core if llm_core is not None else LLMConfig()
|
# self.auto_agent = auto_agent if auto_agent is not None else AutoAgentConfig()
|
||||||
self.paths = paths if paths is not None else PathsConfig()
|
# self.llm_core = llm_core if llm_core is not None else LLMConfig()
|
||||||
|
# self.paths = paths if paths is not None else PathsConfig()
|
||||||
|
|
||||||
|
# 如果是字典则转换,否则使用默认值
|
||||||
|
self.ai = AIConfig(**ai) if isinstance(ai, dict) else (ai or AIConfig())
|
||||||
|
self.tts = TTSConfig(**tts) if isinstance(tts, dict) else (tts or TTSConfig())
|
||||||
|
self.asr = ASRConfig(**asr) if isinstance(asr, dict) else (asr or ASRConfig())
|
||||||
|
self.auto_agent = AutoAgentConfig(**auto_agent) if isinstance(auto_agent, dict) else (
|
||||||
|
auto_agent or AutoAgentConfig())
|
||||||
|
self.llm_core = LLMConfig(**llm_core) if isinstance(llm_core, dict) else (llm_core or LLMConfig())
|
||||||
|
self.paths = PathsConfig(**paths) if isinstance(paths, dict) else (paths or PathsConfig())
|
||||||
|
|
||||||
# 内部状态(非 dataclass 字段,不会被序列化)
|
# 内部状态(非 dataclass 字段,不会被序列化)
|
||||||
self._config_path = _config_path
|
self._config_path = _config_path
|
||||||
@@ -395,6 +405,12 @@ class _LazyConfig:
|
|||||||
def using_dir(self) -> Path:
|
def using_dir(self) -> Path:
|
||||||
return _ensure_initialized().using_dir
|
return _ensure_initialized().using_dir
|
||||||
|
|
||||||
|
def ensure_config_initialized():
|
||||||
|
"""
|
||||||
|
强制立即初始化配置(用于多线程环境)
|
||||||
|
返回真正的 AppConfig 实例而非代理
|
||||||
|
"""
|
||||||
|
return _ensure_initialized()
|
||||||
|
|
||||||
# 全局配置对象:导入即用,自动初始化
|
# 全局配置对象:导入即用,自动初始化
|
||||||
cfg: AppConfig = _LazyConfig() # type: ignore
|
cfg: AppConfig = _LazyConfig() # type: ignore
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ASRInterface:
|
|||||||
self.sample_rate = 16000
|
self.sample_rate = 16000
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
logger.info("🎤 ASR接口初始化完成")
|
logger.info("ASR接口初始化完成")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_instance(cls, config: Optional[ASRConfig] = None) -> 'ASRInterface':
|
def get_instance(cls, config: Optional[ASRConfig] = None) -> 'ASRInterface':
|
||||||
@@ -68,7 +68,7 @@ class ASRInterface:
|
|||||||
import time
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
logger.info(f"🎵 开始识别: {wav_path.name}")
|
logger.info(f"开始识别: {wav_path.name}")
|
||||||
|
|
||||||
# 执行识别...
|
# 执行识别...
|
||||||
audio = self._load_audio(wav_path)
|
audio = self._load_audio(wav_path)
|
||||||
@@ -78,14 +78,14 @@ class ASRInterface:
|
|||||||
# 计算耗时
|
# 计算耗时
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ 识别完成: {lang} | {len(text)}字符 | 置信度:{confidence:.2f} | "
|
f"识别完成: {lang} | {len(text)}字符 | 置信度:{confidence:.2f} | "
|
||||||
f"耗时:{processing_time:.3f}s | RTF:{processing_time/(len(audio)/self.sample_rate):.3f}"
|
f"耗时:{processing_time:.3f}s | RTF:{processing_time/(len(audio)/self.sample_rate):.3f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return text, lang, confidence
|
return text, lang, confidence
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 识别失败 {wav_path}: {e}")
|
logger.error(f"识别失败 {wav_path}: {e}")
|
||||||
raise RuntimeError(f"Transcription failed: {e}")
|
raise RuntimeError(f"Transcription failed: {e}")
|
||||||
|
|
||||||
def _load_audio(self, wav_path: Path) -> numpy.ndarray:
|
def _load_audio(self, wav_path: Path) -> numpy.ndarray:
|
||||||
@@ -180,5 +180,5 @@ class ASRInterface:
|
|||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""优雅关闭"""
|
"""优雅关闭"""
|
||||||
logger.info("🛑 关闭ASR接口...")
|
logger.info("关闭ASR接口...")
|
||||||
self.model_manager.unload()
|
self.model_manager.unload()
|
||||||
@@ -34,8 +34,8 @@ class ModelManager:
|
|||||||
|
|
||||||
def _load_model(self):
|
def _load_model(self):
|
||||||
"""加载模型"""
|
"""加载模型"""
|
||||||
logger.info(f"🚀 初始化模型: {self.config.model_name}")
|
logger.info(f"初始化模型: {self.config.model_name}")
|
||||||
logger.info(f"📦 设备: {self.config.device}, 计算类型: {self.config.compute_type}")
|
logger.info(f"设备: {self.config.device}, 计算类型: {self.config.compute_type}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._model = WhisperModel(
|
self._model = WhisperModel(
|
||||||
@@ -52,15 +52,15 @@ class ModelManager:
|
|||||||
"model_size": self.config.model_name.split("-")[-2]
|
"model_size": self.config.model_name.split("-")[-2]
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("✅ 模型加载成功")
|
logger.info("模型加载成功")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 模型加载失败: {e}")
|
logger.error(f"模型加载失败: {e}")
|
||||||
raise RuntimeError(f"Failed to load ASR model: {e}")
|
raise RuntimeError(f"Failed to load ASR model: {e}")
|
||||||
|
|
||||||
def reload(self, new_config: ASRConfig):
|
def reload(self, new_config: ASRConfig):
|
||||||
"""热重载模型"""
|
"""热重载模型"""
|
||||||
logger.info("🔄 热重载模型...")
|
logger.info("热重载模型...")
|
||||||
self.unload()
|
self.unload()
|
||||||
self.config = new_config
|
self.config = new_config
|
||||||
self._load_model()
|
self._load_model()
|
||||||
@@ -68,7 +68,7 @@ class ModelManager:
|
|||||||
def unload(self):
|
def unload(self):
|
||||||
"""卸载模型释放资源"""
|
"""卸载模型释放资源"""
|
||||||
if self._model is not None:
|
if self._model is not None:
|
||||||
logger.info("🗑️ 卸载模型...")
|
logger.info("卸载模型...")
|
||||||
del self._model
|
del self._model
|
||||||
self._model = None
|
self._model = None
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ class ModelManager:
|
|||||||
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
logger.info("✅ 模型已卸载")
|
logger.info("模型已卸载")
|
||||||
|
|
||||||
def get_device_info(self) -> dict:
|
def get_device_info(self) -> dict:
|
||||||
"""获取设备信息"""
|
"""获取设备信息"""
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ class PerformanceProfiler:
|
|||||||
|
|
||||||
if len(self.stats) % 10 == 0:
|
if len(self.stats) % 10 == 0:
|
||||||
avg_rtf = sum(s["rtf"] for s in self.stats[-10:]) / 10
|
avg_rtf = sum(s["rtf"] for s in self.stats[-10:]) / 10
|
||||||
logger.info(f"📊 最近10次平均RTF: {avg_rtf:.3f}")
|
logger.info(f"最近10次平均RTF: {avg_rtf:.3f}")
|
||||||
@@ -82,7 +82,7 @@ class ASRClientSync:
|
|||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||||
|
|
||||||
logger.info(f"📤 上传文件: {file_path.name}")
|
logger.info(f"上传文件: {file_path.name}")
|
||||||
|
|
||||||
with open(file_path, 'rb') as f:
|
with open(file_path, 'rb') as f:
|
||||||
files = {'file': (file_path.name, f, 'audio/wav')}
|
files = {'file': (file_path.name, f, 'audio/wav')}
|
||||||
@@ -106,7 +106,7 @@ class ASRClientSync:
|
|||||||
audio_bytes = f.read()
|
audio_bytes = f.read()
|
||||||
result = client.transcribe_bytes(audio_bytes)
|
result = client.transcribe_bytes(audio_bytes)
|
||||||
"""
|
"""
|
||||||
logger.info(f"📤 上传字节流 ({len(audio_data)} bytes)")
|
logger.info(f"上传字节流 ({len(audio_data)} bytes)")
|
||||||
|
|
||||||
files = {'file': (filename, audio_data, 'audio/wav')}
|
files = {'file': (filename, audio_data, 'audio/wav')}
|
||||||
result = self._request('POST', '/transcribe', files=files)
|
result = self._request('POST', '/transcribe', files=files)
|
||||||
@@ -171,7 +171,7 @@ class ASRClientAsync:
|
|||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||||
|
|
||||||
logger.info(f"📤 上传文件: {file_path.name}")
|
logger.info(f"上传文件: {file_path.name}")
|
||||||
|
|
||||||
async with aiofiles.open(file_path, 'rb') as f:
|
async with aiofiles.open(file_path, 'rb') as f:
|
||||||
audio_data = await f.read()
|
audio_data = await f.read()
|
||||||
@@ -180,7 +180,7 @@ class ASRClientAsync:
|
|||||||
|
|
||||||
async def transcribe_bytes(self, audio_data: bytes, filename: str = "audio.wav") -> ASRResponse:
|
async def transcribe_bytes(self, audio_data: bytes, filename: str = "audio.wav") -> ASRResponse:
|
||||||
"""异步转录音频字节流"""
|
"""异步转录音频字节流"""
|
||||||
logger.info(f"📤 上传字节流 ({len(audio_data)} bytes)")
|
logger.info(f"上传字节流 ({len(audio_data)} bytes)")
|
||||||
await self._ensure_session() # 确保session已创建
|
await self._ensure_session() # 确保session已创建
|
||||||
form = aiohttp.FormData() # 创建表单数据
|
form = aiohttp.FormData() # 创建表单数据
|
||||||
form.add_field('file', audio_data, filename=filename, content_type='audio/wav') # 添加文件字段
|
form.add_field('file', audio_data, filename=filename, content_type='audio/wav') # 添加文件字段
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def first_test() -> None:
|
|||||||
"""首次启动测试"""
|
"""首次启动测试"""
|
||||||
time.sleep(5) # 给服务器一些启动时间
|
time.sleep(5) # 给服务器一些启动时间
|
||||||
# 构造一个测试请求以验证初始化模型加载成功
|
# 构造一个测试请求以验证初始化模型加载成功
|
||||||
logger.info("🚀 测试模型是否加载成功...")
|
logger.info("测试模型是否加载成功...")
|
||||||
import requests
|
import requests
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
url = "http://localhost:20260/transcribe"
|
url = "http://localhost:20260/transcribe"
|
||||||
@@ -52,7 +52,7 @@ def first_test() -> None:
|
|||||||
logger.error(f"测试过程中发生错误: {e}")
|
logger.error(f"测试过程中发生错误: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("🚀 启动 ASR API 服务...")
|
logger.info("启动 ASR API 服务...")
|
||||||
|
|
||||||
# 在后台线程启动服务器
|
# 在后台线程启动服务器
|
||||||
server_thread = threading.Thread(target=start_server, daemon=True)
|
server_thread = threading.Thread(target=start_server, daemon=True)
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ class OpenAIClient(BaseLLMClient):
|
|||||||
|
|
||||||
def _normal_chat_completion(self, params):
|
def _normal_chat_completion(self, params):
|
||||||
"""非流式响应处理"""
|
"""非流式响应处理"""
|
||||||
logger.info("📡 发送非流式请求...")
|
logger.info("发送非流式请求...")
|
||||||
response = self.client.chat.completions.create(**params)
|
response = self.client.chat.completions.create(**params)
|
||||||
raw_usage = response.usage
|
raw_usage = response.usage
|
||||||
normalized_usage = normalize_usage(
|
normalized_usage = normalize_usage(
|
||||||
@@ -251,7 +251,7 @@ class OpenAIClient(BaseLLMClient):
|
|||||||
|
|
||||||
def _stream_chat_completion(self, params):
|
def _stream_chat_completion(self, params):
|
||||||
"""流式响应处理"""
|
"""流式响应处理"""
|
||||||
logger.info("📡 发送流式请求...")
|
logger.info("发送流式请求...")
|
||||||
response_stream = self.client.chat.completions.create(**params)
|
response_stream = self.client.chat.completions.create(**params)
|
||||||
|
|
||||||
full_content = ""
|
full_content = ""
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class AsyncAudioPlayer:
|
|||||||
# 读取PCM数据(去掉头部)
|
# 读取PCM数据(去掉头部)
|
||||||
pcm_data = wav_file.readframes(wav_file.getnframes())
|
pcm_data = wav_file.readframes(wav_file.getnframes())
|
||||||
|
|
||||||
logger.info(f"📊 解析WAV头: {self.sample_rate}Hz, {self.channels}ch, {self.sampwidth * 8}bit")
|
logger.info(f"解析WAV头: {self.sample_rate}Hz, {self.channels}ch, {self.sampwidth * 8}bit")
|
||||||
|
|
||||||
# 转换为numpy数组
|
# 转换为numpy数组
|
||||||
if self.sampwidth == 2:
|
if self.sampwidth == 2:
|
||||||
@@ -68,7 +68,7 @@ class AsyncAudioPlayer:
|
|||||||
|
|
||||||
except wave.Error:
|
except wave.Error:
|
||||||
# 可能是不完整的WAV头,尝试直接播放
|
# 可能是不完整的WAV头,尝试直接播放
|
||||||
logger.warning("⚠️ WAV头解析失败,尝试直接播放")
|
logger.warning("WAV头解析失败,尝试直接播放")
|
||||||
await self._play_raw(audio_data)
|
await self._play_raw(audio_data)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
@@ -76,7 +76,7 @@ class AsyncAudioPlayer:
|
|||||||
await self._play_raw(audio_data)
|
await self._play_raw(audio_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 音频块处理失败: {e}")
|
logger.error(f"音频块处理失败: {e}")
|
||||||
|
|
||||||
async def _play_raw(self, audio_data: bytes):
|
async def _play_raw(self, audio_data: bytes):
|
||||||
"""播放RAW PCM数据"""
|
"""播放RAW PCM数据"""
|
||||||
@@ -90,11 +90,11 @@ class AsyncAudioPlayer:
|
|||||||
|
|
||||||
await self.audio_queue.put(audio_array)
|
await self.audio_queue.put(audio_array)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ RAW音频处理失败: {e}")
|
logger.error(f"RAW音频处理失败: {e}")
|
||||||
|
|
||||||
async def play_worker(self):
|
async def play_worker(self):
|
||||||
"""后台播放任务"""
|
"""后台播放任务"""
|
||||||
logger.info("🎧 音频播放任务启动")
|
logger.info("音频播放任务启动")
|
||||||
|
|
||||||
while self.is_playing or not self.audio_queue.empty():
|
while self.is_playing or not self.audio_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -103,7 +103,7 @@ class AsyncAudioPlayer:
|
|||||||
|
|
||||||
# 延迟初始化音频流(直到获得第一个数据块)
|
# 延迟初始化音频流(直到获得第一个数据块)
|
||||||
if self.stream is None:
|
if self.stream is None:
|
||||||
logger.info(f"🔊 打开音频输出流: {self.sample_rate}Hz")
|
logger.info(f"打开音频输出流: {self.sample_rate}Hz")
|
||||||
self.stream = sd.OutputStream(
|
self.stream = sd.OutputStream(
|
||||||
samplerate=self.sample_rate,
|
samplerate=self.sample_rate,
|
||||||
channels=1,
|
channels=1,
|
||||||
@@ -122,10 +122,10 @@ class AsyncAudioPlayer:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 播放任务异常: {e}")
|
logger.error(f"播放任务异常: {e}")
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info("🛑 音频播放任务结束")
|
logger.info("音频播放任务结束")
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""启动播放系统"""
|
"""启动播放系统"""
|
||||||
@@ -154,7 +154,7 @@ class AsyncAudioPlayer:
|
|||||||
except:
|
except:
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info("✅ 音频播放已停止")
|
logger.info("音频播放已停止")
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await self.start()
|
await self.start()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
设备命令 DTO - 服务端向客户端发送嵌入式设备控制指令
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import Field, BaseModel
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCommandDataTransferObject(BaseModel):
|
||||||
|
"""设备命令数据传输对象"""
|
||||||
|
device_id: str = Field(default="", description="目标设备ID")
|
||||||
|
payload: str = Field(default="", description="JSON-RPC 调用字符串")
|
||||||
|
|
||||||
|
def to_json(self) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "device_command",
|
||||||
|
"timestamp": datetime.now(timezone.utc).timestamp(),
|
||||||
|
"data": self.model_dump()
|
||||||
|
}
|
||||||
@@ -38,7 +38,9 @@ class JsonDTO(MessageDTO):
|
|||||||
super().__init__(ws_server)
|
super().__init__(ws_server)
|
||||||
self.receivers : Dict[str, List[ReceiveCallback]] = {
|
self.receivers : Dict[str, List[ReceiveCallback]] = {
|
||||||
'audio_data' : [], # 音频数据
|
'audio_data' : [], # 音频数据
|
||||||
'screenshot_data' : [] # 截图数据
|
'screenshot_data' : [], # 截图数据
|
||||||
|
'device_data' : [], # 嵌入式设备数据(注册/响应/事件)
|
||||||
|
'device_command' : [], # 设备控制指令(服务端→客户端)
|
||||||
}
|
}
|
||||||
# 注册json处理callback function
|
# 注册json处理callback function
|
||||||
ws_server.register_receiver('json', self._handle_json)
|
ws_server.register_receiver('json', self._handle_json)
|
||||||
|
|||||||
+320
-39
@@ -25,6 +25,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
import json
|
||||||
from src.modules.websocket_base_module.dto.third_dtos import (
|
from src.modules.websocket_base_module.dto.third_dtos import (
|
||||||
AudioDataDTO, AudioDataTransferObject,
|
AudioDataDTO, AudioDataTransferObject,
|
||||||
ScreenShotDataDTO, ScreenShotDataTransferObject
|
ScreenShotDataDTO, ScreenShotDataTransferObject
|
||||||
@@ -43,9 +44,15 @@ from src.server_core.llm_core.llm_core import (
|
|||||||
YosugaLLMCore, ModelProvider,
|
YosugaLLMCore, ModelProvider,
|
||||||
LLMCoreAnalysisBase,
|
LLMCoreAnalysisBase,
|
||||||
YosugaAudioResponseData, YosugaUITARSResponseData,
|
YosugaAudioResponseData, YosugaUITARSResponseData,
|
||||||
YosugaUITARSRequestData
|
YosugaUITARSRequestData, YosugaEmbeddedResponseData
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from src.server_core.yosuga_embedded_server import (
|
||||||
|
YosugaServer, ServerConfig
|
||||||
|
)
|
||||||
|
from src.server_core.yosuga_embedded_server.device_dto import DeviceDataDTO
|
||||||
|
from src.server_core.llm_core.llm_core_prompt_manager import YosugaEmbedded
|
||||||
|
|
||||||
from src.modules.websocket_base_module.dto.dto_templates.auto_agent_data_dto import AutoAgentDataTransferObject
|
from src.modules.websocket_base_module.dto.dto_templates.auto_agent_data_dto import AutoAgentDataTransferObject
|
||||||
from src.config.config import cfg
|
from src.config.config import cfg
|
||||||
|
|
||||||
@@ -70,6 +77,66 @@ class YosugaServerCore:
|
|||||||
|
|
||||||
llm_core: YosugaLLMCore = None # llm core
|
llm_core: YosugaLLMCore = None # llm core
|
||||||
|
|
||||||
|
embedded_server: YosugaServer # 嵌入式设备管理框架
|
||||||
|
device_dto: DeviceDataDTO # 设备数据分发器
|
||||||
|
|
||||||
|
# @classmethod
|
||||||
|
# async def get_instance(cls) -> "YosugaServerCore":
|
||||||
|
# """异步单例工厂"""
|
||||||
|
# if cls._instance is None:
|
||||||
|
# async with cls._lock:
|
||||||
|
# if cls._instance is None:
|
||||||
|
# logger.info("Initializing YosugaServerCore...")
|
||||||
|
# # 创建实例
|
||||||
|
# instance = cls.__new__(cls)
|
||||||
|
#
|
||||||
|
# # 按依赖顺序初始化数据分发器
|
||||||
|
# instance.ws_server = await get_ws_server()
|
||||||
|
# instance.json_dto = await get_json_dto_instance(instance.ws_server)
|
||||||
|
# instance.audio_dto = AudioDataDTO(instance.json_dto) # 音频分发器
|
||||||
|
# instance.audio_dto.register_audio_callback(instance._handle_audio_data) # 注册音频处理函数
|
||||||
|
# instance.screenshot_dto = ScreenShotDataDTO(instance.json_dto) # 截图分发器
|
||||||
|
# instance.screenshot_dto.register_screenshot_callback(instance._handle_screenshot_data) # 注册截图处理函数
|
||||||
|
#
|
||||||
|
# instance.asr_client = create_asr_client(use_async=True, base_url=cfg.asr.url)
|
||||||
|
# instance.tts_client = GPTSoVITSClient(host=cfg.tts.host, port=cfg.tts.port, debug=True)
|
||||||
|
# # 切换GPT_SoVITS模型
|
||||||
|
# await instance.tts_client.set_gpt_weights(cfg.tts.gpt_model_name)
|
||||||
|
# await instance.tts_client.set_sovits_weights(cfg.tts.sovits_model_name)
|
||||||
|
#
|
||||||
|
# instance.auto_agent_client = UITarsClient(UITarsClientConfig(
|
||||||
|
# deployment_type=cfg.auto_agent.deployment_type,
|
||||||
|
# base_url=cfg.auto_agent.base_url,
|
||||||
|
# model_name=cfg.auto_agent.model_name,
|
||||||
|
# temperature=cfg.auto_agent.temperature,
|
||||||
|
# max_tokens=cfg.auto_agent.max_tokens
|
||||||
|
# ))
|
||||||
|
#
|
||||||
|
# instance.llm_core = YosugaLLMCore(
|
||||||
|
# model_config=ModelConfig( # TODO 同上
|
||||||
|
# provider=ModelProvider.OPENAI,
|
||||||
|
# model_name=cfg.ai.model_name,
|
||||||
|
# base_url=cfg.ai.base_url,
|
||||||
|
# api_key=cfg.ai.api_key,
|
||||||
|
# temperature=cfg.ai.temperature,
|
||||||
|
# max_tokens=cfg.ai.max_tokens
|
||||||
|
# ),
|
||||||
|
# core_config=LLMCoreConfig( # TODO 同上
|
||||||
|
# max_context_tokens=cfg.llm_core.max_context_tokens,
|
||||||
|
# enable_history=cfg.llm_core.enable_history,
|
||||||
|
# role_setting=cfg.llm_core.role_character,
|
||||||
|
# language=cfg.llm_core.language, # 回复使用语言
|
||||||
|
# auto_dispatch=True,
|
||||||
|
# dispatch_async=True # 启用异步分发
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# instance.register_llm_core_analysis() # 注册解析器
|
||||||
|
# instance.register_llm_core_action() # 注册分发器
|
||||||
|
# instance.llm_core.register_overflow_handler(instance._handle_overflow_logger) # 注册上下文溢出处理回调
|
||||||
|
#
|
||||||
|
# cls._instance = instance
|
||||||
|
# logger.success("YosugaServerCore initialized")
|
||||||
|
# return cls._instance
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_instance(cls) -> "YosugaServerCore":
|
async def get_instance(cls) -> "YosugaServerCore":
|
||||||
"""异步单例工厂"""
|
"""异步单例工厂"""
|
||||||
@@ -77,57 +144,141 @@ class YosugaServerCore:
|
|||||||
async with cls._lock:
|
async with cls._lock:
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
logger.info("Initializing YosugaServerCore...")
|
logger.info("Initializing YosugaServerCore...")
|
||||||
|
|
||||||
|
# 强制初始化配置
|
||||||
|
from src.config.config import _ensure_initialized
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
|
||||||
|
real_cfg = _ensure_initialized()
|
||||||
|
|
||||||
|
# 辅助函数:递归转换为 dict
|
||||||
|
def to_dict(obj):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj
|
||||||
|
if is_dataclass(obj) and not isinstance(obj, type):
|
||||||
|
return asdict(obj)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 提取各个配置段并转换为 dict(关键修复)
|
||||||
|
cfg_dict = {
|
||||||
|
'ai': to_dict(getattr(real_cfg, 'ai', {})),
|
||||||
|
'tts': to_dict(getattr(real_cfg, 'tts', {})),
|
||||||
|
'asr': to_dict(getattr(real_cfg, 'asr', {})),
|
||||||
|
'auto_agent': to_dict(getattr(real_cfg, 'auto_agent', {})),
|
||||||
|
'llm_core': to_dict(getattr(real_cfg, 'llm_core', {})),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"配置提取完成: ai={type(cfg_dict['ai'])}, tts={type(cfg_dict['tts'])}")
|
||||||
|
|
||||||
# 创建实例
|
# 创建实例
|
||||||
instance = cls.__new__(cls)
|
instance = cls.__new__(cls)
|
||||||
|
|
||||||
# 按依赖顺序初始化数据分发器
|
# 按依赖顺序初始化数据分发器
|
||||||
instance.ws_server = await get_ws_server()
|
instance.ws_server = await get_ws_server()
|
||||||
instance.json_dto = await get_json_dto_instance(instance.ws_server)
|
instance.json_dto = await get_json_dto_instance(instance.ws_server)
|
||||||
instance.audio_dto = AudioDataDTO(instance.json_dto) # 音频分发器
|
instance.audio_dto = AudioDataDTO(instance.json_dto)
|
||||||
instance.audio_dto.register_audio_callback(instance._handle_audio_data) # 注册音频处理函数
|
instance.audio_dto.register_audio_callback(instance._handle_audio_data)
|
||||||
instance.screenshot_dto = ScreenShotDataDTO(instance.json_dto) # 截图分发器
|
instance.screenshot_dto = ScreenShotDataDTO(instance.json_dto)
|
||||||
instance.screenshot_dto.register_screenshot_callback(instance._handle_screenshot_data) # 注册截图处理函数
|
instance.screenshot_dto.register_screenshot_callback(instance._handle_screenshot_data)
|
||||||
|
|
||||||
instance.asr_client = create_asr_client(use_async=True, base_url=cfg.asr.url)
|
# ASR 客户端
|
||||||
instance.tts_client = GPTSoVITSClient(host=cfg.tts.host, port=cfg.tts.port, debug=True)
|
asr_cfg = cfg_dict.get('asr', {})
|
||||||
# 切换GPT_SoVITS模型
|
instance.asr_client = create_asr_client(
|
||||||
await instance.tts_client.set_gpt_weights(cfg.tts.gpt_model_name)
|
use_async=True,
|
||||||
await instance.tts_client.set_sovits_weights(cfg.tts.sovits_model_name)
|
base_url=asr_cfg.get('url', 'http://localhost:20260/')
|
||||||
|
)
|
||||||
|
|
||||||
|
# TTS 客户端
|
||||||
|
tts_cfg = cfg_dict.get('tts', {})
|
||||||
|
instance.tts_client = GPTSoVITSClient(
|
||||||
|
host=tts_cfg.get('host', 'localhost'),
|
||||||
|
port=tts_cfg.get('port', 20261),
|
||||||
|
debug=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 切换 GPT_SoVITS 模型
|
||||||
|
# await instance.tts_client.set_gpt_weights(
|
||||||
|
# tts_cfg.get('gpt_model_name', 'GPT_weights_v2Pro/Yosuga_Airi-e32.ckpt')
|
||||||
|
# )
|
||||||
|
# await instance.tts_client.set_sovits_weights(
|
||||||
|
# tts_cfg.get('sovits_model_name', 'SoVITS_weights_v2Pro/Yosuga_Airi_e16_s864.pth')
|
||||||
|
# )
|
||||||
|
|
||||||
|
# Auto Agent 客户端
|
||||||
|
auto_cfg = cfg_dict.get('auto_agent', {})
|
||||||
instance.auto_agent_client = UITarsClient(UITarsClientConfig(
|
instance.auto_agent_client = UITarsClient(UITarsClientConfig(
|
||||||
deployment_type=cfg.auto_agent.deployment_type,
|
deployment_type=auto_cfg.get('deployment_type', 'lmstudio'),
|
||||||
base_url=cfg.auto_agent.base_url,
|
base_url=auto_cfg.get('base_url', 'http://localhost:1234/v1'),
|
||||||
model_name=cfg.auto_agent.model_name,
|
model_name=auto_cfg.get('model_name', 'ui-tars-1.5-7b@q4_k_m'),
|
||||||
temperature=cfg.auto_agent.temperature,
|
temperature=auto_cfg.get('temperature', 0.1),
|
||||||
max_tokens=cfg.auto_agent.max_tokens
|
max_tokens=auto_cfg.get('max_tokens', 16384)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# LLM Core
|
||||||
|
ai_cfg = cfg_dict.get('ai', {})
|
||||||
|
llm_cfg = cfg_dict.get('llm_core', {})
|
||||||
|
|
||||||
instance.llm_core = YosugaLLMCore(
|
instance.llm_core = YosugaLLMCore(
|
||||||
model_config=ModelConfig( # TODO 同上
|
model_config=ModelConfig(
|
||||||
provider=ModelProvider.OPENAI,
|
provider=ModelProvider.OPENAI,
|
||||||
model_name=cfg.ai.model_name,
|
model_name=ai_cfg.get('model_name', 'qwen/qwen3-4b-2507'),
|
||||||
base_url=cfg.ai.base_url,
|
base_url=ai_cfg.get('base_url', 'http://localhost:1234/v1'),
|
||||||
api_key=cfg.ai.api_key,
|
api_key=ai_cfg.get('api_key'),
|
||||||
temperature=cfg.ai.temperature,
|
temperature=ai_cfg.get('temperature', 0.4),
|
||||||
max_tokens=cfg.ai.max_tokens
|
max_tokens=ai_cfg.get('max_tokens', 8192)
|
||||||
),
|
),
|
||||||
core_config=LLMCoreConfig( # TODO 同上
|
core_config=LLMCoreConfig(
|
||||||
max_context_tokens=cfg.llm_core.max_context_tokens,
|
max_context_tokens=llm_cfg.get('max_context_tokens', 2048),
|
||||||
enable_history=cfg.llm_core.enable_history,
|
enable_history=llm_cfg.get('enable_history', True),
|
||||||
role_setting=cfg.llm_core.role_character,
|
role_setting=llm_cfg.get('role_character',
|
||||||
language=cfg.llm_core.language, # 回复使用语言
|
'你是由Misakiotoha开发的助手稲葉愛理ちゃん,可以和用户一起玩游戏,聊天,做各种事情,性格抽象,没事爱整整活。'),
|
||||||
|
language=llm_cfg.get('language', '中文'),
|
||||||
auto_dispatch=True,
|
auto_dispatch=True,
|
||||||
dispatch_async=True # 启用异步分发
|
dispatch_async=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
instance.register_llm_core_analysis() # 注册解析器
|
|
||||||
instance.register_llm_core_action() # 注册分发器
|
# 注册 YosugaEmbedded 提示词模块
|
||||||
instance.llm_core.register_overflow_handler(instance._handle_overflow_logger) # 注册上下文溢出处理回调
|
instance.llm_core.register_prompt_module(YosugaEmbedded())
|
||||||
|
logger.info("[Core] 嵌入式设备提示词模块已注册")
|
||||||
|
|
||||||
|
# 初始化嵌入式设备管理框架
|
||||||
|
instance.embedded_server = YosugaServer(
|
||||||
|
config=ServerConfig(
|
||||||
|
device_conflict_strategy="rename",
|
||||||
|
max_concurrent_calls=10,
|
||||||
|
device_timeout=30.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance.device_dto = DeviceDataDTO(
|
||||||
|
instance.json_dto, instance.embedded_server
|
||||||
|
)
|
||||||
|
# 当 YosugaServer 需要发送 RPC 到设备时,通过 WebSocket 发出 device_command
|
||||||
|
instance.embedded_server.on_device_message = (
|
||||||
|
instance._on_device_message
|
||||||
|
)
|
||||||
|
# 当设备能力变更时,更新 LLM 系统提示词中的状态表
|
||||||
|
instance.embedded_server.on_capabilities_changed = (
|
||||||
|
instance._on_capabilities_changed
|
||||||
|
)
|
||||||
|
logger.success("[Core] 嵌入式设备管理框架已初始化")
|
||||||
|
|
||||||
|
# 注册设备 RPC 响应回调(设备结果回来后喂回 LLM)
|
||||||
|
instance.device_dto.register_device_callback(
|
||||||
|
instance._on_device_rpc_response
|
||||||
|
)
|
||||||
|
instance._pending_rpc: Optional[dict] = None
|
||||||
|
|
||||||
|
instance.register_llm_core_analysis()
|
||||||
|
instance.register_llm_core_action()
|
||||||
|
instance.llm_core.register_overflow_handler(instance._handle_overflow_logger)
|
||||||
|
|
||||||
cls._instance = instance
|
cls._instance = instance
|
||||||
logger.success("YosugaServerCore initialized")
|
logger.success("YosugaServerCore initialized")
|
||||||
|
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
|
||||||
def register_llm_core_action(self):
|
def register_llm_core_action(self):
|
||||||
"""
|
"""
|
||||||
注册llm_core的分发器
|
注册llm_core的分发器
|
||||||
@@ -137,6 +288,7 @@ class YosugaServerCore:
|
|||||||
self.llm_core.register_action_handler("audio_text", self._handle_audio_response, is_async=True)
|
self.llm_core.register_action_handler("audio_text", self._handle_audio_response, is_async=True)
|
||||||
self.llm_core.register_action_handler("auto_agent", self._handle_auto_agent, is_async=True)
|
self.llm_core.register_action_handler("auto_agent", self._handle_auto_agent, is_async=True)
|
||||||
self.llm_core.register_action_handler("call_auto_agent", self._handle_call_auto_agent, is_async=True)
|
self.llm_core.register_action_handler("call_auto_agent", self._handle_call_auto_agent, is_async=True)
|
||||||
|
self.llm_core.register_action_handler("embedded_control", self._handle_embedded_control, is_async=True)
|
||||||
self.llm_core.set_fallback_handler(self._handle_fallback)
|
self.llm_core.set_fallback_handler(self._handle_fallback)
|
||||||
|
|
||||||
def register_llm_core_analysis(self):
|
def register_llm_core_analysis(self):
|
||||||
@@ -148,6 +300,7 @@ class YosugaServerCore:
|
|||||||
self.llm_core.register_analysis_model(YosugaAudioResponseData)
|
self.llm_core.register_analysis_model(YosugaAudioResponseData)
|
||||||
self.llm_core.register_analysis_model(YosugaUITARSResponseData)
|
self.llm_core.register_analysis_model(YosugaUITARSResponseData)
|
||||||
self.llm_core.register_analysis_model(YosugaUITARSRequestData)
|
self.llm_core.register_analysis_model(YosugaUITARSRequestData)
|
||||||
|
self.llm_core.register_analysis_model(YosugaEmbeddedResponseData)
|
||||||
|
|
||||||
def _handle_overflow_logger(self, history: List[Any], metadata: Dict[str, Any]):
|
def _handle_overflow_logger(self, history: List[Any], metadata: Dict[str, Any]):
|
||||||
"""上下文溢出记录,仅打印日志"""
|
"""上下文溢出记录,仅打印日志"""
|
||||||
@@ -211,14 +364,23 @@ class YosugaServerCore:
|
|||||||
try:
|
try:
|
||||||
# 使用最快模式流式输出
|
# 使用最快模式流式输出
|
||||||
chunk_count = 0
|
chunk_count = 0
|
||||||
|
# async for chunk in await self.tts_client.tts(
|
||||||
|
# text=data.response_text,
|
||||||
|
# ref_audio_path="uploaded_audio/test_voice.wav", # TODO 需要替换成config或者后续设计情感系统
|
||||||
|
# text_lang="ja",
|
||||||
|
# prompt_lang="ja",
|
||||||
|
# prompt_text="もう!こんなところで何やってるんだよ!", # 参考语音的真实文本
|
||||||
|
# streaming_mode=StreamingMode.FASTEST, # 模式3:快速流式
|
||||||
|
# media_type="wav"
|
||||||
|
# ):
|
||||||
async for chunk in await self.tts_client.tts(
|
async for chunk in await self.tts_client.tts(
|
||||||
text=data.response_text,
|
text=data.response_text,
|
||||||
ref_audio_path="uploaded_audio/test_voice.wav", # TODO 需要替换成config或者后续设计情感系统
|
ref_audio_path="uploaded_audio/kq.wav", # TODO 需要替换成config或者后续设计情感系统
|
||||||
text_lang="ja",
|
text_lang="zh",
|
||||||
prompt_lang="ja",
|
prompt_lang="zh",
|
||||||
prompt_text="もう!こんなところで何やってるんだよ!", # 参考语音的真实文本
|
prompt_text="电闪雷鸣虽然有点吓人,但璃月港的防雷防火工事是一流的,不用担心。", # 参考语音的真实文本
|
||||||
streaming_mode=StreamingMode.FASTEST, # 模式3:快速流式
|
streaming_mode=StreamingMode.FASTEST, # 模式3:快速流式
|
||||||
media_type="wav"
|
media_type="wav"
|
||||||
):
|
):
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
# print(f"🎵 收到音频块 #{chunk_count}: {len(chunk.audio_data)} bytes")
|
# print(f"🎵 收到音频块 #{chunk_count}: {len(chunk.audio_data)} bytes")
|
||||||
@@ -245,7 +407,7 @@ class YosugaServerCore:
|
|||||||
text=data.response_text
|
text=data.response_text
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
print(f"✅ 流式TTS完成!共{chunk_count}个音频块")
|
print(f"流式TTS完成!共{chunk_count}个音频块")
|
||||||
# 构造音频尾包发送给客户端(虚假的音频数据)
|
# 构造音频尾包发送给客户端(虚假的音频数据)
|
||||||
await self.audio_dto.send_audio_data(
|
await self.audio_dto.send_audio_data(
|
||||||
AudioDataTransferObject(
|
AudioDataTransferObject(
|
||||||
@@ -258,7 +420,7 @@ class YosugaServerCore:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 流式错误: {e}")
|
print(f"流式错误: {e}")
|
||||||
return {"status": "success", "executed": data.response_text}
|
return {"status": "success", "executed": data.response_text}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -284,6 +446,125 @@ class YosugaServerCore:
|
|||||||
await self.screenshot_dto.send_screenshot_data(ScreenShotDataTransferObject(LLMResponse=data.llm_translation))
|
await self.screenshot_dto.send_screenshot_data(ScreenShotDataTransferObject(LLMResponse=data.llm_translation))
|
||||||
return {"status": "success", "executed": data.type}
|
return {"status": "success", "executed": data.type}
|
||||||
|
|
||||||
|
async def _handle_embedded_control(self, data: YosugaEmbeddedResponseData):
|
||||||
|
"""
|
||||||
|
llm_core异步处理器:嵌入式设备控制
|
||||||
|
将LLM输出的 JSON-RPC 调用列表交由 YosugaServer 框架处理并路由到对应设备
|
||||||
|
"""
|
||||||
|
logger.info(f"Handling embedded control: {len(data.calls)} calls")
|
||||||
|
|
||||||
|
results = self.embedded_server.process_ai_response(json.dumps(data.calls))
|
||||||
|
logger.info(f"Embedded control results: {results}")
|
||||||
|
|
||||||
|
# 保存 pending RPC 信息,等设备异步响应回来后喂回 LLM
|
||||||
|
if results and len(results) > 0:
|
||||||
|
first_call = results[0]
|
||||||
|
self._pending_rpc = {
|
||||||
|
"device_id": first_call.get("device_id"),
|
||||||
|
"method": first_call.get("method"),
|
||||||
|
"call_id": first_call.get("id"),
|
||||||
|
"original_response_text": data.response_text or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果 LLM 同时返回了需要回复用户的文本,通过 TTS 播报
|
||||||
|
if data.response_text:
|
||||||
|
try:
|
||||||
|
chunk_count = 0
|
||||||
|
# async for chunk in await self.tts_client.tts(
|
||||||
|
# text=data.response_text,
|
||||||
|
# ref_audio_path="uploaded_audio/test_voice.wav",
|
||||||
|
# text_lang="ja",
|
||||||
|
# prompt_lang="ja",
|
||||||
|
# prompt_text="もう!こんなところで何やってるんだよ!",
|
||||||
|
# streaming_mode=StreamingMode.FASTEST,
|
||||||
|
# media_type="wav"
|
||||||
|
# ):
|
||||||
|
async for chunk in await self.tts_client.tts(
|
||||||
|
text=data.response_text,
|
||||||
|
ref_audio_path="uploaded_audio/kq.wav", # TODO 需要替换成config或者后续设计情感系统
|
||||||
|
text_lang="zh",
|
||||||
|
prompt_lang="zh",
|
||||||
|
prompt_text="电闪雷鸣虽然有点吓人,但璃月港的防雷防火工事是一流的,不用担心。", # 参考语音的真实文本
|
||||||
|
streaming_mode=StreamingMode.FASTEST, # 模式3:快速流式
|
||||||
|
media_type="wav"
|
||||||
|
):
|
||||||
|
chunk_count += 1
|
||||||
|
if chunk_count == 1:
|
||||||
|
await self.audio_dto.send_audio_data(
|
||||||
|
AudioDataTransferObject(
|
||||||
|
data=chunk.audio_data,
|
||||||
|
isStream=True, isStart=True,
|
||||||
|
sequence=chunk_count, isEnd=False,
|
||||||
|
text=data.response_text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.audio_dto.send_audio_data(
|
||||||
|
AudioDataTransferObject(
|
||||||
|
data=chunk.audio_data,
|
||||||
|
isStream=True, isStart=False,
|
||||||
|
sequence=chunk_count, isEnd=False,
|
||||||
|
text=data.response_text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.audio_dto.send_audio_data(
|
||||||
|
AudioDataTransferObject(
|
||||||
|
data=b"0",
|
||||||
|
isStream=True, isStart=False,
|
||||||
|
sequence=chunk_count + 1, isEnd=True,
|
||||||
|
text=data.response_text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Embedded control TTS error: {e}")
|
||||||
|
|
||||||
|
return {"status": "success", "calls": len(data.calls)}
|
||||||
|
|
||||||
|
def _on_device_rpc_response(self, device_id: str, payload: dict):
|
||||||
|
"""DeviceDataDTO 回调:设备 RPC 响应回来时触发,喂回 LLM"""
|
||||||
|
if self._pending_rpc and self._pending_rpc.get("device_id") == device_id:
|
||||||
|
call_id = payload.get("id")
|
||||||
|
if call_id is None or call_id == self._pending_rpc.get("call_id"):
|
||||||
|
pending = self._pending_rpc
|
||||||
|
self._pending_rpc = None
|
||||||
|
asyncio.create_task(self._continue_with_device_result(device_id, payload, pending))
|
||||||
|
|
||||||
|
async def _continue_with_device_result(self, device_id: str, payload: dict, pending: dict):
|
||||||
|
"""设备 RPC 结果回来后,喂回 LLM 生成最终回复并 TTS"""
|
||||||
|
method = pending.get("method", "unknown")
|
||||||
|
original_text = pending.get("original_response_text", "")
|
||||||
|
|
||||||
|
result_str = json.dumps(payload.get("result", payload), ensure_ascii=False)
|
||||||
|
followup_input = (
|
||||||
|
f"你之前请求设备 {device_id} 执行了 {method} 操作,"
|
||||||
|
f"现在设备返回了结果:{result_str}。\n"
|
||||||
|
f"你之前的回复是:'{original_text}'\n"
|
||||||
|
f"请基于设备返回的实际结果,用自然语言重新组织回复,告诉用户结果。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
llm_result = await self.llm_core.interact(user_input={"text": followup_input})
|
||||||
|
logger.info(f"[Core] 设备结果回送 LLM 完成: {llm_result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Core] 设备结果回送 LLM 失败: {e}")
|
||||||
|
|
||||||
|
def _on_device_message(self, device_id: str, rpc_call: str) -> Optional[str]:
|
||||||
|
"""YosugaServer 的设备消息回调:通过 WebSocket 发送 RPC 到客户端"""
|
||||||
|
logger.info(f"[Core] 发送设备命令到 {device_id}")
|
||||||
|
asyncio.create_task(self.device_dto.send_device_command(device_id, rpc_call))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _on_capabilities_changed(self, capabilities: dict):
|
||||||
|
"""设备能力变更回调:更新 LLM 系统提示词中的状态表"""
|
||||||
|
functions_str = json.dumps(capabilities.get("functions", []), ensure_ascii=False, indent=2)
|
||||||
|
device_str = json.dumps(capabilities.get("devices", {}), ensure_ascii=False, indent=2)
|
||||||
|
state_table = (
|
||||||
|
f"【当前在线设备】\n{device_str}\n\n"
|
||||||
|
f"【设备可用函数】\n{functions_str}"
|
||||||
|
)
|
||||||
|
self.llm_core.core_config.system_state_table = state_table
|
||||||
|
logger.info(f"[Core] 系统状态表已更新 | 设备: {capabilities.get('device_count', 0)} 台 | 函数: {capabilities.get('function_count', 0)} 个")
|
||||||
|
|
||||||
def _handle_fallback(self, data: LLMCoreAnalysisBase):
|
def _handle_fallback(self, data: LLMCoreAnalysisBase):
|
||||||
"""
|
"""
|
||||||
llm_core同步处理器:回退处理器
|
llm_core同步处理器:回退处理器
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from src.modules.text_ai_module.text_ai_core.general_text_ai_req import (
|
|||||||
)
|
)
|
||||||
from src.server_core.llm_core.llm_core_analysis import (
|
from src.server_core.llm_core.llm_core_analysis import (
|
||||||
LLMCoreAnalysisManager, LLMCoreAnalysisBase, YosugaAudioResponseData, YosugaUITARSResponseData, YosugaUITARSRequestData
|
LLMCoreAnalysisManager, LLMCoreAnalysisBase, YosugaAudioResponseData, YosugaUITARSResponseData, YosugaUITARSRequestData
|
||||||
|
, YosugaEmbeddedResponseData
|
||||||
)
|
)
|
||||||
from src.server_core.llm_core.llm_core_dispatcher import LLMCoreActionDispatcher
|
from src.server_core.llm_core.llm_core_dispatcher import LLMCoreActionDispatcher
|
||||||
from src.server_core.llm_core.llm_core_prompt_manager import (
|
from src.server_core.llm_core.llm_core_prompt_manager import (
|
||||||
|
|||||||
@@ -249,18 +249,25 @@ class YosugaLive2DResponseData(LLMCoreAnalysisBase):
|
|||||||
|
|
||||||
class YosugaEmbeddedResponseData(LLMCoreAnalysisBase):
|
class YosugaEmbeddedResponseData(LLMCoreAnalysisBase):
|
||||||
"""
|
"""
|
||||||
嵌入式设备场景的LLM输出数据模型 TODO
|
嵌入式设备场景的LLM输出数据模型
|
||||||
|
LLM输出 JSON-RPC 风格的函数调用,由服务端解析并路由到对应设备
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: str = Field(default="embedded_control", description="固定为embedded_control")
|
type: str = Field(default="embedded_control", description="固定为embedded_control")
|
||||||
device_id: str = Field(..., description="设备ID")
|
calls: list[dict] = Field(default_factory=list, description="JSON-RPC 调用列表,每项含 method/params/id")
|
||||||
command: str = Field(..., description="控制指令")
|
response_text: str = Field(default="", description="同时回复给用户的文本(可选)")
|
||||||
params: Optional[Dict[str, Any]] = Field(default=None, description="参数")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def type_(cls) -> str:
|
def type_(cls) -> str:
|
||||||
return "embedded_control"
|
return "embedded_control"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"type": self.type,
|
||||||
|
"calls": self.calls,
|
||||||
|
"response_text": self.response_text
|
||||||
|
}
|
||||||
|
|
||||||
# 使用示例
|
# 使用示例
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from typing import Callable, List, Optional, Coroutine, Any, ClassVar, Dict
|
from typing import Callable, List, Optional, Coroutine, Any, ClassVar, Dict
|
||||||
from src.server_core.llm_core.llm_core_prompts import YOSUGA_SYSTEM_PROMPT_SCH
|
from src.server_core.llm_core.llm_core_prompts import YOSUGA_SYSTEM_PROMPT_SCH
|
||||||
|
|
||||||
|
|
||||||
class LLMCorePromptBase(BaseModel, ABC):
|
class LLMCorePromptBase(BaseModel, ABC):
|
||||||
"""LLM 提示词基类:定义输入输出结构"""
|
"""LLM 提示词基类:定义输入输出结构"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
"""返回该提示词类型的唯一标识"""
|
"""返回该提示词类型的唯一标识"""
|
||||||
@@ -62,8 +64,10 @@ class LLMCorePromptManager(LLMCorePromptBase):
|
|||||||
for type_id, son in self._registry.items()
|
for type_id, son in self._registry.items()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class YosugaAudioASRText(LLMCorePromptBase):
|
class YosugaAudioASRText(LLMCorePromptBase):
|
||||||
"""音频ASR文本输入场景"""
|
"""音频ASR文本输入场景"""
|
||||||
|
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
return "用户语音asr信息"
|
return "用户语音asr信息"
|
||||||
|
|
||||||
@@ -92,19 +96,50 @@ class YosugaAudioASRText(LLMCorePromptBase):
|
|||||||
- `action`: 触发的动作指令,如"wave_hand"、"nod"等,"none"表示无动作
|
- `action`: 触发的动作指令,如"wave_hand"、"nod"等,"none"表示无动作
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
class YosugaEmbedded(LLMCorePromptBase):
|
class YosugaEmbedded(LLMCorePromptBase):
|
||||||
"""嵌入式设备输入场景"""
|
"""嵌入式设备控制场景"""
|
||||||
|
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
pass
|
return "嵌入式设备信息"
|
||||||
|
|
||||||
def describe_input(self) -> str:
|
def describe_input(self) -> str:
|
||||||
pass
|
return '''
|
||||||
|
当嵌入式设备有数据上报或客户端发来设备状态时,你会收到以下格式:
|
||||||
|
{
|
||||||
|
"device_event": {
|
||||||
|
"device_id": "设备ID",
|
||||||
|
"event": "事件内容"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
但大多数情况下,你只需参考系统状态表中的设备函数表来决策。
|
||||||
|
'''
|
||||||
|
|
||||||
def describe_output(self) -> str:
|
def describe_output(self) -> str:
|
||||||
pass
|
return '''
|
||||||
|
当你需要控制嵌入式设备时,按以下JSON格式返回:
|
||||||
|
{
|
||||||
|
"type": "固定为embedded_control",
|
||||||
|
"calls": [
|
||||||
|
{
|
||||||
|
"method": "函数名(来自系统状态表中的设备能力表)",
|
||||||
|
"params": { "参数名": 参数值 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_text": "同时回复给用户的文本说明(可选,可留空)"
|
||||||
|
}
|
||||||
|
- `calls`: JSON-RPC 调用列表,每个调用对应一个设备函数
|
||||||
|
- `method`: 必须来自系统状态表中列出的可用函数名
|
||||||
|
- `params`: 按函数定义的参数传入,可省略无参数的调用
|
||||||
|
- `response_text`: 可选,若同时需要回复用户可在此填写
|
||||||
|
注意:仅当用户意图涉及现实世界控制时,才需要返回 embedded_control。
|
||||||
|
如果只是聊天,只需返回 audio_text。
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
class YosugaUITARS(LLMCorePromptBase):
|
class YosugaUITARS(LLMCorePromptBase):
|
||||||
"""自动化操作构建场景"""
|
"""自动化操作构建场景"""
|
||||||
|
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
return "自动化操作信息"
|
return "自动化操作信息"
|
||||||
|
|
||||||
@@ -144,8 +179,10 @@ class YosugaUITARS(LLMCorePromptBase):
|
|||||||
注意:自动化agent的状态可见YosugaSystemState表。
|
注意:自动化agent的状态可见YosugaSystemState表。
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
class YosugaLive2DControl(LLMCorePromptBase):
|
class YosugaLive2DControl(LLMCorePromptBase):
|
||||||
"""对Yosuga Live2D控制场景"""
|
"""对Yosuga Live2D控制场景"""
|
||||||
|
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
return "Yosuga Live2D控制信息"
|
return "Yosuga Live2D控制信息"
|
||||||
|
|
||||||
@@ -168,7 +205,7 @@ if __name__ == "__main__":
|
|||||||
OutputInfo=manager.describe_output(),
|
OutputInfo=manager.describe_output(),
|
||||||
RoleSetting="...",
|
RoleSetting="...",
|
||||||
Language="ja",
|
Language="ja",
|
||||||
Memory = "",
|
Memory="",
|
||||||
SystemStateTable=""
|
SystemStateTable=""
|
||||||
)
|
)
|
||||||
print(system_prompt)
|
print(system_prompt)
|
||||||
@@ -60,7 +60,7 @@ class TokenManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _get_tokenizer(self, model_name: str) -> tiktoken.Encoding:
|
def _get_tokenizer(self, model_name: str) -> tiktoken.Encoding:
|
||||||
"""获取 tokenizer(与之前实现相同,省略重复代码)"""
|
"""获取 tokenizer"""
|
||||||
model_tokenizer_map = {
|
model_tokenizer_map = {
|
||||||
"qwen": "gpt-3.5-turbo",
|
"qwen": "gpt-3.5-turbo",
|
||||||
"llama": "gpt-3.5-turbo",
|
"llama": "gpt-3.5-turbo",
|
||||||
@@ -178,7 +178,7 @@ class TokenManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def count_text_tokens(self, text: str) -> int:
|
def count_text_tokens(self, text: str) -> int:
|
||||||
"""计算单段文本的 token 数量(与之前相同)"""
|
"""计算单段文本的 token 数量"""
|
||||||
if not isinstance(text, str) or not text:
|
if not isinstance(text, str) or not text:
|
||||||
return 0
|
return 0
|
||||||
return len(self.tokenizer.encode(text))
|
return len(self.tokenizer.encode(text))
|
||||||
@@ -188,7 +188,7 @@ class TokenManager:
|
|||||||
messages: List[Any],
|
messages: List[Any],
|
||||||
tokens_per_message: int = 3
|
tokens_per_message: int = 3
|
||||||
) -> int:
|
) -> int:
|
||||||
"""计算消息列表的总 token 数量(与之前相同,优化实现)"""
|
"""计算消息列表的总 token 数量"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ class TokenManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_tokenizer_info(self) -> TokenizerInfo:
|
def get_tokenizer_info(self) -> TokenizerInfo:
|
||||||
"""获取当前 tokenizer 的详细信息(与之前相同)"""
|
"""获取当前 tokenizer 的详细信息"""
|
||||||
if "cl100k_base" in self.tokenizer.name and "gpt-3.5" not in self.model_name:
|
if "cl100k_base" in self.tokenizer.name and "gpt-3.5" not in self.model_name:
|
||||||
accuracy = "low"
|
accuracy = "low"
|
||||||
elif self.model_name in self.tokenizer.name:
|
elif self.model_name in self.tokenizer.name:
|
||||||
@@ -332,7 +332,7 @@ class TokenManager:
|
|||||||
limit: int,
|
limit: int,
|
||||||
threshold: float = 0.85
|
threshold: float = 0.85
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""判断 token 使用量是否接近限制(与之前相同)"""
|
"""判断 token 使用量是否接近限制"""
|
||||||
return current_tokens > limit * threshold
|
return current_tokens > limit * threshold
|
||||||
|
|
||||||
def calculate_chunk_size(
|
def calculate_chunk_size(
|
||||||
@@ -340,7 +340,7 @@ class TokenManager:
|
|||||||
available_tokens: int,
|
available_tokens: int,
|
||||||
safety_margin: float = 0.1
|
safety_margin: float = 0.1
|
||||||
) -> int:
|
) -> int:
|
||||||
"""计算安全的消息块大小(与之前相同)"""
|
"""计算安全的消息块大小"""
|
||||||
return int(available_tokens * (1 - safety_margin))
|
return int(available_tokens * (1 - safety_margin))
|
||||||
|
|
||||||
def clear_api_usage_cache(self):
|
def clear_api_usage_cache(self):
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Yosuga 服务端 - 面向AI的嵌入式设备JSON-RPC框架。
|
||||||
|
|
||||||
|
管理多个嵌入式设备,每个设备暴露AI可调用的函数。
|
||||||
|
服务端维护全局函数注册表、生成描述可用能力的AI提示词,
|
||||||
|
并在AI和设备之间路由JSON-RPC调用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .device_manager import DeviceManager, DeviceInfo, DeviceState
|
||||||
|
from .function_registry import FunctionRegistry, FunctionInfo, ParamInfo, FuncType
|
||||||
|
from .ai_prompt import AIPromptBuilder
|
||||||
|
from .json_rpc import JSONRPCHandler, RPCRequest, RPCResponse, RPCError
|
||||||
|
from .server import YosugaServer, ServerConfig
|
||||||
|
from .device_dto import DeviceDataDTO
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
AI提示词构建器 - 构造用于AI函数调用的结构化提示词。
|
||||||
|
|
||||||
|
提示词引导AI输出有效的JSON-RPC调用,
|
||||||
|
服务端可将这些调用转发到相应的嵌入式设备。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# -- 约束AI输出的系统提示词模板 --
|
||||||
|
SYSTEM_PROMPT_TEMPLATE = """你是一个通过函数调用来控制嵌入式设备的AI助手。
|
||||||
|
|
||||||
|
你可以访问所有已连接设备上的以下函数:
|
||||||
|
|
||||||
|
{functions_table}
|
||||||
|
|
||||||
|
指令:
|
||||||
|
1. 你可以同时调用一个或多个函数。
|
||||||
|
2. 要调用函数,请以JSON数组格式返回RPC调用:
|
||||||
|
{calls_syntax_example}
|
||||||
|
3. 始终为所有必填参数提供有效值。
|
||||||
|
4. 对于可选参数,仅在需要时包含它们。
|
||||||
|
5. 如果函数返回数据,结果将回传给你。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 只能调用上面列出的函数。
|
||||||
|
- 不要虚构函数名或参数。
|
||||||
|
- 如果需要的功能没有对应的函数支持,请说明你需要的功能。
|
||||||
|
- 如果进行函数调用,只返回JSON数组(不要额外文字)。
|
||||||
|
- 如果需要提问或提供信息,添加"_explanation"字段。
|
||||||
|
|
||||||
|
函数调用的响应格式:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "function_name",
|
||||||
|
"params": {{ "param1": value1, "param2": value2 }},
|
||||||
|
"id": 1
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
设备路由: 每个函数属于特定设备(显示为@device_name)。
|
||||||
|
系统会自动将调用路由到正确的设备。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
FUNCTION_ENTRY_TEMPLATE = """### {name} (类型: {type}) @ {device_name}
|
||||||
|
描述: {description}
|
||||||
|
参数:
|
||||||
|
{params}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PARAM_ENTRY_TEMPLATE = " - {name} ({type}{optional}): {description}"
|
||||||
|
|
||||||
|
|
||||||
|
CALLS_SYNTAX_EXAMPLE = """[
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "function_name",
|
||||||
|
"params": {
|
||||||
|
"param1": "value1",
|
||||||
|
"param2": 42
|
||||||
|
},
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
]"""
|
||||||
|
|
||||||
|
|
||||||
|
class AIPromptBuilder:
|
||||||
|
"""从函数注册表内容构建AI提示词。
|
||||||
|
|
||||||
|
提示词设计目标:
|
||||||
|
- 清晰列出所有可用函数及其描述
|
||||||
|
- 显示每个函数由哪个设备提供
|
||||||
|
- 约束AI输出有效的JSON-RPC
|
||||||
|
- 支持多调用响应
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._system_prompt = SYSTEM_PROMPT_TEMPLATE
|
||||||
|
|
||||||
|
def set_system_prompt(self, prompt: str):
|
||||||
|
self._system_prompt = prompt
|
||||||
|
|
||||||
|
def build_system_prompt(self, functions: list[dict]) -> str:
|
||||||
|
"""用当前函数表构建完整的系统提示词。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
functions: 来自FunctionRegistry的函数信息字典列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
准备发送给AI的完整系统提示词字符串。
|
||||||
|
"""
|
||||||
|
if not functions:
|
||||||
|
return "当前没有已连接的嵌入式设备,无可用函数。"
|
||||||
|
|
||||||
|
table = self._format_functions_table(functions)
|
||||||
|
return self._system_prompt.format(
|
||||||
|
functions_table=table,
|
||||||
|
calls_syntax_example=CALLS_SYNTAX_EXAMPLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_functions_table(self, functions: list[dict]) -> str:
|
||||||
|
"""将函数列表格式化为可读的函数表。"""
|
||||||
|
entries = []
|
||||||
|
for func in functions:
|
||||||
|
params_str = self._format_params(func.get("params", []))
|
||||||
|
entry = FUNCTION_ENTRY_TEMPLATE.format(
|
||||||
|
name=func["name"],
|
||||||
|
type=func.get("type", "ctrl_noret"),
|
||||||
|
device_name=func.get("device_name", "unknown"),
|
||||||
|
description=func.get("description", "No description"),
|
||||||
|
params=params_str if params_str else " (none)",
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
return "\n".join(entries)
|
||||||
|
|
||||||
|
def _format_params(self, params: list[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
for p in params:
|
||||||
|
optional_str = ", optional" if p.get("optional") else ""
|
||||||
|
lines.append(PARAM_ENTRY_TEMPLATE.format(
|
||||||
|
name=p.get("name", "?"),
|
||||||
|
type=p.get("type", "unknown"),
|
||||||
|
optional=optional_str,
|
||||||
|
description=p.get("description", ""),
|
||||||
|
))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def parse_ai_response(self, response_text: str) -> Optional[list[dict]]:
|
||||||
|
"""从AI响应文本中提取JSON-RPC调用数组。
|
||||||
|
|
||||||
|
AI可能将JSON包裹在markdown代码块中,或直接输出原始JSON。
|
||||||
|
此方法处理两种情况。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_text: AI返回的原始文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RPC请求字典列表,解析失败时返回None
|
||||||
|
"""
|
||||||
|
text = response_text.strip()
|
||||||
|
|
||||||
|
# 尝试从markdown代码块中提取
|
||||||
|
if "```json" in text:
|
||||||
|
start = text.find("```json") + 7
|
||||||
|
end = text.find("```", start)
|
||||||
|
if end > start:
|
||||||
|
text = text[start:end].strip()
|
||||||
|
elif "```" in text:
|
||||||
|
start = text.find("```") + 3
|
||||||
|
end = text.find("```", start)
|
||||||
|
if end > start:
|
||||||
|
text = text[start:end].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [data]
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
return None
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
设备数据 DTO - 处理客户端发来的嵌入式设备数据
|
||||||
|
通过 WebSocket 的 device_data 类型消息与客户端通信
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Callable, Optional, Coroutine
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDataDTO:
|
||||||
|
"""设备数据分发器,处理客户端发来的设备注册/响应/事件"""
|
||||||
|
|
||||||
|
def __init__(self, json_dto, embedded_server):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
json_dto: JsonDTO 实例,用于注册接收器
|
||||||
|
embedded_server: YosugaServer 实例(嵌入式框架)
|
||||||
|
"""
|
||||||
|
json_dto.register_receiver("device_data", self._handle_device_data)
|
||||||
|
logger.info("[DeviceDataDTO] 设备数据接收业务已注册")
|
||||||
|
self.json_dto = json_dto
|
||||||
|
self.embedded_server = embedded_server
|
||||||
|
self._device_callbacks: list[Callable] = []
|
||||||
|
self.on_rpc_response: Optional[Callable[[str, dict], Coroutine]] = None
|
||||||
|
|
||||||
|
async def _handle_device_data(self, data: dict):
|
||||||
|
"""处理设备数据的入口
|
||||||
|
|
||||||
|
客户端发来的 device_data JSON 格式:
|
||||||
|
{
|
||||||
|
"action": "register" | "rpc_response" | "event",
|
||||||
|
"device_id": "...",
|
||||||
|
"payload": { ... } # 根据 action 不同而不同
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
action = data.get("action", "")
|
||||||
|
logger.info(f"[DeviceDataDTO] 收到设备数据, action={action}")
|
||||||
|
|
||||||
|
if action == "register":
|
||||||
|
device_id = data.get("device_id", "")
|
||||||
|
await self._handle_register(data.get("payload", {}), device_id)
|
||||||
|
elif action == "rpc_response":
|
||||||
|
await self._handle_rpc_response(
|
||||||
|
data.get("device_id", ""),
|
||||||
|
data.get("payload", {})
|
||||||
|
)
|
||||||
|
elif action == "event":
|
||||||
|
await self._handle_device_event(
|
||||||
|
data.get("device_id", ""),
|
||||||
|
data.get("payload", {})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[DeviceDataDTO] 未知的 action: {action}")
|
||||||
|
|
||||||
|
async def _handle_register(self, payload: dict, device_id: str = ""):
|
||||||
|
"""处理设备注册
|
||||||
|
|
||||||
|
payload 格式同 YosugaServer 的设备能力描述:
|
||||||
|
{
|
||||||
|
"device": { "name": "...", "description": "..." },
|
||||||
|
"functions": [ { "name": "...", "type": "...", ... } ]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
device = self.embedded_server.register_device_from_dict(payload, device_id)
|
||||||
|
logger.success(f"[DeviceDataDTO] 设备注册成功: {device.name} ({device.device_id})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DeviceDataDTO] 设备注册失败: {e}")
|
||||||
|
|
||||||
|
async def _handle_rpc_response(self, device_id: str, payload: dict):
|
||||||
|
"""处理设备返回的 RPC 响应"""
|
||||||
|
logger.info(f"[DeviceDataDTO] 收到设备 {device_id} 的 RPC 响应: {payload}")
|
||||||
|
|
||||||
|
if self.on_rpc_response:
|
||||||
|
try:
|
||||||
|
await self.on_rpc_response(device_id, payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DeviceDataDTO] on_rpc_response 回调错误: {e}")
|
||||||
|
|
||||||
|
for cb in self._device_callbacks:
|
||||||
|
try:
|
||||||
|
cb(device_id, payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DeviceDataDTO] 回调执行错误: {e}")
|
||||||
|
|
||||||
|
async def _handle_device_event(self, device_id: str, payload: dict):
|
||||||
|
"""处理设备主动上报的事件"""
|
||||||
|
logger.info(f"[DeviceDataDTO] 收到设备 {device_id} 的事件: {payload}")
|
||||||
|
|
||||||
|
def register_device_callback(self, callback: Callable) -> None:
|
||||||
|
"""注册设备消息回调"""
|
||||||
|
self._device_callbacks.append(callback)
|
||||||
|
|
||||||
|
async def send_device_command(self, device_id: str, rpc_call: str) -> None:
|
||||||
|
"""向客户端发送设备控制命令
|
||||||
|
|
||||||
|
发送给客户端的 JSON 格式:
|
||||||
|
{
|
||||||
|
"type": "device_command",
|
||||||
|
"data": {
|
||||||
|
"device_id": "...",
|
||||||
|
"payload": "{\\"jsonrpc\\": \\"2.0\\", ...}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"payload": rpc_call
|
||||||
|
}
|
||||||
|
await self.json_dto.send_json({
|
||||||
|
"type": "device_command",
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"data": payload
|
||||||
|
})
|
||||||
|
logger.info(f"[DeviceDataDTO] 已发送设备命令到 {device_id}")
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
设备管理器 - 管理多个嵌入式设备的连接。
|
||||||
|
|
||||||
|
每个设备由唯一ID标识(自动生成或客户端提供)。
|
||||||
|
管理器处理设备注册、重复检测,并维护设备ID与其功能表的映射。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceState(Enum):
|
||||||
|
"""设备连接的当前状态。"""
|
||||||
|
DISCONNECTED = "disconnected"
|
||||||
|
CONNECTED = "connected"
|
||||||
|
REGISTERED = "registered"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInfo:
|
||||||
|
"""嵌入式设备的信息。
|
||||||
|
|
||||||
|
存储设备的能力、身份和连接元数据。
|
||||||
|
除 device_id 外,所有字段均由设备自身提供。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
name: str = "",
|
||||||
|
description: str = "",
|
||||||
|
firmware_version: str = "",
|
||||||
|
hardware_version: str = "",
|
||||||
|
functions: Optional[list] = None,
|
||||||
|
):
|
||||||
|
self.device_id = device_id
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.firmware_version = firmware_version
|
||||||
|
self.hardware_version = hardware_version
|
||||||
|
self.functions = functions or []
|
||||||
|
self.state = DeviceState.DISCONNECTED
|
||||||
|
self.last_seen: float = 0.0
|
||||||
|
self.connected_at: float = 0.0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"device_id": self.device_id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"firmware_version": self.firmware_version,
|
||||||
|
"hardware_version": self.hardware_version,
|
||||||
|
"state": self.state.value,
|
||||||
|
"last_seen": self.last_seen,
|
||||||
|
"connected_at": self.connected_at,
|
||||||
|
"register_time": self.connected_at,
|
||||||
|
"functions": self.functions,
|
||||||
|
"function_count": len(self.functions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"DeviceInfo({self.device_id}, {self.name}, state={self.state.value})"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManager:
|
||||||
|
"""管理所有连接到服务端的嵌入式设备。
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 未提供时自动生成设备ID
|
||||||
|
- 可配置策略处理重复设备名
|
||||||
|
- 跟踪连接状态和最后活跃时间戳
|
||||||
|
- 提供设备状态变更的回调
|
||||||
|
"""
|
||||||
|
|
||||||
|
class ConflictStrategy(Enum):
|
||||||
|
"""处理重复设备名的策略。"""
|
||||||
|
REJECT = "reject"
|
||||||
|
RENAME = "rename"
|
||||||
|
REPLACE = "replace"
|
||||||
|
|
||||||
|
def __init__(self, conflict_strategy: str = "rename"):
|
||||||
|
self._devices: dict[str, "DeviceInfo"] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._next_id = 1
|
||||||
|
self._conflict_strategy = DeviceManager.ConflictStrategy(conflict_strategy)
|
||||||
|
self._on_device_change: Optional[Callable[[str, "DeviceInfo"], Any]] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_device_change(self) -> Optional[Callable[[str, "DeviceInfo"], Any]]:
|
||||||
|
"""设备添加、更新或移除时的回调。
|
||||||
|
签名: callback(event_type: str, device: DeviceInfo)
|
||||||
|
event_type: 'added', 'updated', 'removed'
|
||||||
|
"""
|
||||||
|
return self._on_device_change
|
||||||
|
|
||||||
|
@on_device_change.setter
|
||||||
|
def on_device_change(self, cb: Optional[Callable[[str, "DeviceInfo"], Any]]):
|
||||||
|
self._on_device_change = cb
|
||||||
|
|
||||||
|
def _notify(self, event: str, device: "DeviceInfo"):
|
||||||
|
if self._on_device_change:
|
||||||
|
try:
|
||||||
|
self._on_device_change(event, device)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _generate_id(self) -> str:
|
||||||
|
"""生成唯一的设备ID。"""
|
||||||
|
while True:
|
||||||
|
dev_id = f"device_{self._next_id}"
|
||||||
|
self._next_id += 1
|
||||||
|
if dev_id not in self._devices:
|
||||||
|
return dev_id
|
||||||
|
|
||||||
|
def register_from_json(self, json_data: dict) -> "DeviceInfo":
|
||||||
|
"""从设备的能力描述JSON注册或更新设备。
|
||||||
|
|
||||||
|
期望的JSON格式:
|
||||||
|
{
|
||||||
|
"_device_id": "...", # 可选的显式设备 ID(来自客户端转发)
|
||||||
|
"device": {
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"firmware_version": "...",
|
||||||
|
"hardware_version": "..."
|
||||||
|
},
|
||||||
|
"functions": [ ... ]
|
||||||
|
}
|
||||||
|
|
||||||
|
返回 DeviceInfo 对象。
|
||||||
|
策略为 REJECT 时,发生冲突会抛出 ValueError。
|
||||||
|
"""
|
||||||
|
device_data = json_data.get("device", {})
|
||||||
|
name = device_data.get("name", "")
|
||||||
|
description = device_data.get("description", "")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
explicit_id = json_data.get("_device_id", "")
|
||||||
|
device_id = explicit_id if explicit_id else self._resolve_identity(name)
|
||||||
|
device = DeviceInfo(
|
||||||
|
device_id=device_id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
firmware_version=device_data.get("firmware_version", ""),
|
||||||
|
hardware_version=device_data.get("hardware_version", ""),
|
||||||
|
functions=json_data.get("functions", []),
|
||||||
|
)
|
||||||
|
device.state = DeviceState.REGISTERED
|
||||||
|
now = time.time()
|
||||||
|
device.last_seen = now
|
||||||
|
device.connected_at = now
|
||||||
|
|
||||||
|
existing = self._find_by_name(name)
|
||||||
|
is_new = device_id not in self._devices
|
||||||
|
|
||||||
|
if existing and existing.device_id != device_id:
|
||||||
|
if self._conflict_strategy == DeviceManager.ConflictStrategy.REJECT:
|
||||||
|
raise ValueError(f"Device name conflict: '{name}' already registered")
|
||||||
|
elif existing and existing.device_id == device_id:
|
||||||
|
is_new = False
|
||||||
|
|
||||||
|
self._devices[device_id] = device
|
||||||
|
is_new_flag = is_new
|
||||||
|
device_ref = device
|
||||||
|
|
||||||
|
self._notify("added" if is_new_flag else "updated", device_ref)
|
||||||
|
return device_ref
|
||||||
|
|
||||||
|
def _resolve_identity(self, name: str) -> str:
|
||||||
|
"""将设备名解析为唯一的设备ID。"""
|
||||||
|
existing = self._find_by_name(name)
|
||||||
|
if existing:
|
||||||
|
if self._conflict_strategy == DeviceManager.ConflictStrategy.RENAME:
|
||||||
|
return self._generate_id()
|
||||||
|
elif self._conflict_strategy == DeviceManager.ConflictStrategy.REJECT:
|
||||||
|
raise ValueError(f"Device name conflict: '{name}' already registered")
|
||||||
|
elif self._conflict_strategy == DeviceManager.ConflictStrategy.REPLACE:
|
||||||
|
return existing.device_id
|
||||||
|
return self._generate_id()
|
||||||
|
|
||||||
|
def _find_by_name(self, name: str) -> Optional["DeviceInfo"]:
|
||||||
|
for dev in self._devices.values():
|
||||||
|
if dev.name == name:
|
||||||
|
return dev
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_device(self, device_id: str) -> Optional["DeviceInfo"]:
|
||||||
|
with self._lock:
|
||||||
|
return self._devices.get(device_id)
|
||||||
|
|
||||||
|
def get_device_by_name(self, name: str) -> Optional["DeviceInfo"]:
|
||||||
|
return self._find_by_name(name)
|
||||||
|
|
||||||
|
def remove_device(self, device_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
device = self._devices.pop(device_id, None)
|
||||||
|
if device:
|
||||||
|
device.state = DeviceState.DISCONNECTED
|
||||||
|
self._notify("removed", device)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_all_devices(self) -> list["DeviceInfo"]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._devices.values())
|
||||||
|
|
||||||
|
def device_count(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._devices)
|
||||||
|
|
||||||
|
def touch_device(self, device_id: str):
|
||||||
|
with self._lock:
|
||||||
|
device = self._devices.get(device_id)
|
||||||
|
if device:
|
||||||
|
device.last_seen = time.time()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"devices": [d.to_dict() for d in self.get_all_devices()],
|
||||||
|
"count": self.device_count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_device_for_function(self, function_name: str) -> Optional[str]:
|
||||||
|
"""Find which device provides a specific function name.
|
||||||
|
Returns device_id or None if not found.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
for dev_id, dev in self._devices.items():
|
||||||
|
for func in dev.functions:
|
||||||
|
if func.get("name") == function_name:
|
||||||
|
return dev_id
|
||||||
|
return None
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
函数注册表 - 维护所有设备的全局函数表。
|
||||||
|
|
||||||
|
这是所有已连接设备的所有函数的汇总视图。
|
||||||
|
注册表在运行时动态更新:随设备连接/断开而更新。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class FuncType(Enum):
|
||||||
|
CTRL_NORET = "ctrl_noret"
|
||||||
|
CTRL_RET = "ctrl_ret"
|
||||||
|
DATA_RET = "data_ret"
|
||||||
|
NORET_NODATA = "noret_nodata"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_str(cls, s: str) -> "FuncType":
|
||||||
|
for ft in cls:
|
||||||
|
if ft.value == s:
|
||||||
|
return ft
|
||||||
|
return cls.CTRL_NORET
|
||||||
|
|
||||||
|
|
||||||
|
class ParamInfo:
|
||||||
|
"""单个函数参数的描述符"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "", description: str = "",
|
||||||
|
param_type: str = "int", optional: bool = False):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.type = param_type
|
||||||
|
self.optional = optional
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> "ParamInfo":
|
||||||
|
return cls(
|
||||||
|
name=d.get("name", ""),
|
||||||
|
description=d.get("description", ""),
|
||||||
|
param_type=d.get("type", "int"),
|
||||||
|
optional=d.get("optional", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"type": self.type,
|
||||||
|
"optional": self.optional,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"ParamInfo({self.name}: {self.type})"
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionInfo:
|
||||||
|
"""整个系统中单个函数的完整描述符。
|
||||||
|
|
||||||
|
包含函数名、描述、参数信息、类型以及提供该函数的设备。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "",
|
||||||
|
description: str = "",
|
||||||
|
func_type: FuncType = FuncType.CTRL_NORET,
|
||||||
|
params: Optional[list] = None,
|
||||||
|
device_id: str = "",
|
||||||
|
device_name: str = "",
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.func_type = func_type
|
||||||
|
self.params = params or []
|
||||||
|
self.device_id = device_id
|
||||||
|
self.device_name = device_name
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_device_func(cls, func_dict: dict, device_id: str, device_name: str) -> "FunctionInfo":
|
||||||
|
raw_params = func_dict.get("params", [])
|
||||||
|
params = [ParamInfo.from_dict(p) if isinstance(p, dict) else ParamInfo()
|
||||||
|
for p in raw_params]
|
||||||
|
return cls(
|
||||||
|
name=func_dict.get("name", ""),
|
||||||
|
description=func_dict.get("description", ""),
|
||||||
|
func_type=FuncType.from_str(func_dict.get("type", "ctrl_noret")),
|
||||||
|
params=params,
|
||||||
|
device_id=device_id,
|
||||||
|
device_name=device_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"type": self.func_type.value,
|
||||||
|
"params": [p.to_dict() for p in self.params],
|
||||||
|
"device_id": self.device_id,
|
||||||
|
"device_name": self.device_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"FunctionInfo({self.name} @ {self.device_name})"
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionRegistry:
|
||||||
|
"""所有设备上所有函数的全局注册表。
|
||||||
|
|
||||||
|
维护:
|
||||||
|
- functions_by_name: dict[str, FunctionInfo] - 按名称快速查找
|
||||||
|
- functions_by_device: dict[str, list[FunctionInfo]] - 按设备查找
|
||||||
|
|
||||||
|
线程安全。变更时触发回调。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._functions_by_name: dict[str, FunctionInfo] = {}
|
||||||
|
self._functions_by_device: dict[str, list[FunctionInfo]] = {}
|
||||||
|
self._on_change: Optional[Callable] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_change(self) -> Optional[Callable]:
|
||||||
|
return self._on_change
|
||||||
|
|
||||||
|
@on_change.setter
|
||||||
|
def on_change(self, cb: Optional[Callable]):
|
||||||
|
self._on_change = cb
|
||||||
|
|
||||||
|
def _notify(self):
|
||||||
|
if self._on_change:
|
||||||
|
try:
|
||||||
|
self._on_change()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_device_functions(self, device_id: str, device_name: str,
|
||||||
|
func_list: list[dict]):
|
||||||
|
"""添加或更新某个设备的所有函数。"""
|
||||||
|
with self._lock:
|
||||||
|
self._remove_device_functions_locked(device_id)
|
||||||
|
func_infos = []
|
||||||
|
for func_dict in func_list:
|
||||||
|
fi = FunctionInfo.from_device_func(func_dict, device_id, device_name)
|
||||||
|
func_infos.append(fi)
|
||||||
|
self._functions_by_name[fi.name] = fi
|
||||||
|
self._functions_by_device[device_id] = func_infos
|
||||||
|
self._notify()
|
||||||
|
|
||||||
|
def _remove_device_functions_locked(self, device_id: str):
|
||||||
|
"""移除设备函数,不加锁(调用者必须持有锁)。"""
|
||||||
|
funcs = self._functions_by_device.pop(device_id, [])
|
||||||
|
for fi in funcs:
|
||||||
|
self._functions_by_name.pop(fi.name, None)
|
||||||
|
|
||||||
|
def remove_device_functions(self, device_id: str):
|
||||||
|
"""移除某个设备的所有函数。"""
|
||||||
|
with self._lock:
|
||||||
|
self._remove_device_functions_locked(device_id)
|
||||||
|
self._notify()
|
||||||
|
|
||||||
|
def get_function(self, name: str) -> Optional[FunctionInfo]:
|
||||||
|
with self._lock:
|
||||||
|
return self._functions_by_name.get(name)
|
||||||
|
|
||||||
|
def get_all_functions(self) -> list[FunctionInfo]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._functions_by_name.values())
|
||||||
|
|
||||||
|
def get_device_functions(self, device_id: str) -> list[FunctionInfo]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._functions_by_device.get(device_id, []))
|
||||||
|
|
||||||
|
def function_count(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._functions_by_name)
|
||||||
|
|
||||||
|
def to_function_list(self) -> list[dict]:
|
||||||
|
return [fi.to_dict() for fi in self.get_all_functions()]
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps(self.to_function_list(), indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
def find_device_for_function(self, func_name: str) -> Optional[str]:
|
||||||
|
fi = self.get_function(func_name)
|
||||||
|
return fi.device_id if fi else None
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
JSON-RPC 2.0 协议处理器。
|
||||||
|
|
||||||
|
处理AI、服务端和嵌入式设备之间JSON-RPC消息的
|
||||||
|
解析、验证、构建和路由
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class RPCError(Exception):
|
||||||
|
"""JSON-RPC错误,包含标准错误码"""
|
||||||
|
|
||||||
|
# 标准 JSON-RPC 错误码
|
||||||
|
PARSE_ERROR = -32700
|
||||||
|
INVALID_REQUEST = -32600
|
||||||
|
METHOD_NOT_FOUND = -32601
|
||||||
|
INVALID_PARAMS = -32602
|
||||||
|
INTERNAL_ERROR = -32603
|
||||||
|
|
||||||
|
# 自定义错误码
|
||||||
|
DEVICE_NOT_FOUND = -32000
|
||||||
|
DEVICE_ERROR = -32001
|
||||||
|
TIMEOUT = -32002
|
||||||
|
|
||||||
|
def __init__(self, code: int, message: str, data: Optional[dict] = None):
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.data = data
|
||||||
|
super().__init__(f"[{code}] {message}")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
err = {"code": self.code, "message": self.message}
|
||||||
|
if self.data:
|
||||||
|
err["data"] = self.data
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
class RPCRequest:
|
||||||
|
"""表示一个JSON-RPC 2.0请求"""
|
||||||
|
|
||||||
|
def __init__(self, method: str, params: Optional[dict] = None,
|
||||||
|
request_id: Optional[int] = None):
|
||||||
|
self.method = method
|
||||||
|
self.params = params or {}
|
||||||
|
self.id = request_id
|
||||||
|
|
||||||
|
def is_notification(self) -> bool:
|
||||||
|
return self.id is None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
req = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": self.method,
|
||||||
|
}
|
||||||
|
if self.params:
|
||||||
|
req["params"] = self.params
|
||||||
|
if self.id is not None:
|
||||||
|
req["id"] = self.id
|
||||||
|
return req
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps(self.to_dict(), ensure_ascii=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> "RPCRequest":
|
||||||
|
return cls(
|
||||||
|
method=d["method"],
|
||||||
|
params=d.get("params"),
|
||||||
|
request_id=d.get("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"RPCRequest(method={self.method}, id={self.id})"
|
||||||
|
|
||||||
|
|
||||||
|
class RPCResponse:
|
||||||
|
"""表示一个JSON-RPC 2.0响应"""
|
||||||
|
|
||||||
|
def __init__(self, result: Optional[dict] = None,
|
||||||
|
error: Optional[RPCError] = None,
|
||||||
|
request_id: Optional[int] = None):
|
||||||
|
self.result = result
|
||||||
|
self.error = error
|
||||||
|
self.id = request_id
|
||||||
|
|
||||||
|
def is_success(self) -> bool:
|
||||||
|
return self.error is None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
resp = {"jsonrpc": "2.0"}
|
||||||
|
if self.id is not None:
|
||||||
|
resp["id"] = self.id
|
||||||
|
if self.error:
|
||||||
|
resp["error"] = self.error.to_dict()
|
||||||
|
else:
|
||||||
|
resp["result"] = self.result
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps(self.to_dict(), ensure_ascii=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success(cls, result: Optional[dict], request_id: Optional[int]) -> "RPCResponse":
|
||||||
|
return cls(result=result, request_id=request_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def error(cls, code: int, message: str, request_id: Optional[int] = None) -> "RPCResponse":
|
||||||
|
return cls(error=RPCError(code, message), request_id=request_id)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
if self.error:
|
||||||
|
return f"RPCResponse(error={self.error.message}, id={self.id})"
|
||||||
|
return f"RPCResponse(result={self.result}, id={self.id})"
|
||||||
|
|
||||||
|
|
||||||
|
class JSONRPCHandler:
|
||||||
|
"""处理JSON-RPC协议解析和响应构建"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_request(json_str: str) -> Optional[RPCRequest]:
|
||||||
|
"""将JSON字符串解析为RPCRequest"""
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
if data.get("jsonrpc") != "2.0":
|
||||||
|
return None
|
||||||
|
if "method" not in data or not isinstance(data["method"], str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return RPCRequest.from_dict(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_request_batch(json_str: str) -> Optional[list[RPCRequest]]:
|
||||||
|
"""将JSON字符串解析为RPCRequest列表(用于批量调用)"""
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
req = JSONRPCHandler.parse_request(json_str)
|
||||||
|
return [req] if req else None
|
||||||
|
|
||||||
|
if isinstance(data, list):
|
||||||
|
results = []
|
||||||
|
for item in data:
|
||||||
|
item_str = json.dumps(item)
|
||||||
|
req = JSONRPCHandler.parse_request(item_str)
|
||||||
|
if req:
|
||||||
|
results.append(req)
|
||||||
|
return results if results else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_request(request: dict) -> Optional[RPCError]:
|
||||||
|
"""验证原始请求字典。无效时返回RPCError"""
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
return RPCError(RPCError.INVALID_REQUEST, "request must be a JSON object")
|
||||||
|
if request.get("jsonrpc") != "2.0":
|
||||||
|
return RPCError(RPCError.INVALID_REQUEST, "jsonrpc must be '2.0'")
|
||||||
|
if "method" not in request:
|
||||||
|
return RPCError(RPCError.INVALID_REQUEST, "missing method")
|
||||||
|
if not isinstance(request["method"], str) or not request["method"]:
|
||||||
|
return RPCError(RPCError.INVALID_REQUEST, "method must be a non-empty string")
|
||||||
|
params = request.get("params")
|
||||||
|
if params is not None and not isinstance(params, dict):
|
||||||
|
return RPCError(RPCError.INVALID_PARAMS, "params must be a JSON object")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_error_response(code: int, message: str,
|
||||||
|
request_id: Optional[int] = None) -> str:
|
||||||
|
"""构建JSON-RPC错误响应字符串"""
|
||||||
|
resp = RPCResponse.error(code, message, request_id)
|
||||||
|
return resp.to_json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_success_response(result: Optional[dict],
|
||||||
|
request_id: Optional[int] = None) -> str:
|
||||||
|
"""构建JSON-RPC成功响应字符串"""
|
||||||
|
resp = RPCResponse.success(result, request_id)
|
||||||
|
return resp.to_json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_call(method: str, params: Optional[dict] = None,
|
||||||
|
call_id: Optional[int] = None) -> str:
|
||||||
|
"""构建JSON-RPC调用字符串(服务端 -> 设备)"""
|
||||||
|
req = RPCRequest(method, params, call_id)
|
||||||
|
return req.to_json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_response(json_str: str) -> bool:
|
||||||
|
"""检查JSON字符串是否为JSON-RPC响应"""
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return "result" in data or "error" in data
|
||||||
|
return False
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_response(json_str: str) -> Optional[RPCResponse]:
|
||||||
|
"""将JSON字符串解析为RPCResponse"""
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
if data.get("jsonrpc") != "2.0":
|
||||||
|
return None
|
||||||
|
|
||||||
|
request_id = data.get("id")
|
||||||
|
if "error" in data:
|
||||||
|
err_data = data["error"]
|
||||||
|
return RPCResponse.error(
|
||||||
|
err_data.get("code", RPCError.INTERNAL_ERROR),
|
||||||
|
err_data.get("message", "unknown error"),
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
elif "result" in data:
|
||||||
|
return RPCResponse.success(data["result"], request_id)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
YosugaServer - 主服务端类,串联所有组件。
|
||||||
|
|
||||||
|
架构:
|
||||||
|
AI <-> YosugaServer <-> DeviceManager <-> 嵌入式设备
|
||||||
|
FunctionRegistry
|
||||||
|
AIPromptBuilder
|
||||||
|
JSONRPCHandler
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 设备连接,发送能力描述JSON
|
||||||
|
2. 服务端注册设备+函数
|
||||||
|
3. 变更回调触发 -> 服务端更新AI提示词
|
||||||
|
4. 用户通过服务端向AI发送请求
|
||||||
|
5. 服务端用当前函数构建系统提示词
|
||||||
|
6. AI响应函数调用
|
||||||
|
7. 服务端解析调用并路由到设备
|
||||||
|
8. 设备响应被收集并返回
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
|
||||||
|
from .device_manager import DeviceManager, DeviceInfo
|
||||||
|
from .function_registry import FunctionRegistry
|
||||||
|
from .ai_prompt import AIPromptBuilder
|
||||||
|
from .json_rpc import JSONRPCHandler, RPCRequest, RPCResponse, RPCError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfig:
|
||||||
|
"""服务端配置。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_conflict_strategy: str = "rename",
|
||||||
|
max_concurrent_calls: int = 10,
|
||||||
|
device_timeout: float = 30.0,
|
||||||
|
):
|
||||||
|
self.device_conflict_strategy = device_conflict_strategy
|
||||||
|
self.max_concurrent_calls = max_concurrent_calls
|
||||||
|
self.device_timeout = device_timeout
|
||||||
|
|
||||||
|
|
||||||
|
class YosugaServer:
|
||||||
|
"""主服务端,协调设备、函数和AI交互。"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[ServerConfig] = None):
|
||||||
|
self.config = config or ServerConfig()
|
||||||
|
self.device_manager = DeviceManager(
|
||||||
|
conflict_strategy=self.config.device_conflict_strategy
|
||||||
|
)
|
||||||
|
self.function_registry = FunctionRegistry()
|
||||||
|
self.ai_prompt = AIPromptBuilder()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._call_id_counter = 0
|
||||||
|
|
||||||
|
# 挂载变更通知
|
||||||
|
self.device_manager.on_device_change = self._on_device_change
|
||||||
|
self.function_registry.on_change = self._on_functions_change
|
||||||
|
|
||||||
|
# 外部集成回调
|
||||||
|
self.on_capabilities_changed: Optional[Callable[[dict], Any]] = None
|
||||||
|
self.on_device_message: Optional[Callable[[str, str], Optional[str]]] = None
|
||||||
|
|
||||||
|
def _on_device_change(self, event: str, device: DeviceInfo):
|
||||||
|
logger.info("Device %s: %s", event, device.device_id)
|
||||||
|
if event == "removed":
|
||||||
|
self.function_registry.remove_device_functions(device.device_id)
|
||||||
|
elif event in ("added", "updated"):
|
||||||
|
if device.state.value == "registered":
|
||||||
|
self.function_registry.add_device_functions(
|
||||||
|
device.device_id,
|
||||||
|
device.name,
|
||||||
|
device.functions or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_functions_change(self):
|
||||||
|
if self.on_capabilities_changed:
|
||||||
|
try:
|
||||||
|
self.on_capabilities_changed(self.get_capabilities_summary())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("capabilities callback error: %s", e)
|
||||||
|
|
||||||
|
def _next_call_id(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._call_id_counter += 1
|
||||||
|
return self._call_id_counter
|
||||||
|
|
||||||
|
def register_device(self, device_json_str: str) -> DeviceInfo:
|
||||||
|
"""从设备的JSON能力描述注册设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_json_str: 设备发来的JSON字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
已注册设备的 DeviceInfo
|
||||||
|
"""
|
||||||
|
data = json.loads(device_json_str)
|
||||||
|
return self.device_manager.register_from_json(data)
|
||||||
|
|
||||||
|
def register_device_from_dict(self, device_json: dict, device_id: str = "") -> DeviceInfo:
|
||||||
|
"""从字典注册设备(格式同JSON)。
|
||||||
|
Args:
|
||||||
|
device_json: 设备能力描述字典
|
||||||
|
device_id: 可选的显式设备 ID(来自客户端转发,覆盖自动生成)
|
||||||
|
"""
|
||||||
|
if device_id:
|
||||||
|
device_json = dict(device_json)
|
||||||
|
device_json["_device_id"] = device_id
|
||||||
|
return self.device_manager.register_from_json(device_json)
|
||||||
|
|
||||||
|
def remove_device(self, device_id: str) -> bool:
|
||||||
|
"""移除设备及其函数。"""
|
||||||
|
return self.device_manager.remove_device(device_id)
|
||||||
|
|
||||||
|
def build_ai_system_prompt(self) -> str:
|
||||||
|
"""构建当前AI系统提示词。"""
|
||||||
|
functions = self.function_registry.to_function_list()
|
||||||
|
return self.ai_prompt.build_system_prompt(functions)
|
||||||
|
|
||||||
|
def process_ai_response(self, response_text: str) -> list[dict]:
|
||||||
|
"""解析AI响应为RPC调用并路由到设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_text: AI返回的原始文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含设备响应结果的字典列表
|
||||||
|
"""
|
||||||
|
calls = self.ai_prompt.parse_ai_response(response_text)
|
||||||
|
if not calls:
|
||||||
|
return [{"error": "无法将AI响应解析为RPC调用"}]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for call in calls:
|
||||||
|
method = call.get("method")
|
||||||
|
params = call.get("params", {})
|
||||||
|
call_id = call.get("id", self._next_call_id())
|
||||||
|
|
||||||
|
# 查找哪个设备提供此函数
|
||||||
|
func_info = self.function_registry.get_function(method)
|
||||||
|
if not func_info:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"error": {"code": RPCError.METHOD_NOT_FOUND,
|
||||||
|
"message": f"未找到函数 '{method}'"},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_id = func_info.device_id
|
||||||
|
device = self.device_manager.get_device(device_id)
|
||||||
|
if not device:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"error": {"code": RPCError.DEVICE_NOT_FOUND,
|
||||||
|
"message": f"设备 '{device_id}' 不可用"},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 构建发送给设备的RPC调用
|
||||||
|
rpc_call = JSONRPCHandler.build_call(method, params, call_id)
|
||||||
|
|
||||||
|
# 如果有设备消息回调,使用它
|
||||||
|
if self.on_device_message:
|
||||||
|
try:
|
||||||
|
response_str = self.on_device_message(device_id, rpc_call)
|
||||||
|
if response_str:
|
||||||
|
resp = JSONRPCHandler.parse_response(response_str)
|
||||||
|
if resp:
|
||||||
|
if resp.is_success():
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"device_id": device_id,
|
||||||
|
"result": resp.result,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"device_id": device_id,
|
||||||
|
"error": resp.error.to_dict(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"error": {"code": RPCError.PARSE_ERROR,
|
||||||
|
"message": "Invalid response from device"},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"device_id": device_id,
|
||||||
|
"result": None,
|
||||||
|
"note": "notification (no response expected)",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"error": {"code": RPCError.DEVICE_ERROR,
|
||||||
|
"message": str(e)},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"id": call_id,
|
||||||
|
"method": method,
|
||||||
|
"device_id": device_id,
|
||||||
|
"note": "No on_device_message callback set - call would be routed here",
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def list_devices(self) -> list[dict]:
|
||||||
|
"""获取所有在线设备的字典列表"""
|
||||||
|
return [d.to_dict() for d in self.device_manager.get_all_devices()]
|
||||||
|
|
||||||
|
def send_rpc(self, device_id: str, rpc_call: str) -> Optional[str]:
|
||||||
|
"""向指定设备发送 RPC 调用并返回响应"""
|
||||||
|
if self.on_device_message:
|
||||||
|
return self.on_device_message(device_id, rpc_call)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_capabilities_summary(self) -> dict:
|
||||||
|
"""获取所有能力的摘要(供外部使用)。"""
|
||||||
|
return {
|
||||||
|
"device_count": self.device_manager.device_count(),
|
||||||
|
"function_count": self.function_registry.function_count(),
|
||||||
|
"devices": self.device_manager.to_dict(),
|
||||||
|
"functions": self.function_registry.to_function_list(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def process_device_message(self, device_id: str, message: str) -> str:
|
||||||
|
"""处理设备发来的消息。
|
||||||
|
|
||||||
|
设备发送:
|
||||||
|
- 能力描述(注册)
|
||||||
|
- RPC响应
|
||||||
|
|
||||||
|
如有需要返回响应字符串。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JSONRPCHandler.build_error_response(
|
||||||
|
RPCError.PARSE_ERROR, "Invalid JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否为能力描述(包含"device"和"functions")
|
||||||
|
if isinstance(data, dict) and "device" in data and "functions" in data:
|
||||||
|
device = self.register_device_from_dict(data)
|
||||||
|
return json.dumps({"status": "registered", "device_id": device.device_id})
|
||||||
|
|
||||||
|
# 检查是否为RPC响应
|
||||||
|
if isinstance(data, dict) and ("result" in data or "error" in data):
|
||||||
|
# 仅确认 - 响应由回调处理
|
||||||
|
return json.dumps({"status": "received"})
|
||||||
|
|
||||||
|
# 可能是转发调用或其他消息
|
||||||
|
return JSONRPCHandler.build_error_response(
|
||||||
|
RPCError.INVALID_REQUEST, "Unknown message type"
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# from ide
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
|
||||||
|
user_preferences.json
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
"""
|
||||||
|
Yosuga Server Web UI - FastAPI Backend with Socket.IO
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import psutil
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Set
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
import socketio
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from src.server_view.backend.core_manager import (
|
||||||
|
start_core, stop_core, get_status, get_core_status
|
||||||
|
)
|
||||||
|
from src.server_view.backend.diagnostics import get_diagnostics, HealthStatus, CheckResult
|
||||||
|
|
||||||
|
# Socket.IO 服务器
|
||||||
|
sio = socketio.AsyncServer(
|
||||||
|
cors_allowed_origins="*",
|
||||||
|
async_mode="asgi",
|
||||||
|
logger=False,
|
||||||
|
engineio_logger=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 跟踪已连接的客户端
|
||||||
|
connected_clients: Set[str] = set()
|
||||||
|
|
||||||
|
# RPC 响应转发回调标志(只注册一次)
|
||||||
|
_rpc_forwarder_registered: bool = False
|
||||||
|
|
||||||
|
# 日志广播处理器
|
||||||
|
class SocketIOLogHandler(logging.Handler):
|
||||||
|
"""将Python标准日志发送到Socket.IO"""
|
||||||
|
def __init__(self, sio_server):
|
||||||
|
super().__init__()
|
||||||
|
self.sio = sio_server
|
||||||
|
self.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord):
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(self._broadcast(msg, record.levelname))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _broadcast(self, message: str, level: str):
|
||||||
|
try:
|
||||||
|
await self.sio.emit('log_line', {
|
||||||
|
'line': message,
|
||||||
|
'timestamp': time.time(),
|
||||||
|
'level': level
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setup_logging(sio_server):
|
||||||
|
"""配置日志系统 - 减少HTTP访问日志噪音"""
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# 清除现有处理器
|
||||||
|
for handler in root_logger.handlers[:]:
|
||||||
|
root_logger.removeHandler(handler)
|
||||||
|
|
||||||
|
# 控制台处理器 - 过滤掉频繁的系统信息HTTP请求日志
|
||||||
|
console = logging.StreamHandler(sys.stdout)
|
||||||
|
console.setLevel(logging.INFO)
|
||||||
|
console.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d - %(message)s'
|
||||||
|
))
|
||||||
|
|
||||||
|
# 添加过滤器,排除频繁的HTTP轮询日志
|
||||||
|
def filter_http_poll(record):
|
||||||
|
msg = record.getMessage()
|
||||||
|
# 过滤掉 /api/system/info 和 /api/core/status 的GET请求日志
|
||||||
|
if 'GET /api/system/info' in msg or 'GET /api/core/status' in msg:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
console.addFilter(filter_http_poll)
|
||||||
|
root_logger.addHandler(console)
|
||||||
|
|
||||||
|
# Socket.IO处理器
|
||||||
|
sio_handler = SocketIOLogHandler(sio_server)
|
||||||
|
sio_handler.setLevel(logging.DEBUG)
|
||||||
|
sio_handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s | %(levelname)-8s | %(name)s - %(message)s'
|
||||||
|
))
|
||||||
|
root_logger.addHandler(sio_handler)
|
||||||
|
|
||||||
|
# 设置yosuga logger
|
||||||
|
yosuga_logger = logging.getLogger("yosuga")
|
||||||
|
yosuga_logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
return root_logger
|
||||||
|
|
||||||
|
# 系统状态监控
|
||||||
|
async def system_monitor_task():
|
||||||
|
"""后台任务:定期采集并广播系统状态"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 只有有客户端连接时才采集数据
|
||||||
|
if connected_clients:
|
||||||
|
# 采集系统数据
|
||||||
|
cpu = psutil.cpu_percent(interval=0.1)
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
disk = psutil.disk_usage('/')
|
||||||
|
proc = psutil.Process()
|
||||||
|
|
||||||
|
system_data = {
|
||||||
|
"cpu": {"percent": cpu, "count": psutil.cpu_count()},
|
||||||
|
"memory": {
|
||||||
|
"total": mem.total, "available": mem.available,
|
||||||
|
"percent": mem.percent, "used": mem.used, "free": mem.free
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"total": disk.total, "used": disk.used,
|
||||||
|
"free": disk.free, "percent": (disk.used/disk.total)*100
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"memory_percent": proc.memory_percent(),
|
||||||
|
"cpu_percent": proc.cpu_percent(interval=0.1),
|
||||||
|
"threads": proc.num_threads(),
|
||||||
|
"uptime": time.time() - proc.create_time()
|
||||||
|
},
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 广播给所有客户端
|
||||||
|
await sio.emit('system_stats', {
|
||||||
|
'success': True,
|
||||||
|
'data': system_data
|
||||||
|
})
|
||||||
|
|
||||||
|
# 同时广播核心状态(实时推送)
|
||||||
|
core_status = get_core_status()
|
||||||
|
await sio.emit('core_status', {
|
||||||
|
'success': True,
|
||||||
|
'data': core_status
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"系统监控任务异常: {e}")
|
||||||
|
|
||||||
|
# 1秒间隔,比HTTP轮询更实时
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# FastAPI 应用
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期"""
|
||||||
|
logger = setup_logging(sio)
|
||||||
|
logger.info("Yosuga Server Web UI 启动")
|
||||||
|
|
||||||
|
monitor_task = asyncio.create_task(system_monitor_task())
|
||||||
|
logger.info("系统监控任务已启动")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 清理诊断模块
|
||||||
|
try:
|
||||||
|
diag = await get_diagnostics()
|
||||||
|
# 诊断模块无需要关闭的资源,但保留钩子
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("应用关闭")
|
||||||
|
stop_core()
|
||||||
|
fastapi_app = FastAPI(
|
||||||
|
title="Yosuga Server Web UI",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
fastapi_app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fastapi_app.post("/api/diagnostics/run")
|
||||||
|
async def run_diagnostics():
|
||||||
|
"""执行完整系统体检"""
|
||||||
|
try:
|
||||||
|
diag = await get_diagnostics()
|
||||||
|
report = await diag.run_full_diagnostics()
|
||||||
|
return report.to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@fastapi_app.get("/api/diagnostics/check/{module}")
|
||||||
|
async def check_single_module(module: str):
|
||||||
|
"""检查单个模块状态"""
|
||||||
|
try:
|
||||||
|
from src.config.config import cfg
|
||||||
|
diag = await get_diagnostics()
|
||||||
|
|
||||||
|
# 获取对应配置
|
||||||
|
config_map = {
|
||||||
|
'asr': cfg.asr,
|
||||||
|
'tts': cfg.tts,
|
||||||
|
'ai': cfg.ai,
|
||||||
|
'auto_agent': cfg.auto_agent,
|
||||||
|
'llm_core': cfg.llm_core
|
||||||
|
}
|
||||||
|
|
||||||
|
if module not in config_map:
|
||||||
|
raise HTTPException(status_code=400, detail=f"未知模块: {module}")
|
||||||
|
|
||||||
|
# 转换为dict
|
||||||
|
config_dict = {}
|
||||||
|
if hasattr(config_map[module], '__dataclass_fields__'):
|
||||||
|
from dataclasses import asdict
|
||||||
|
config_dict = asdict(config_map[module])
|
||||||
|
else:
|
||||||
|
config_dict = dict(config_map[module])
|
||||||
|
|
||||||
|
result = await diag.quick_check_module(module, config_dict)
|
||||||
|
return {"success": True, "data": result.to_dict()}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@fastapi_app.get("/api/diagnostics/health")
|
||||||
|
async def quick_health_check():
|
||||||
|
"""快速健康检查(用于负载均衡/心跳)"""
|
||||||
|
try:
|
||||||
|
from src.server_view.backend.core_manager import get_status
|
||||||
|
status = get_status()
|
||||||
|
|
||||||
|
# 简单检查WebSocket服务器是否活着
|
||||||
|
ws_ok = len(connected_clients) >= 0 # 总是True,只要能响应
|
||||||
|
|
||||||
|
health = {
|
||||||
|
"status": "healthy" if status.is_running else "degraded",
|
||||||
|
"web_ui": "up",
|
||||||
|
"core_running": status.is_running,
|
||||||
|
"websocket_clients": len(connected_clients),
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
return health
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "unhealthy", "error": str(e)}
|
||||||
|
|
||||||
|
# Socket.IO ASGI应用
|
||||||
|
app = socketio.ASGIApp(sio, fastapi_app)
|
||||||
|
|
||||||
|
# Socket.IO 事件
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ):
|
||||||
|
"""客户端连接"""
|
||||||
|
connected_clients.add(sid)
|
||||||
|
print(f"客户端连接: {sid} (当前在线: {len(connected_clients)})")
|
||||||
|
|
||||||
|
# 立即发送一次当前状态(避免前端等待)
|
||||||
|
await sio.emit('system', {
|
||||||
|
'message': '连接成功',
|
||||||
|
'timestamp': time.time(),
|
||||||
|
'clients_count': len(connected_clients)
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
# 立即推送一次系统状态(前端无需再发HTTP请求)
|
||||||
|
try:
|
||||||
|
core_status = get_core_status()
|
||||||
|
await sio.emit('core_status', {
|
||||||
|
'success': True,
|
||||||
|
'data': core_status
|
||||||
|
}, to=sid)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"推送初始状态失败: {e}")
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def disconnect(sid):
|
||||||
|
"""客户端断开"""
|
||||||
|
connected_clients.discard(sid)
|
||||||
|
print(f"客户端断开: {sid} (当前在线: {len(connected_clients)})")
|
||||||
|
|
||||||
|
@sio.on('subscribe_logs')
|
||||||
|
async def handle_subscribe_logs(sid, data):
|
||||||
|
"""订阅日志(可指定级别过滤)"""
|
||||||
|
level = data.get('level', 'ALL') if isinstance(data, dict) else 'ALL'
|
||||||
|
print(f"客户端 {sid} 订阅日志: {level}")
|
||||||
|
await sio.emit('system', {'message': f'已订阅日志: {level}'}, to=sid)
|
||||||
|
|
||||||
|
@sio.on('control_core')
|
||||||
|
async def handle_control_core(sid, data):
|
||||||
|
"""WebSocket方式控制核心"""
|
||||||
|
action = data.get('action') if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
if action == 'start':
|
||||||
|
try:
|
||||||
|
# 检查是否已运行
|
||||||
|
status = get_status()
|
||||||
|
if status.is_running:
|
||||||
|
await sio.emit('core_control_result', {
|
||||||
|
'success': True,
|
||||||
|
'message': '核心已在运行',
|
||||||
|
'data': status.to_dict()
|
||||||
|
}, to=sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 启动核心
|
||||||
|
success, error = start_core(project_root)
|
||||||
|
if success:
|
||||||
|
# 等待一下确保启动成功
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
new_status = get_status()
|
||||||
|
# 广播给所有客户端(不仅仅是操作者)
|
||||||
|
await sio.emit('core_status', {
|
||||||
|
'success': True,
|
||||||
|
'data': new_status.to_dict(),
|
||||||
|
'message': '核心启动成功'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await sio.emit('core_control_result', {
|
||||||
|
'success': False,
|
||||||
|
'error': error or '启动失败'
|
||||||
|
}, to=sid)
|
||||||
|
except Exception as e:
|
||||||
|
await sio.emit('core_control_result', {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
elif action == 'stop':
|
||||||
|
try:
|
||||||
|
status = get_status()
|
||||||
|
if not status.is_running:
|
||||||
|
await sio.emit('core_control_result', {
|
||||||
|
'success': True,
|
||||||
|
'message': '核心已停止',
|
||||||
|
'data': status.to_dict()
|
||||||
|
}, to=sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
success, error = stop_core()
|
||||||
|
if success:
|
||||||
|
await asyncio.sleep(0.5) # 等待停止完成
|
||||||
|
new_status = get_status()
|
||||||
|
await sio.emit('core_status', {
|
||||||
|
'success': True,
|
||||||
|
'data': new_status.to_dict(),
|
||||||
|
'message': '核心已停止'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await sio.emit('core_control_result', {
|
||||||
|
'success': False,
|
||||||
|
'error': error or '停止失败'
|
||||||
|
}, to=sid)
|
||||||
|
except Exception as e:
|
||||||
|
await sio.emit('core_control_result', {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
|
||||||
|
# 设备管理 Socket.IO 事件
|
||||||
|
@sio.on('get_devices')
|
||||||
|
async def handle_get_devices(sid):
|
||||||
|
"""获取当前所有在线设备列表"""
|
||||||
|
try:
|
||||||
|
from src.server_core.core import YosugaServerCore
|
||||||
|
core = await YosugaServerCore.get_instance()
|
||||||
|
devices = core.embedded_server.list_devices()
|
||||||
|
await sio.emit('devices_list', {
|
||||||
|
'success': True,
|
||||||
|
'data': devices
|
||||||
|
}, to=sid)
|
||||||
|
except Exception as e:
|
||||||
|
await sio.emit('devices_list', {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
@sio.on('send_device_rpc')
|
||||||
|
async def handle_send_device_rpc(sid, data):
|
||||||
|
"""向指定设备发送 RPC 命令"""
|
||||||
|
device_id = data.get('device_id') if isinstance(data, dict) else None
|
||||||
|
rpc_call = data.get('rpc_call') if isinstance(data, dict) else None
|
||||||
|
if not device_id or not rpc_call:
|
||||||
|
await sio.emit('device_rpc_result', {
|
||||||
|
'success': False,
|
||||||
|
'error': '缺少 device_id 或 rpc_call'
|
||||||
|
}, to=sid)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from src.server_core.core import YosugaServerCore
|
||||||
|
core = await YosugaServerCore.get_instance()
|
||||||
|
|
||||||
|
# 注册一次性的 RPC 响应转发回调
|
||||||
|
global _rpc_forwarder_registered
|
||||||
|
if not _rpc_forwarder_registered:
|
||||||
|
async def forward_rpc_response(dev_id: str, payload: dict):
|
||||||
|
await sio.emit('device_rpc_response', {
|
||||||
|
'device_id': dev_id,
|
||||||
|
'payload': payload
|
||||||
|
})
|
||||||
|
core.device_dto.on_rpc_response = forward_rpc_response
|
||||||
|
_rpc_forwarder_registered = True
|
||||||
|
|
||||||
|
core.embedded_server.send_rpc(device_id, rpc_call)
|
||||||
|
await sio.emit('device_rpc_result', {
|
||||||
|
'success': True,
|
||||||
|
'device_id': device_id,
|
||||||
|
'message': 'RPC 命令已发送到设备'
|
||||||
|
}, to=sid)
|
||||||
|
except Exception as e:
|
||||||
|
await sio.emit('device_rpc_result', {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
# 设备管理 REST API
|
||||||
|
@fastapi_app.get("/api/devices")
|
||||||
|
async def get_devices_api():
|
||||||
|
"""获取所有在线设备(HTTP 备用)"""
|
||||||
|
try:
|
||||||
|
from src.server_core.core import YosugaServerCore
|
||||||
|
core = await YosugaServerCore.get_instance()
|
||||||
|
devices = core.embedded_server.list_devices()
|
||||||
|
return {"success": True, "data": devices}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@sio.on('check_module_health')
|
||||||
|
async def handle_check_module(sid, data):
|
||||||
|
"""WebSocket方式检查模块健康"""
|
||||||
|
module = data.get('module') if isinstance(data, dict) else None
|
||||||
|
if not module:
|
||||||
|
await sio.emit('module_health_result', {
|
||||||
|
"success": False,
|
||||||
|
"error": "未指定模块"
|
||||||
|
}, to=sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.config.config import cfg
|
||||||
|
diag = await get_diagnostics()
|
||||||
|
|
||||||
|
config_map = {
|
||||||
|
'asr': cfg.asr,
|
||||||
|
'tts': cfg.tts,
|
||||||
|
'ai': cfg.ai,
|
||||||
|
'auto_agent': cfg.auto_agent,
|
||||||
|
'llm_core': cfg.llm_core
|
||||||
|
}
|
||||||
|
|
||||||
|
if module not in config_map:
|
||||||
|
await sio.emit('module_health_result', {
|
||||||
|
"success": False,
|
||||||
|
"error": f"未知模块: {module}"
|
||||||
|
}, to=sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 转换配置
|
||||||
|
config_dict = {}
|
||||||
|
if hasattr(config_map[module], '__dataclass_fields__'):
|
||||||
|
from dataclasses import asdict
|
||||||
|
config_dict = asdict(config_map[module])
|
||||||
|
else:
|
||||||
|
config_dict = dict(config_map[module])
|
||||||
|
|
||||||
|
result = await diag.quick_check_module(module, config_dict)
|
||||||
|
|
||||||
|
await sio.emit('module_health_result', {
|
||||||
|
"success": True,
|
||||||
|
"module": module,
|
||||||
|
"data": result.to_dict()
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
# 同时广播给所有客户端更新模块状态
|
||||||
|
await sio.emit('module_status_update', {
|
||||||
|
"module": module,
|
||||||
|
"status": result.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await sio.emit('module_health_result', {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, to=sid)
|
||||||
|
|
||||||
|
# REST API
|
||||||
|
@fastapi_app.get("/api/system/info")
|
||||||
|
async def get_system_info():
|
||||||
|
"""HTTP备用接口 - 减少日志"""
|
||||||
|
try:
|
||||||
|
cpu = psutil.cpu_percent(interval=0.1)
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
disk = psutil.disk_usage('/')
|
||||||
|
proc = psutil.Process()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"cpu": {"percent": cpu, "count": psutil.cpu_count()},
|
||||||
|
"memory": {
|
||||||
|
"total": mem.total, "available": mem.available,
|
||||||
|
"percent": mem.percent, "used": mem.used, "free": mem.free
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"total": disk.total, "used": disk.used,
|
||||||
|
"free": disk.free, "percent": (disk.used/disk.total)*100
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"memory_percent": proc.memory_percent(),
|
||||||
|
"cpu_percent": proc.cpu_percent(interval=0.1),
|
||||||
|
"threads": proc.num_threads(),
|
||||||
|
"uptime": time.time() - proc.create_time()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@fastapi_app.get("/api/core/status")
|
||||||
|
async def get_core_status_api():
|
||||||
|
"""HTTP备用接口"""
|
||||||
|
return {"success": True, "data": get_core_status()}
|
||||||
|
|
||||||
|
@fastapi_app.post("/api/core/start")
|
||||||
|
async def start_core_api():
|
||||||
|
"""HTTP备用接口"""
|
||||||
|
try:
|
||||||
|
status = get_status()
|
||||||
|
if status.is_running:
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": "核心已在运行"}
|
||||||
|
|
||||||
|
success, error = start_core(project_root)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail=error or "启动失败")
|
||||||
|
|
||||||
|
# 等待初始化完成
|
||||||
|
for _ in range(20):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
status = get_status()
|
||||||
|
if status.is_running:
|
||||||
|
# 通过WebSocket广播状态更新(给所有连接的客户端)
|
||||||
|
await sio.emit('core_status', {
|
||||||
|
'success': True,
|
||||||
|
'data': status.to_dict(),
|
||||||
|
'message': '核心启动成功'
|
||||||
|
})
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": "核心启动成功"}
|
||||||
|
|
||||||
|
status = get_status()
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": "核心启动中..."}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
status = get_status()
|
||||||
|
if status.is_running:
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": "核心已启动"}
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@fastapi_app.post("/api/core/stop")
|
||||||
|
async def stop_core_api():
|
||||||
|
"""HTTP备用接口"""
|
||||||
|
try:
|
||||||
|
status = get_status()
|
||||||
|
if not status.is_running:
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": "核心已停止"}
|
||||||
|
|
||||||
|
success, error = stop_core()
|
||||||
|
if success:
|
||||||
|
for _ in range(20):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
status = get_status()
|
||||||
|
if not status.is_running:
|
||||||
|
# 广播状态更新
|
||||||
|
await sio.emit('core_status', {
|
||||||
|
'success': True,
|
||||||
|
'data': status.to_dict(),
|
||||||
|
'message': '核心已停止'
|
||||||
|
})
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": "核心已停止"}
|
||||||
|
|
||||||
|
status = get_status()
|
||||||
|
return {"success": True, "data": status.to_dict(), "message": error or "核心停止中..."}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail=error or "停止失败")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@fastapi_app.get("/api/modules/status")
|
||||||
|
async def get_modules_status():
|
||||||
|
"""模块状态"""
|
||||||
|
try:
|
||||||
|
from src.config.config import cfg
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"asr": {"enabled": cfg.asr.enabled},
|
||||||
|
"tts": {"enabled": cfg.tts.enabled},
|
||||||
|
"ai": {"enabled": cfg.ai.api_key is not None},
|
||||||
|
"auto_agent": {"enabled": cfg.auto_agent.enabled},
|
||||||
|
"llm_core": {"enabled": cfg.llm_core.enabled}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"asr": {"enabled": True}, "tts": {"enabled": True},
|
||||||
|
"ai": {"enabled": True}, "auto_agent": {"enabled": True},
|
||||||
|
"llm_core": {"enabled": True}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@fastapi_app.get("/api/config")
|
||||||
|
async def get_config():
|
||||||
|
from src.config.config import cfg
|
||||||
|
return {"success": True, "data": cfg.to_dict()}
|
||||||
|
|
||||||
|
@fastapi_app.post("/api/config/{section}")
|
||||||
|
async def update_config(section: str, data: Dict[str, Any]):
|
||||||
|
from src.config.config import cfg
|
||||||
|
cfg.update({section: data})
|
||||||
|
return {"success": True, "message": "配置已更新"}
|
||||||
|
|
||||||
|
@fastapi_app.post("/api/config/reload")
|
||||||
|
async def reload_config():
|
||||||
|
from src.config.config import cfg
|
||||||
|
cfg.reload()
|
||||||
|
return {"success": True, "message": "配置已重载"}
|
||||||
|
|
||||||
|
@fastapi_app.get("/api/preferences")
|
||||||
|
async def get_preferences():
|
||||||
|
prefs_path = Path(__file__).parent / "user_preferences.json"
|
||||||
|
if prefs_path.exists():
|
||||||
|
with open(prefs_path, 'r', encoding='utf-8') as f:
|
||||||
|
return {"success": True, "data": json.load(f)}
|
||||||
|
return {"success": True, "data": {}}
|
||||||
|
|
||||||
|
@fastapi_app.post("/api/preferences")
|
||||||
|
async def save_preferences(data: Dict[str, Any]):
|
||||||
|
prefs_path = Path(__file__).parent / "user_preferences.json"
|
||||||
|
with open(prefs_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
return {"success": True, "message": "偏好已保存"}
|
||||||
|
|
||||||
|
# 静态文件
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
if static_dir.exists():
|
||||||
|
fastapi_app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
|
|
||||||
|
def run_server(host: str = "0.0.0.0", port: int = 8089, debug: bool = False):
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("backend.app:app", host=host, port=port, reload=debug,
|
||||||
|
access_log=False) # 禁用默认访问日志(我们使用自定义过滤器)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_server()
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""
|
||||||
|
Yosuga Server 核心进程管理器
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# 全局状态
|
||||||
|
_core_instance: Optional[Any] = None
|
||||||
|
_core_thread: Optional[threading.Thread] = None
|
||||||
|
_core_start_time: Optional[float] = None
|
||||||
|
_core_stop_event: threading.Event = threading.Event() # 停止信号
|
||||||
|
_core_lock = threading.Lock()
|
||||||
|
_logger: Optional[logging.Logger] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CoreStatus:
|
||||||
|
is_running: bool = False
|
||||||
|
pid: int = 0
|
||||||
|
uptime: float = 0.0
|
||||||
|
error: Optional[str] = None
|
||||||
|
thread_alive: bool = False
|
||||||
|
start_time: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"is_running": self.is_running,
|
||||||
|
"pid": self.pid,
|
||||||
|
"uptime": round(self.uptime, 1),
|
||||||
|
"error": self.error,
|
||||||
|
"thread_alive": self.thread_alive,
|
||||||
|
"start_time": self.start_time
|
||||||
|
}
|
||||||
|
|
||||||
|
import os
|
||||||
|
def get_status() -> CoreStatus:
|
||||||
|
"""获取当前核心状态"""
|
||||||
|
global _core_thread, _core_start_time, _core_instance
|
||||||
|
|
||||||
|
with _core_lock:
|
||||||
|
status = CoreStatus()
|
||||||
|
|
||||||
|
if _core_thread is not None:
|
||||||
|
status.thread_alive = _core_thread.is_alive()
|
||||||
|
status.pid = os.getpid()
|
||||||
|
|
||||||
|
if _core_start_time and (status.thread_alive or _core_instance is not None):
|
||||||
|
status.uptime = time.time() - _core_start_time
|
||||||
|
status.is_running = True
|
||||||
|
status.start_time = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(_core_start_time))
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_loguru_logging(log_queue: Optional[Any] = None):
|
||||||
|
"""配置loguru,同时转发到标准logging以便Socket.IO捕获"""
|
||||||
|
try:
|
||||||
|
from loguru import logger
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger.remove()
|
||||||
|
|
||||||
|
# 关键:添加一个sink将loguru日志转发到标准logging
|
||||||
|
class LoguruToStandard:
|
||||||
|
def write(self, message):
|
||||||
|
# 解析loguru格式提取level
|
||||||
|
record = message.record
|
||||||
|
level = record["level"].name
|
||||||
|
# 获取标准logger并发送
|
||||||
|
std_logger = logging.getLogger("yosuga")
|
||||||
|
if level == "DEBUG":
|
||||||
|
std_logger.debug(record["message"])
|
||||||
|
elif level == "INFO":
|
||||||
|
std_logger.info(record["message"])
|
||||||
|
elif level == "SUCCESS":
|
||||||
|
std_logger.info(record["message"])
|
||||||
|
elif level == "WARNING":
|
||||||
|
std_logger.warning(record["message"])
|
||||||
|
elif level == "ERROR":
|
||||||
|
std_logger.error(record["message"])
|
||||||
|
elif level == "CRITICAL":
|
||||||
|
std_logger.critical(record["message"])
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 添加转发处理器
|
||||||
|
logger.add(LoguruToStandard(), format="{message}")
|
||||||
|
|
||||||
|
# 文件日志
|
||||||
|
from src.config.config import cfg
|
||||||
|
log_dir = Path(cfg.log_dir)
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.add(
|
||||||
|
f"{log_dir}/Yosuga_server-{{time:YYYY-MM-DD_HH-mm-ss}}.log",
|
||||||
|
encoding="utf-8",
|
||||||
|
rotation="100 MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Loguru配置错误: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_core_thread(project_root: Path):
|
||||||
|
"""在独立线程中运行YosugaServerCore"""
|
||||||
|
global _core_instance, _core_start_time, _core_stop_event, _logger
|
||||||
|
|
||||||
|
_core_stop_event.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from src.server_core.core import YosugaServerCore
|
||||||
|
from src.config.config import cfg
|
||||||
|
|
||||||
|
# 配置loguru并获取logger
|
||||||
|
logger = _setup_loguru_logging()
|
||||||
|
if logger:
|
||||||
|
logger.info("Yosuga_server 在线程中启动")
|
||||||
|
_logger = logger
|
||||||
|
|
||||||
|
async def run_core():
|
||||||
|
global _core_instance
|
||||||
|
try:
|
||||||
|
_core_instance = await YosugaServerCore.get_instance()
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.success(f"YosugaServerCore 初始化完成,线程ID: {threading.current_thread().ident}")
|
||||||
|
|
||||||
|
# 运行核心,同时检查停止信号
|
||||||
|
core_task = asyncio.create_task(_core_instance.run())
|
||||||
|
|
||||||
|
# 等待任务完成或收到停止信号
|
||||||
|
while not core_task.done():
|
||||||
|
if _core_stop_event.is_set():
|
||||||
|
core_task.cancel()
|
||||||
|
try:
|
||||||
|
await core_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if logger:
|
||||||
|
logger.info("核心收到停止信号,正在关闭...")
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.info("核心事件循环已结束")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if logger:
|
||||||
|
logger.info("核心任务已取消")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.exception(f"核心运行异常: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 运行异步核心
|
||||||
|
asyncio.run(run_core())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
error_msg = f"核心线程异常: {str(e)}\n{traceback.format_exc()}"
|
||||||
|
print(error_msg, file=sys.stderr)
|
||||||
|
if _logger:
|
||||||
|
_logger.error(error_msg)
|
||||||
|
finally:
|
||||||
|
_core_instance = None
|
||||||
|
if logger:
|
||||||
|
logger.info("核心线程已退出")
|
||||||
|
|
||||||
|
|
||||||
|
def start_core(project_root: Path) -> tuple[bool, Optional[str]]:
|
||||||
|
"""启动Yosuga核心"""
|
||||||
|
global _core_thread, _core_start_time, _core_stop_event
|
||||||
|
|
||||||
|
with _core_lock:
|
||||||
|
if _core_thread is not None and _core_thread.is_alive():
|
||||||
|
return True, None # 已经在运行
|
||||||
|
|
||||||
|
_core_start_time = None
|
||||||
|
_core_stop_event.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_core_thread = threading.Thread(
|
||||||
|
target=_run_core_thread,
|
||||||
|
args=(project_root,),
|
||||||
|
name="YosugaServerCore",
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
_core_thread.start()
|
||||||
|
_core_start_time = time.time() # 立即记录
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if not _core_thread.is_alive():
|
||||||
|
_core_start_time = None
|
||||||
|
return False, "核心线程未能启动"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_core_start_time = None
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_core() -> tuple[bool, Optional[str]]:
|
||||||
|
"""停止Yosuga核心"""
|
||||||
|
global _core_thread, _core_start_time, _core_stop_event
|
||||||
|
|
||||||
|
with _core_lock:
|
||||||
|
if _core_thread is None or not _core_thread.is_alive():
|
||||||
|
_core_thread = None
|
||||||
|
_core_start_time = None
|
||||||
|
return True, None # 已经停止
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 发送停止信号
|
||||||
|
_core_stop_event.set()
|
||||||
|
|
||||||
|
# 等待线程结束(带超时)
|
||||||
|
_core_thread.join(timeout=10.0)
|
||||||
|
|
||||||
|
was_alive = _core_thread.is_alive()
|
||||||
|
_core_thread = None
|
||||||
|
|
||||||
|
if was_alive:
|
||||||
|
# 线程还在运行,但已经发送了停止信号
|
||||||
|
# 由于daemon=True,主进程退出时会强制终止
|
||||||
|
_core_start_time = None
|
||||||
|
return True, "核心停止信号已发送,正在后台停止"
|
||||||
|
|
||||||
|
_core_start_time = None
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧接口
|
||||||
|
def get_core_status() -> Dict[str, Any]:
|
||||||
|
return get_status().to_dict()
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
"""
|
||||||
|
Yosuga Server 系统诊断模块 - TCP端口连通性版本
|
||||||
|
生产级健康检查与自检工具(不依赖HTTP接口)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import psutil
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
class HealthStatus(Enum):
|
||||||
|
HEALTHY = "healthy"
|
||||||
|
UNHEALTHY = "unhealthy"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
CHECKING = "checking"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckResult:
|
||||||
|
"""单项检查结果"""
|
||||||
|
name: str
|
||||||
|
status: HealthStatus
|
||||||
|
message: str
|
||||||
|
details: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
latency_ms: float = 0.0
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"status": self.status.value,
|
||||||
|
"message": self.message,
|
||||||
|
"details": self.details,
|
||||||
|
"latency_ms": round(self.latency_ms, 2),
|
||||||
|
"timestamp": self.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiagnosticsReport:
|
||||||
|
"""完整诊断报告"""
|
||||||
|
overall_status: HealthStatus
|
||||||
|
checks: List[CheckResult]
|
||||||
|
summary: Dict[str, int]
|
||||||
|
generated_at: float = field(default_factory=time.time)
|
||||||
|
version: str = "1.1.0"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"overall_status": self.overall_status.value,
|
||||||
|
"checks": [c.to_dict() for c in self.checks],
|
||||||
|
"summary": self.summary,
|
||||||
|
"generated_at": self.generated_at,
|
||||||
|
"version": self.version
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemDiagnostics:
|
||||||
|
"""系统诊断核心类 - TCP端口连通性检测"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
self.config_path = config_path or self._find_config_path()
|
||||||
|
self._timeout_seconds = 3
|
||||||
|
|
||||||
|
def _find_config_path(self) -> Path:
|
||||||
|
"""自动查找配置文件路径"""
|
||||||
|
markers = ['settings.json', 'pyproject.toml']
|
||||||
|
current = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
|
for path in [current, *current.parents]:
|
||||||
|
if (path / 'settings.json').exists():
|
||||||
|
return path / 'settings.json'
|
||||||
|
if path == path.parent:
|
||||||
|
break
|
||||||
|
return current / 'settings.json'
|
||||||
|
|
||||||
|
async def _check_tcp_port(self, host: str, port: int) -> Tuple[bool, float, Optional[str]]:
|
||||||
|
"""
|
||||||
|
基础TCP端口连通性检查
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否连通, 延迟ms, 错误信息)
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(host, port),
|
||||||
|
timeout=self._timeout_seconds
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
latency = (time.time() - start) * 1000
|
||||||
|
return True, latency, None
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False, self._timeout_seconds * 1000, "连接超时"
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
return False, (time.time() - start) * 1000, "连接被拒绝"
|
||||||
|
except Exception as e:
|
||||||
|
return False, (time.time() - start) * 1000, str(e)
|
||||||
|
|
||||||
|
def _parse_url(self, url: str) -> Tuple[str, int]:
|
||||||
|
"""
|
||||||
|
从URL解析主机和端口
|
||||||
|
支持 http://host:port/path 格式
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or 'localhost'
|
||||||
|
if parsed.port:
|
||||||
|
port = parsed.port
|
||||||
|
elif parsed.scheme == 'https':
|
||||||
|
port = 443
|
||||||
|
elif parsed.scheme == 'http':
|
||||||
|
port = 80
|
||||||
|
else:
|
||||||
|
port = 80
|
||||||
|
return host, port
|
||||||
|
except Exception:
|
||||||
|
if ':' in url:
|
||||||
|
parts = url.split(':')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
last_part = parts[-1].split('/')[0]
|
||||||
|
try:
|
||||||
|
return parts[-2].replace('//', ''), int(last_part)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return 'localhost', 80
|
||||||
|
|
||||||
|
async def check_asr(self, url: str = "http://localhost:20260") -> CheckResult:
|
||||||
|
"""检查ASR服务 - TCP端口连通性"""
|
||||||
|
host, port = self._parse_url(url)
|
||||||
|
if port == 80 and '20260' in url:
|
||||||
|
port = 20260
|
||||||
|
|
||||||
|
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||||
|
|
||||||
|
if is_open:
|
||||||
|
return CheckResult(
|
||||||
|
name="ASR服务",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"端口可连通 {host}:{port}",
|
||||||
|
details={"host": host, "port": port, "protocol": "TCP"},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CheckResult(
|
||||||
|
name="ASR服务",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"端口不可达 {host}:{port} - {error}",
|
||||||
|
details={"host": host, "port": port, "error": error},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_tts(self, host: str = "localhost", port: int = 20261) -> CheckResult:
|
||||||
|
"""检查TTS服务 - TCP端口连通性"""
|
||||||
|
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||||
|
|
||||||
|
if is_open:
|
||||||
|
return CheckResult(
|
||||||
|
name="TTS服务",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"端口可连通 {host}:{port}",
|
||||||
|
details={"host": host, "port": port},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CheckResult(
|
||||||
|
name="TTS服务",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"端口不可达 {host}:{port} - {error}",
|
||||||
|
details={"host": host, "port": port, "error": error},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_ai_service(self, base_url: str, api_key: Optional[str] = None) -> CheckResult:
|
||||||
|
"""检查AI服务 - TCP端口连通性"""
|
||||||
|
host, port = self._parse_url(base_url)
|
||||||
|
|
||||||
|
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||||
|
|
||||||
|
if is_open:
|
||||||
|
return CheckResult(
|
||||||
|
name="AI服务",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"端口可连通 {host}:{port}",
|
||||||
|
details={"host": host, "port": port, "base_url": base_url},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CheckResult(
|
||||||
|
name="AI服务",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"端口不可达 {host}:{port} - {error}",
|
||||||
|
details={"host": host, "port": port, "error": error},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_auto_agent(self, base_url: str) -> CheckResult:
|
||||||
|
"""检查Auto Agent服务 - TCP端口连通性"""
|
||||||
|
host, port = self._parse_url(base_url)
|
||||||
|
|
||||||
|
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||||
|
|
||||||
|
if is_open:
|
||||||
|
return CheckResult(
|
||||||
|
name="自动代理服务",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"端口可连通 {host}:{port}",
|
||||||
|
details={"host": host, "port": port},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CheckResult(
|
||||||
|
name="自动代理服务",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"端口不可达 {host}:{port} - {error}",
|
||||||
|
details={"host": host, "port": port, "error": error},
|
||||||
|
latency_ms=latency
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_config_file(self) -> CheckResult:
|
||||||
|
"""检查配置文件合法性"""
|
||||||
|
try:
|
||||||
|
if not self.config_path.exists():
|
||||||
|
return CheckResult(
|
||||||
|
name="配置文件",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"配置文件不存在: {self.config_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
config = json.loads(content)
|
||||||
|
|
||||||
|
required_sections = ['ai', 'tts', 'asr']
|
||||||
|
missing = [s for s in required_sections if s not in config]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
return CheckResult(
|
||||||
|
name="配置文件",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"缺少配置节: {', '.join(missing)}",
|
||||||
|
details={"missing_sections": missing}
|
||||||
|
)
|
||||||
|
|
||||||
|
return CheckResult(
|
||||||
|
name="配置文件",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"配置合法,含 {len(config)} 个配置节",
|
||||||
|
details={"sections": list(config.keys())}
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return CheckResult(
|
||||||
|
name="配置文件",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"JSON格式错误: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CheckResult(
|
||||||
|
name="配置文件",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"读取失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_model_files(self, config: Optional[Dict] = None) -> CheckResult:
|
||||||
|
"""检查模型文件存在性"""
|
||||||
|
try:
|
||||||
|
if config is None:
|
||||||
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
checks = {}
|
||||||
|
missing = []
|
||||||
|
project_root = self.config_path.parent
|
||||||
|
|
||||||
|
if 'tts' in config:
|
||||||
|
tts = config['tts']
|
||||||
|
gpt_path = tts.get('gpt_model_name', '')
|
||||||
|
sovits_path = tts.get('sovits_model_name', '')
|
||||||
|
|
||||||
|
if gpt_path:
|
||||||
|
full_path = project_root / gpt_path
|
||||||
|
exists = full_path.exists()
|
||||||
|
checks['gpt_model'] = {"path": str(full_path), "exists": exists}
|
||||||
|
if not exists:
|
||||||
|
missing.append(f"GPT模型: {gpt_path}")
|
||||||
|
|
||||||
|
if sovits_path:
|
||||||
|
full_path = project_root / sovits_path
|
||||||
|
exists = full_path.exists()
|
||||||
|
checks['sovits_model'] = {"path": str(full_path), "exists": exists}
|
||||||
|
if not exists:
|
||||||
|
missing.append(f"SoVITS模型: {sovits_path}")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
return CheckResult(
|
||||||
|
name="模型文件",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"缺少 {len(missing)} 个模型文件",
|
||||||
|
details={"missing": missing, "checks": checks}
|
||||||
|
)
|
||||||
|
|
||||||
|
return CheckResult(
|
||||||
|
name="模型文件",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message="所有配置模型文件已找到",
|
||||||
|
details={"checks": checks}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CheckResult(
|
||||||
|
name="模型文件",
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message=f"检查失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_ports(self, ports: Optional[List[int]] = None) -> CheckResult:
|
||||||
|
"""检查关键端口占用情况"""
|
||||||
|
if ports is None:
|
||||||
|
ports = [8089, 20260, 20261, 8765]
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_pid = psutil.Process().pid
|
||||||
|
current_ports = set()
|
||||||
|
|
||||||
|
proc = psutil.Process(current_pid)
|
||||||
|
for conn in proc.connections(kind='inet'):
|
||||||
|
if conn.status == 'LISTEN':
|
||||||
|
current_ports.add(conn.lport)
|
||||||
|
|
||||||
|
occupied_by_others = []
|
||||||
|
for port in ports:
|
||||||
|
if port in current_ports:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for p in psutil.process_iter(['pid', 'name']):
|
||||||
|
try:
|
||||||
|
for conn in p.connections(kind='inet'):
|
||||||
|
if conn.lport == port:
|
||||||
|
occupied_by_others.append({
|
||||||
|
"port": port,
|
||||||
|
"pid": p.pid,
|
||||||
|
"name": p.name()
|
||||||
|
})
|
||||||
|
break
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if occupied_by_others:
|
||||||
|
return CheckResult(
|
||||||
|
name="端口占用",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"{len(occupied_by_others)} 个端口被占用",
|
||||||
|
details={"conflicts": occupied_by_others}
|
||||||
|
)
|
||||||
|
|
||||||
|
return CheckResult(
|
||||||
|
name="端口占用",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message="关键端口可用",
|
||||||
|
details={"checked_ports": list(ports), "self_ports": list(current_ports)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CheckResult(
|
||||||
|
name="端口占用",
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message=f"检查失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_full_diagnostics(self) -> DiagnosticsReport:
|
||||||
|
"""执行完整系统体检"""
|
||||||
|
logger.info("开始系统体检(TCP模式)...")
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
checks.append(await self.check_config_file())
|
||||||
|
checks.append(await self.check_ports())
|
||||||
|
|
||||||
|
service_results = await self._check_services_tcp()
|
||||||
|
checks.extend(service_results)
|
||||||
|
|
||||||
|
checks.append(await self.check_model_files())
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"healthy": sum(1 for c in checks if c.status == HealthStatus.HEALTHY),
|
||||||
|
"unhealthy": sum(1 for c in checks if c.status == HealthStatus.UNHEALTHY),
|
||||||
|
"unknown": sum(1 for c in checks if c.status == HealthStatus.UNKNOWN),
|
||||||
|
"total": len(checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary["unhealthy"] == 0:
|
||||||
|
overall = HealthStatus.HEALTHY
|
||||||
|
elif summary["unhealthy"] <= summary["healthy"]:
|
||||||
|
overall = HealthStatus.UNKNOWN
|
||||||
|
else:
|
||||||
|
overall = HealthStatus.UNHEALTHY
|
||||||
|
|
||||||
|
report = DiagnosticsReport(
|
||||||
|
overall_status=overall,
|
||||||
|
checks=checks,
|
||||||
|
summary=summary
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"体检完成: {summary['healthy']}/{summary['total']} 项正常")
|
||||||
|
return report
|
||||||
|
|
||||||
|
async def _check_services_tcp(self) -> List[CheckResult]:
|
||||||
|
"""检查各项服务 - 纯TCP连通性"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
if 'asr' in config:
|
||||||
|
asr_url = config['asr'].get('url', 'http://localhost:20260')
|
||||||
|
results.append(await self.check_asr(asr_url))
|
||||||
|
else:
|
||||||
|
results.append(CheckResult(
|
||||||
|
name="ASR服务",
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message="配置中未启用ASR"
|
||||||
|
))
|
||||||
|
|
||||||
|
if 'tts' in config:
|
||||||
|
tts_cfg = config['tts']
|
||||||
|
results.append(await self.check_tts(
|
||||||
|
tts_cfg.get('host', 'localhost'),
|
||||||
|
tts_cfg.get('port', 20261)
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
results.append(CheckResult(
|
||||||
|
name="TTS服务",
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message="配置中未启用TTS"
|
||||||
|
))
|
||||||
|
|
||||||
|
if 'ai' in config:
|
||||||
|
ai_cfg = config['ai']
|
||||||
|
results.append(await self.check_ai_service(
|
||||||
|
ai_cfg.get('base_url', 'http://localhost:1234/v1')
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
results.append(CheckResult(
|
||||||
|
name="AI服务",
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message="配置中未启用AI"
|
||||||
|
))
|
||||||
|
|
||||||
|
if 'auto_agent' in config:
|
||||||
|
aa_cfg = config['auto_agent']
|
||||||
|
results.append(await self.check_auto_agent(
|
||||||
|
aa_cfg.get('base_url', 'http://localhost:1234/v1')
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
results.append(CheckResult(
|
||||||
|
name="自动代理服务",
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message="配置中未启用自动代理"
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"服务检查失败: {e}")
|
||||||
|
results.append(CheckResult(
|
||||||
|
name="服务检查",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"配置加载失败: {str(e)}"
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def quick_check_module(self, module_name: str, config: Dict) -> CheckResult:
|
||||||
|
"""快速检查单个模块 - TCP连通性"""
|
||||||
|
if module_name == "asr":
|
||||||
|
url = config.get('url', 'http://localhost:20260')
|
||||||
|
return await self.check_asr(url)
|
||||||
|
elif module_name == "tts":
|
||||||
|
return await self.check_tts(
|
||||||
|
config.get('host', 'localhost'),
|
||||||
|
config.get('port', 20261)
|
||||||
|
)
|
||||||
|
elif module_name == "ai":
|
||||||
|
return await self.check_ai_service(
|
||||||
|
config.get('base_url', 'http://localhost:1234/v1')
|
||||||
|
)
|
||||||
|
elif module_name == "auto_agent":
|
||||||
|
return await self.check_auto_agent(
|
||||||
|
config.get('base_url', 'http://localhost:1234/v1')
|
||||||
|
)
|
||||||
|
elif module_name == "llm_core":
|
||||||
|
from src.server_view.backend.core_manager import get_status
|
||||||
|
status = get_status()
|
||||||
|
if status.is_running:
|
||||||
|
return CheckResult(
|
||||||
|
name="LLM核心",
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message="核心进程运行中",
|
||||||
|
details={"uptime": status.uptime, "pid": status.pid}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CheckResult(
|
||||||
|
name="LLM核心",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message="核心进程未启动"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CheckResult(
|
||||||
|
name=module_name,
|
||||||
|
status=HealthStatus.UNKNOWN,
|
||||||
|
message="未知模块"
|
||||||
|
)
|
||||||
|
|
||||||
|
_diagnostics_instance: Optional[SystemDiagnostics] = None
|
||||||
|
|
||||||
|
async def get_diagnostics() -> SystemDiagnostics:
|
||||||
|
"""获取诊断实例(单例)"""
|
||||||
|
global _diagnostics_instance
|
||||||
|
if _diagnostics_instance is None:
|
||||||
|
_diagnostics_instance = SystemDiagnostics()
|
||||||
|
return _diagnostics_instance
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# from ide
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
node_modules/
|
||||||
|
dist
|
||||||
|
dist/
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Yosuga Server - 管理界面</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "yosuga-server-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vicons/ionicons5": "^0.12.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"naive-ui": "^2.35.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"socket.io-client": "^4.7.2",
|
||||||
|
"vue": "^3.3.8",
|
||||||
|
"vue-echarts": "^6.6.1",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.9.0",
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vue-tsc": "^1.8.22"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<n-config-provider :theme="currentTheme" :theme-overrides="themeOverrides">
|
||||||
|
<n-loading-bar-provider>
|
||||||
|
<n-dialog-provider>
|
||||||
|
<n-notification-provider>
|
||||||
|
<n-message-provider>
|
||||||
|
<n-layout class="layout" position="absolute" has-sider>
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<n-layout-sider
|
||||||
|
bordered
|
||||||
|
collapse-mode="width"
|
||||||
|
:collapsed-width="64"
|
||||||
|
:width="240"
|
||||||
|
:collapsed="prefs.preferences.sidebarCollapsed"
|
||||||
|
show-trigger
|
||||||
|
@collapse="prefs.toggleSidebar()"
|
||||||
|
@expand="prefs.toggleSidebar()"
|
||||||
|
>
|
||||||
|
<div class="logo">
|
||||||
|
<n-icon size="32" color="#18a058">
|
||||||
|
<logo-icon />
|
||||||
|
</n-icon>
|
||||||
|
<span v-if="!prefs.preferences.sidebarCollapsed" class="logo-text">Yosuga Server</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-menu
|
||||||
|
:collapsed="prefs.preferences.sidebarCollapsed"
|
||||||
|
:collapsed-width="64"
|
||||||
|
:collapsed-icon-size="22"
|
||||||
|
:options="menuOptions"
|
||||||
|
:value="activeKey"
|
||||||
|
@update:value="handleMenuUpdate"
|
||||||
|
/>
|
||||||
|
</n-layout-sider>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<n-layout>
|
||||||
|
<n-layout-header bordered class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<n-breadcrumb>
|
||||||
|
<n-breadcrumb-item>Yosuga</n-breadcrumb-item>
|
||||||
|
<n-breadcrumb-item>{{ pageTitle }}</n-breadcrumb-item>
|
||||||
|
</n-breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<!-- 主题切换 -->
|
||||||
|
<n-dropdown :options="themeOptions" @select="handleThemeSelect">
|
||||||
|
<n-button circle>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<sunny-icon v-if="prefs.preferences.theme === 'light'" />
|
||||||
|
<moon-icon v-else-if="prefs.preferences.theme === 'dark'" />
|
||||||
|
<contrast-icon v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
|
||||||
|
<n-badge :value="wsStatus === 'connected' ? 'Live' : 'Offline'"
|
||||||
|
:type="wsStatus === 'connected' ? 'success' : 'error'">
|
||||||
|
<n-button circle>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><wifi-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-badge>
|
||||||
|
</div>
|
||||||
|
</n-layout-header>
|
||||||
|
|
||||||
|
<n-layout-content class="content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</n-layout-content>
|
||||||
|
</n-layout>
|
||||||
|
</n-layout>
|
||||||
|
</n-message-provider>
|
||||||
|
</n-notification-provider>
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-loading-bar-provider>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, h, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { darkTheme, lightTheme, type GlobalTheme, type DropdownOption } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
HomeOutline as HomeIcon,
|
||||||
|
SettingsOutline as SettingsIcon,
|
||||||
|
TerminalOutline as TerminalIcon,
|
||||||
|
StatsChartOutline as StatsIcon,
|
||||||
|
HardwareChipOutline as DeviceIcon,
|
||||||
|
SunnyOutline as SunnyIcon,
|
||||||
|
MoonOutline as MoonIcon,
|
||||||
|
WifiOutline as WifiIcon,
|
||||||
|
PulseOutline as LogoIcon,
|
||||||
|
ContrastOutline as ContrastIcon
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { useWebSocketStore } from '@/stores/websocket'
|
||||||
|
import { usePreferencesStore } from '@/stores/preferences'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
const { status: wsStatus } = storeToRefs(wsStore)
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
|
||||||
|
const activeKey = computed(() => route.name as string)
|
||||||
|
const pageTitle = computed(() => route.meta?.title as string || 'Dashboard')
|
||||||
|
|
||||||
|
// 主题计算
|
||||||
|
const currentTheme = computed<GlobalTheme | null>(() => {
|
||||||
|
if (prefs.preferences.theme === 'dark') return darkTheme
|
||||||
|
if (prefs.preferences.theme === 'light') return lightTheme
|
||||||
|
return null // auto 时由 CSS 处理
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeOverrides = {
|
||||||
|
common: {
|
||||||
|
primaryColor: '#18a058',
|
||||||
|
primaryColorHover: '#36ad6a',
|
||||||
|
primaryColorPressed: '#0c7a43',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题选项
|
||||||
|
const themeOptions: DropdownOption[] = [
|
||||||
|
{ label: '浅色模式', key: 'light', icon: () => h(NIcon, null, { default: () => h(SunnyIcon) }) },
|
||||||
|
{ label: '深色模式', key: 'dark', icon: () => h(NIcon, null, { default: () => h(MoonIcon) }) },
|
||||||
|
{ label: '跟随系统', key: 'auto', icon: () => h(NIcon, null, { default: () => h(ContrastIcon) }) }
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderIcon = (icon: any) => {
|
||||||
|
return () => h(NIcon, null, { default: () => h(icon) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuOptions = [
|
||||||
|
{ label: '仪表板', key: 'dashboard', icon: renderIcon(HomeIcon) },
|
||||||
|
{ label: '系统监控', key: 'monitor', icon: renderIcon(StatsIcon) },
|
||||||
|
{ label: '设备管理', key: 'devices', icon: renderIcon(DeviceIcon) },
|
||||||
|
{ label: '配置管理', key: 'config', icon: renderIcon(SettingsIcon) },
|
||||||
|
{ label: '日志查看', key: 'logs', icon: renderIcon(TerminalIcon) }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleMenuUpdate = (key: string) => {
|
||||||
|
prefs.preferences.lastVisitedPage = key
|
||||||
|
router.push({ name: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThemeSelect = (key: string) => {
|
||||||
|
prefs.setTheme(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
prefs.loadPreferences()
|
||||||
|
wsStore.connect()
|
||||||
|
|
||||||
|
// 恢复上次访问的页面
|
||||||
|
if (prefs.preferences.lastVisitedPage && prefs.preferences.lastVisitedPage !== 'dashboard') {
|
||||||
|
router.push({ name: prefs.preferences.lastVisitedPage })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 全局主题 CSS 变量 */
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式自动适配 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light) {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--n-text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24px;
|
||||||
|
background-color: var(--n-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<n-card class="core-control" :bordered="false">
|
||||||
|
<n-space align="center" justify="space-between">
|
||||||
|
<n-space align="center">
|
||||||
|
<n-badge
|
||||||
|
dot
|
||||||
|
:type="isRunning ? 'success' : 'error'"
|
||||||
|
:processing="isLoading"
|
||||||
|
>
|
||||||
|
<n-icon size="28" :color="getStatusColor">
|
||||||
|
<pulse-icon />
|
||||||
|
</n-icon>
|
||||||
|
</n-badge>
|
||||||
|
<div>
|
||||||
|
<div class="core-title">
|
||||||
|
Yosuga 核心
|
||||||
|
<n-tag v-if="isRunning" type="success" size="small" style="margin-left: 8px">
|
||||||
|
运行中
|
||||||
|
</n-tag>
|
||||||
|
<n-tag v-else-if="hasError" type="error" size="small" style="margin-left: 8px">
|
||||||
|
启动失败
|
||||||
|
</n-tag>
|
||||||
|
<n-tag v-else type="default" size="small" style="margin-left: 8px">
|
||||||
|
已停止
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
<div class="core-status">
|
||||||
|
<span v-if="isRunning">
|
||||||
|
PID: {{ coreStatus?.pid || 0 }} |
|
||||||
|
运行时间: {{ formatUptime(coreStatus?.uptime || 0) }} |
|
||||||
|
线程{{ coreStatus?.thread_alive ? '存活' : '异常' }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="hasError" class="error-text">
|
||||||
|
错误: {{ coreStatus?.error }}
|
||||||
|
</span>
|
||||||
|
<span v-else>点击启动按钮开始运行</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-space>
|
||||||
|
<!-- 启动按钮 -->
|
||||||
|
<n-button
|
||||||
|
v-if="!isRunning && !isLoading"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="handleStart"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><play-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
启动核心
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<!-- 停止按钮 -->
|
||||||
|
<n-button
|
||||||
|
v-if="isRunning"
|
||||||
|
type="error"
|
||||||
|
size="large"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="handleStop"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><stop-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
停止核心
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<!-- 刷新按钮(现在主要用于强制刷新HTTP备用) -->
|
||||||
|
<n-button circle size="large" :loading="isLoading" @click="refreshStatus">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><refresh-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<!-- WebSocket连接状态指示器 -->
|
||||||
|
<n-divider />
|
||||||
|
<n-space align="center" justify="space-between" size="small">
|
||||||
|
<n-space align="center" size="small">
|
||||||
|
<n-badge :type="wsStore.status === 'connected' ? 'success' : 'error'" dot />
|
||||||
|
<span style="font-size: 12px; color: var(--n-text-color-3)">
|
||||||
|
{{ wsStore.status === 'connected' ? '实时连接正常' : '实时连接断开' }}
|
||||||
|
<span v-if="wsStore.latency > 0">({{ wsStore.latency }}ms)</span>
|
||||||
|
</span>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-button v-if="wsStore.status !== 'connected'" text type="primary" size="small" @click="reconnect">
|
||||||
|
重新连接
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<n-alert v-if="errorMsg" type="error" closable style="margin-top: 12px" @close="errorMsg = ''">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</n-alert>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
PulseOutline as PulseIcon,
|
||||||
|
PlayOutline as PlayIcon,
|
||||||
|
StopOutline as StopIcon,
|
||||||
|
RefreshOutline as RefreshIcon
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { useWebSocketStore } from '@/stores/websocket'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
|
||||||
|
// 本地状态
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
// 从WebSocket获取核心状态(实时)
|
||||||
|
const coreStatus = computed(() => wsStore.coreStatus)
|
||||||
|
const isRunning = computed(() => coreStatus.value?.is_running ?? false)
|
||||||
|
const hasError = computed(() => !!coreStatus.value?.error)
|
||||||
|
|
||||||
|
// 状态颜色
|
||||||
|
const getStatusColor = computed(() => {
|
||||||
|
if (isLoading.value) return '#f0a020'
|
||||||
|
if (isRunning.value) return '#18a058'
|
||||||
|
if (hasError.value) return '#d03050'
|
||||||
|
return '#808080'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化运行时间
|
||||||
|
const formatUptime = (seconds: number) => {
|
||||||
|
if (!seconds) return '0s'
|
||||||
|
const hrs = Math.floor(seconds / 3600)
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
if (hrs > 0) return `${hrs}h ${mins}m`
|
||||||
|
if (mins > 0) return `${mins}m ${secs}s`
|
||||||
|
return `${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用WebSocket启动核心(更快速,无HTTP开销)
|
||||||
|
const handleStart = async () => {
|
||||||
|
errorMsg.value = ''
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 优先使用WebSocket,如果不连接则回退到HTTP
|
||||||
|
if (wsStore.status === 'connected') {
|
||||||
|
wsStore.controlCore('start')
|
||||||
|
// 等待WebSocket推送状态更新(最多5秒)
|
||||||
|
await waitForStatusChange(true, 5000)
|
||||||
|
} else {
|
||||||
|
// HTTP备用方案
|
||||||
|
const res = await axios.post('/api/core/start')
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.error || '启动失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMsg.value = error.response?.data?.error || error.message || '启动失败'
|
||||||
|
message.error(errorMsg.value)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用WebSocket停止核心
|
||||||
|
const handleStop = async () => {
|
||||||
|
const confirmed = confirm('确定要停止 Yosuga 核心吗?这将中断所有正在进行的对话和任务。')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
if (wsStore.status === 'connected') {
|
||||||
|
wsStore.controlCore('stop')
|
||||||
|
await waitForStatusChange(false, 5000)
|
||||||
|
} else {
|
||||||
|
const res = await axios.post('/api/core/stop')
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.error || '停止失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.success('Yosuga 核心已停止')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMsg.value = error.response?.data?.error || error.message || '停止失败'
|
||||||
|
message.error(errorMsg.value)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待状态变化辅助函数
|
||||||
|
const waitForStatusChange = (targetRunning: boolean, timeout: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (coreStatus.value?.is_running === targetRunning) {
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
resolve() // 超时也不报错,依赖后续状态更新
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新状态(HTTP备用)
|
||||||
|
const refreshStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/core/status')
|
||||||
|
if (res.data.success) {
|
||||||
|
// 手动更新store(如果需要)
|
||||||
|
message.success('状态已刷新')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('刷新状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新连接WebSocket
|
||||||
|
const reconnect = () => {
|
||||||
|
wsStore.disconnect()
|
||||||
|
setTimeout(() => wsStore.connect(), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听WebSocket连接状态
|
||||||
|
watch(() => wsStore.status, (newStatus) => {
|
||||||
|
if (newStatus === 'disconnected' && isRunning.value) {
|
||||||
|
// 如果断开但核心在运行,尝试重新连接
|
||||||
|
setTimeout(() => wsStore.connect(), 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 确保WebSocket连接
|
||||||
|
if (wsStore.status !== 'connected') {
|
||||||
|
wsStore.connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.core-control {
|
||||||
|
background: linear-gradient(135deg, var(--n-card-color) 0%, var(--n-action-color) 100%);
|
||||||
|
border: 1px solid var(--n-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #d03050;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-badge .n-badge-dot) {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import naive from 'naive-ui'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import { useDiagnosticsStore } from './stores/diagnostics'
|
||||||
|
|
||||||
|
export { useDiagnosticsStore }
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(naive)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import Dashboard from '@/views/Dashboard.vue'
|
||||||
|
import Monitor from '@/views/Monitor.vue'
|
||||||
|
import Devices from '@/views/Devices.vue'
|
||||||
|
import Config from '@/views/Config.vue'
|
||||||
|
import Logs from '@/views/Logs.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: Dashboard,
|
||||||
|
meta: { title: '仪表板' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/monitor',
|
||||||
|
name: 'monitor',
|
||||||
|
component: Monitor,
|
||||||
|
meta: { title: '系统监控' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/devices',
|
||||||
|
name: 'devices',
|
||||||
|
component: Devices,
|
||||||
|
meta: { title: '设备管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/config',
|
||||||
|
name: 'config',
|
||||||
|
component: Config,
|
||||||
|
meta: { title: '配置管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/logs',
|
||||||
|
name: 'logs',
|
||||||
|
component: Logs,
|
||||||
|
meta: { title: '日志查看' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export interface ASRConfig {
|
||||||
|
enabled: boolean
|
||||||
|
url: string
|
||||||
|
model_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSConfig {
|
||||||
|
enabled: boolean
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
gpt_model_name: string
|
||||||
|
sovits_model_name: string
|
||||||
|
streaming: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfig {
|
||||||
|
api_key: string | null
|
||||||
|
base_url: string
|
||||||
|
model_name: string
|
||||||
|
temperature: number
|
||||||
|
max_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfigState {
|
||||||
|
version: string
|
||||||
|
debug: boolean
|
||||||
|
ai: AIConfig
|
||||||
|
tts: TTSConfig
|
||||||
|
asr: ASRConfig
|
||||||
|
auto_agent: any
|
||||||
|
llm_core: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfigStore = defineStore('config', () => {
|
||||||
|
const config = ref<AppConfigState | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/config')
|
||||||
|
if (response.data.success) {
|
||||||
|
config.value = response.data.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取配置失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConfig = async (section: string, data: any) => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/config/${section}`, data)
|
||||||
|
if (response.data.success) {
|
||||||
|
if (config.value) {
|
||||||
|
(config.value as any)[section] = data
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新配置失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/config/reload')
|
||||||
|
return response.data.success
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重载配置失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
fetchConfig,
|
||||||
|
updateConfig,
|
||||||
|
reloadConfig
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// core.ts - 现在仅作为HTTP备用,主要逻辑迁移到websocket.ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const useCoreStore = defineStore('core', () => {
|
||||||
|
// 保留HTTP方法作为备用
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/core/status')
|
||||||
|
return response.data.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取核心状态失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCore = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/core/start')
|
||||||
|
return response.data.success
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopCore = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/core/stop')
|
||||||
|
return response.data.success
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchStatus,
|
||||||
|
startCore,
|
||||||
|
stopCore
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useWebSocketStore } from './websocket'
|
||||||
|
|
||||||
|
export interface CheckResult {
|
||||||
|
name: string
|
||||||
|
status: 'healthy' | 'unhealthy' | 'unknown' | 'checking'
|
||||||
|
message: string
|
||||||
|
details: {
|
||||||
|
host?: string
|
||||||
|
port?: number
|
||||||
|
error?: string
|
||||||
|
tcp_ok?: boolean
|
||||||
|
}
|
||||||
|
latency_ms: number
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticsReport {
|
||||||
|
overall_status: 'healthy' | 'unhealthy' | 'unknown'
|
||||||
|
checks: CheckResult[]
|
||||||
|
summary: {
|
||||||
|
healthy: number
|
||||||
|
unhealthy: number
|
||||||
|
unknown: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
generated_at: number
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleHealth {
|
||||||
|
module: string
|
||||||
|
status: 'healthy' | 'unhealthy' | 'unknown' | 'checking'
|
||||||
|
message: string
|
||||||
|
details: {
|
||||||
|
host?: string
|
||||||
|
port?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
latency_ms?: number
|
||||||
|
lastCheck: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDiagnosticsStore = defineStore('diagnostics', () => {
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
|
||||||
|
const isRunningFullCheck = ref(false)
|
||||||
|
const lastReport = ref<DiagnosticsReport | null>(null)
|
||||||
|
const moduleHealth = ref<Record<string, ModuleHealth>>({
|
||||||
|
asr: { module: 'asr', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||||
|
tts: { module: 'tts', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||||
|
ai: { module: 'ai', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||||
|
auto_agent: { module: 'auto_agent', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||||
|
llm_core: { module: 'llm_core', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const healthyCount = computed(() =>
|
||||||
|
Object.values(moduleHealth.value).filter(m => m.status === 'healthy').length
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasUnhealthy = computed(() =>
|
||||||
|
Object.values(moduleHealth.value).some(m => m.status === 'unhealthy')
|
||||||
|
)
|
||||||
|
|
||||||
|
const overallStatus = computed(() => {
|
||||||
|
if (hasUnhealthy.value) return 'error'
|
||||||
|
if (healthyCount.value === 5) return 'success'
|
||||||
|
return 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
const runFullDiagnostics = async (): Promise<DiagnosticsReport> => {
|
||||||
|
isRunningFullCheck.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.post<DiagnosticsReport>('/api/diagnostics/run')
|
||||||
|
lastReport.value = response.data
|
||||||
|
|
||||||
|
response.data.checks.forEach(check => {
|
||||||
|
const moduleMap: Record<string, string> = {
|
||||||
|
'ASR服务': 'asr',
|
||||||
|
'TTS服务': 'tts',
|
||||||
|
'AI服务': 'ai',
|
||||||
|
'自动代理服务': 'auto_agent',
|
||||||
|
'LLM核心': 'llm_core'
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleKey = moduleMap[check.name]
|
||||||
|
if (moduleKey && moduleHealth.value[moduleKey]) {
|
||||||
|
moduleHealth.value[moduleKey] = {
|
||||||
|
module: moduleKey,
|
||||||
|
status: check.status,
|
||||||
|
message: check.message,
|
||||||
|
details: check.details,
|
||||||
|
latency_ms: check.latency_ms,
|
||||||
|
lastCheck: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} finally {
|
||||||
|
isRunningFullCheck.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkModule = async (module: string) => {
|
||||||
|
if (moduleHealth.value[module]) {
|
||||||
|
moduleHealth.value[module].status = 'checking'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (wsStore.status === 'connected') {
|
||||||
|
wsStore.socket?.emit('check_module_health', { module })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(`/api/diagnostics/check/${module}`)
|
||||||
|
if (response.data.success) {
|
||||||
|
updateModuleHealth(module, response.data.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
moduleHealth.value[module] = {
|
||||||
|
module,
|
||||||
|
status: 'unhealthy',
|
||||||
|
message: '检查失败',
|
||||||
|
details: { error: '网络请求失败' },
|
||||||
|
lastCheck: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateModuleHealth = (module: string, data: CheckResult) => {
|
||||||
|
moduleHealth.value[module] = {
|
||||||
|
module,
|
||||||
|
status: data.status,
|
||||||
|
message: data.message,
|
||||||
|
details: data.details,
|
||||||
|
latency_ms: data.latency_ms,
|
||||||
|
lastCheck: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAllModules = async () => {
|
||||||
|
const modules = ['asr', 'tts', 'ai', 'auto_agent', 'llm_core']
|
||||||
|
await Promise.all(modules.map(m => checkModule(m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const initWebSocketListeners = () => {
|
||||||
|
wsStore.socket?.on('module_health_result', (result: {
|
||||||
|
success: boolean
|
||||||
|
module?: string
|
||||||
|
data?: CheckResult
|
||||||
|
error?: string
|
||||||
|
}) => {
|
||||||
|
if (result.success && result.module && result.data) {
|
||||||
|
updateModuleHealth(result.module, result.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
wsStore.socket?.on('module_status_update', (update: {
|
||||||
|
module: string
|
||||||
|
status: CheckResult
|
||||||
|
}) => {
|
||||||
|
updateModuleHealth(update.module, update.status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let autoCheckInterval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const startAutoCheck = (intervalMs = 30000) => {
|
||||||
|
stopAutoCheck()
|
||||||
|
autoCheckInterval = setInterval(() => {
|
||||||
|
checkAllModules()
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAutoCheck = () => {
|
||||||
|
if (autoCheckInterval) {
|
||||||
|
clearInterval(autoCheckInterval)
|
||||||
|
autoCheckInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRunningFullCheck,
|
||||||
|
lastReport,
|
||||||
|
moduleHealth,
|
||||||
|
healthyCount,
|
||||||
|
hasUnhealthy,
|
||||||
|
overallStatus,
|
||||||
|
runFullDiagnostics,
|
||||||
|
checkModule,
|
||||||
|
checkAllModules,
|
||||||
|
initWebSocketListeners,
|
||||||
|
startAutoCheck,
|
||||||
|
stopAutoCheck
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const usePreferencesStore = defineStore('preferences', () => {
|
||||||
|
// 用户偏好状态
|
||||||
|
const preferences = ref({
|
||||||
|
theme: 'light', // 'light' | 'dark' | 'auto'
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
logLevel: 'ALL', // 默认日志等级筛选
|
||||||
|
logAutoScroll: true, // 日志自动滚动
|
||||||
|
refreshInterval: 2000, // 数据刷新间隔(ms)
|
||||||
|
lastVisitedPage: 'dashboard',
|
||||||
|
favoriteConfigs: [] as string[], // 收藏的配置项
|
||||||
|
notifications: {
|
||||||
|
coreStatus: true, // 核心状态变化通知
|
||||||
|
errors: true // 错误通知
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
|
// 从服务器加载偏好
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/preferences')
|
||||||
|
if (response.data.success) {
|
||||||
|
preferences.value = { ...preferences.value, ...response.data.data }
|
||||||
|
applyTheme(preferences.value.theme)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载偏好失败:', error)
|
||||||
|
// 从 localStorage 回退
|
||||||
|
const local = localStorage.getItem('yosuga_preferences')
|
||||||
|
if (local) {
|
||||||
|
preferences.value = { ...preferences.value, ...JSON.parse(local) }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存偏好到服务器和本地
|
||||||
|
const savePreferences = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/preferences', preferences.value)
|
||||||
|
localStorage.setItem('yosuga_preferences', JSON.stringify(preferences.value))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存偏好失败:', error)
|
||||||
|
// 至少保存到本地
|
||||||
|
localStorage.setItem('yosuga_preferences', JSON.stringify(preferences.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用主题
|
||||||
|
const applyTheme = (theme: string) => {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else if (theme === 'light') {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
} else {
|
||||||
|
// auto: 跟随系统
|
||||||
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置主题
|
||||||
|
const setTheme = (theme: string) => {
|
||||||
|
preferences.value.theme = theme
|
||||||
|
applyTheme(theme)
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换侧边栏
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
preferences.value.sidebarCollapsed = !preferences.value.sidebarCollapsed
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置日志等级
|
||||||
|
const setLogLevel = (level: string) => {
|
||||||
|
preferences.value.logLevel = level
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
if (preferences.value.theme === 'auto') {
|
||||||
|
applyTheme('auto')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存(防抖)
|
||||||
|
let saveTimer: NodeJS.Timeout
|
||||||
|
watch(preferences, () => {
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
savePreferences()
|
||||||
|
}, 500)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
isLoaded,
|
||||||
|
loadPreferences,
|
||||||
|
savePreferences,
|
||||||
|
setTheme,
|
||||||
|
toggleSidebar,
|
||||||
|
setLogLevel
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { io, Socket } from 'socket.io-client'
|
||||||
|
|
||||||
|
// 系统状态类型定义
|
||||||
|
export interface SystemStats {
|
||||||
|
cpu: { percent: number; count: number }
|
||||||
|
memory: {
|
||||||
|
total: number; available: number;
|
||||||
|
percent: number; used: number; free: number
|
||||||
|
}
|
||||||
|
disk: { total: number; used: number; free: number; percent: number }
|
||||||
|
process: {
|
||||||
|
memory_percent: number; cpu_percent: number;
|
||||||
|
threads: number; uptime: number
|
||||||
|
}
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreStatus {
|
||||||
|
is_running: boolean
|
||||||
|
pid: number
|
||||||
|
uptime: number
|
||||||
|
error: string | null
|
||||||
|
thread_alive: boolean
|
||||||
|
start_time: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWebSocketStore = defineStore('websocket', () => {
|
||||||
|
const socket = ref<Socket | null>(null)
|
||||||
|
const status = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
|
||||||
|
const logs = ref<string[]>([])
|
||||||
|
const currentFilter = ref('ALL')
|
||||||
|
|
||||||
|
// 新增:系统状态(来自WebSocket实时推送)
|
||||||
|
const systemStats = ref<SystemStats | null>(null)
|
||||||
|
const coreStatus = ref<CoreStatus | null>(null)
|
||||||
|
|
||||||
|
// 连接状态时间戳
|
||||||
|
const lastPing = ref<number>(0)
|
||||||
|
const latency = computed(() => {
|
||||||
|
if (!lastPing.value) return 0
|
||||||
|
return Date.now() - lastPing.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (socket.value?.connected) return
|
||||||
|
|
||||||
|
status.value = 'connecting'
|
||||||
|
socket.value = io('http://localhost:8089', {
|
||||||
|
transports: ['websocket'],
|
||||||
|
autoConnect: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.value.on('connect', () => {
|
||||||
|
status.value = 'connected'
|
||||||
|
lastPing.value = Date.now()
|
||||||
|
// 连接时自动订阅日志
|
||||||
|
subscribeWithLevel(currentFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.value.on('disconnect', (reason) => {
|
||||||
|
status.value = 'disconnected'
|
||||||
|
console.log(`WebSocket断开: ${reason}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.value.on('connect_error', (err) => {
|
||||||
|
console.error('WebSocket连接错误:', err)
|
||||||
|
status.value = 'disconnected'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 日志接收
|
||||||
|
socket.value.on('log_line', (data: { line: string; timestamp?: string; level?: string }) => {
|
||||||
|
logs.value.push(data.line)
|
||||||
|
if (logs.value.length > 1000) {
|
||||||
|
logs.value.shift()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新增:系统状态实时推送
|
||||||
|
socket.value.on('system_stats', (response: { success: boolean; data: SystemStats }) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
systemStats.value = response.data
|
||||||
|
lastPing.value = Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新增:核心状态实时推送(替代HTTP轮询)
|
||||||
|
socket.value.on('core_status', (response: { success: boolean; data: CoreStatus; message?: string }) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
coreStatus.value = response.data
|
||||||
|
console.log('核心状态更新:', response.data.is_running ? '运行中' : '已停止', response.message || '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 控制操作结果回调
|
||||||
|
socket.value.on('core_control_result', (result: { success: boolean; error?: string; message?: string; data?: any }) => {
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('核心控制失败:', result.error)
|
||||||
|
} else {
|
||||||
|
console.log('核心控制成功:', result.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:通过WebSocket控制核心(替代HTTP请求)
|
||||||
|
const controlCore = (action: 'start' | 'stop') => {
|
||||||
|
socket.value?.emit('control_core', { action })
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeWithLevel = (level: string) => {
|
||||||
|
currentFilter.value = level
|
||||||
|
socket.value?.emit('subscribe_logs', { level })
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
socket.value?.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearLogs = () => {
|
||||||
|
logs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳检测
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status.value === 'connected' && socket.value) {
|
||||||
|
// 如果超过10秒没有收到system_stats,认为连接可能有问题
|
||||||
|
if (lastPing.value && (Date.now() - lastPing.value > 10000)) {
|
||||||
|
console.warn('WebSocket可能卡顿,尝试重新连接...')
|
||||||
|
socket.value.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
status,
|
||||||
|
logs,
|
||||||
|
currentFilter,
|
||||||
|
systemStats, // 导出系统状态
|
||||||
|
coreStatus, // 导出核心状态
|
||||||
|
latency,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
clearLogs,
|
||||||
|
subscribeWithLevel,
|
||||||
|
controlCore, // 导出控制函数
|
||||||
|
startHeartbeat
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
<template>
|
||||||
|
<div class="config-page">
|
||||||
|
<n-card title="全局配置管理" :bordered="false">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-space>
|
||||||
|
<n-button @click="fetchConfig" :loading="loading">
|
||||||
|
<template #icon><n-icon><refresh-icon /></n-icon></template>
|
||||||
|
刷新
|
||||||
|
</n-button>
|
||||||
|
<n-button type="primary" @click="handleSave" :loading="saving">
|
||||||
|
<template #icon><n-icon><save-icon /></n-icon></template>
|
||||||
|
保存更改
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="showJson = true">
|
||||||
|
<template #icon><n-icon><code-icon /></n-icon></template>
|
||||||
|
查看JSON
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-tabs type="line" animated v-model:value="activeTab">
|
||||||
|
<!-- AI 模型配置 -->
|
||||||
|
<n-tab-pane name="ai" tab="AI 模型">
|
||||||
|
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||||
|
配置主对话AI服务,支持OpenAI、DeepSeek或本地LM Studio
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-form
|
||||||
|
v-if="formData.ai"
|
||||||
|
:model="formData.ai"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="140px"
|
||||||
|
require-mark-placement="right-hanging"
|
||||||
|
>
|
||||||
|
<n-form-item label="API Key">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.ai.api_key"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
<template #feedback>留空使用本地模型或无需认证的端点</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="Base URL">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.ai.base_url"
|
||||||
|
placeholder="http://localhost:1234/v1"
|
||||||
|
/>
|
||||||
|
<template #feedback>API端点地址,本地LM Studio通常为 http://localhost:1234/v1</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="模型名称">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.ai.model_name"
|
||||||
|
placeholder="qwen/qwen3-4b-2507"
|
||||||
|
/>
|
||||||
|
<template #feedback>例如: gpt-4, deepseek-chat, qwen/qwen3-4b-2507</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="Temperature">
|
||||||
|
<n-space align="center" style="width: 100%">
|
||||||
|
<n-slider
|
||||||
|
v-model:value="formData.ai.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.ai.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
style="width: 100px"
|
||||||
|
/>
|
||||||
|
</n-space>
|
||||||
|
<template #feedback>控制随机性: 0=确定性, 1=平衡, 2=创造性</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="Max Tokens">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.ai.max_tokens"
|
||||||
|
:min="256"
|
||||||
|
:max="32768"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<template #feedback>单次回复最大token数,通常设为8192</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="超时时间(秒)">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.ai.timeout"
|
||||||
|
:min="5"
|
||||||
|
:max="300"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<template #feedback>API请求超时时间</template>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- TTS 语音合成配置 -->
|
||||||
|
<n-tab-pane name="tts" tab="语音合成 (TTS)">
|
||||||
|
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||||
|
GPT-SoVITS 语音合成服务配置
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-form
|
||||||
|
v-if="formData.tts"
|
||||||
|
:model="formData.tts"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="140px"
|
||||||
|
>
|
||||||
|
<n-form-item label="启用 TTS">
|
||||||
|
<n-switch v-model:value="formData.tts.enabled" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-divider title-placement="left">服务端连接</n-divider>
|
||||||
|
|
||||||
|
<n-form-item label="主机地址">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.tts.host"
|
||||||
|
placeholder="localhost"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="端口">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.tts.port"
|
||||||
|
:min="1"
|
||||||
|
:max="65535"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<template #feedback>GPT-SoVITS API默认端口 9880 或 20261</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="API Key">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.tts.api_key"
|
||||||
|
type="password"
|
||||||
|
placeholder="可选"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-divider title-placement="left">模型配置</n-divider>
|
||||||
|
|
||||||
|
<n-form-item label="GPT模型路径">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.tts.gpt_model_name"
|
||||||
|
placeholder="GPT_weights_v2Pro/Yosuga_Airi-e32.ckpt"
|
||||||
|
/>
|
||||||
|
<template #feedback>相对于项目根目录的路径</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="SoVITS模型路径">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.tts.sovits_model_name"
|
||||||
|
placeholder="SoVITS_weights_v2Pro/Yosuga_Airi_e16_s864.pth"
|
||||||
|
/>
|
||||||
|
<template #feedback>相对于项目根目录的路径</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-divider title-placement="left">音频设置</n-divider>
|
||||||
|
|
||||||
|
<n-form-item label="参考音频路径">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.tts.reference_audio"
|
||||||
|
placeholder="./using/reference.wav"
|
||||||
|
/>
|
||||||
|
<template #feedback>用于声音克隆的参考音频文件路径</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="流式输出">
|
||||||
|
<n-switch v-model:value="formData.tts.streaming" />
|
||||||
|
<template #feedback>启用流式传输可降低延迟</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="语速倍率">
|
||||||
|
<n-space align="center" style="width: 100%">
|
||||||
|
<n-slider
|
||||||
|
v-model:value="formData.tts.speed"
|
||||||
|
:min="0.6"
|
||||||
|
:max="1.65"
|
||||||
|
:step="0.05"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<span style="min-width: 50px">{{ formData.tts.speed }}x</span>
|
||||||
|
</n-space>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- ASR 语音识别配置 -->
|
||||||
|
<n-tab-pane name="asr" tab="语音识别 (ASR)">
|
||||||
|
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||||
|
Faster-Whisper 语音识别服务配置
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-form
|
||||||
|
v-if="formData.asr"
|
||||||
|
:model="formData.asr"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="140px"
|
||||||
|
>
|
||||||
|
<n-form-item label="启用 ASR">
|
||||||
|
<n-switch v-model:value="formData.asr.enabled" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="服务 URL">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.asr.url"
|
||||||
|
placeholder="http://localhost:20260/"
|
||||||
|
/>
|
||||||
|
<template #feedback>ASR API端点地址,默认端口20260</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="模型名称">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.asr.model_name"
|
||||||
|
placeholder="fast-whisper"
|
||||||
|
/>
|
||||||
|
<template #feedback>使用的ASR模型标识</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="API Key">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.asr.api_key"
|
||||||
|
type="password"
|
||||||
|
placeholder="可选"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- 自动代理配置 -->
|
||||||
|
<n-tab-pane name="auto_agent" tab="自动代理 (Agent)">
|
||||||
|
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||||
|
UI-TARS 自动化操作代理配置,用于GUI控制
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-form
|
||||||
|
v-if="formData.auto_agent"
|
||||||
|
:model="formData.auto_agent"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="140px"
|
||||||
|
>
|
||||||
|
<n-form-item label="启用自动代理">
|
||||||
|
<n-switch v-model:value="formData.auto_agent.enabled" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="部署类型">
|
||||||
|
<n-select
|
||||||
|
v-model:value="formData.auto_agent.deployment_type"
|
||||||
|
:options="[
|
||||||
|
{ label: 'LM Studio (本地)', value: 'lmstudio' },
|
||||||
|
{ label: 'vLLM (本地服务器)', value: 'vllm' },
|
||||||
|
{ label: 'Ollama (本地)', value: 'ollama' },
|
||||||
|
{ label: '云端 API', value: 'cloud' }
|
||||||
|
]"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="Base URL">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.auto_agent.base_url"
|
||||||
|
placeholder="http://localhost:1234/v1"
|
||||||
|
/>
|
||||||
|
<template #feedback>UI-TARS模型服务端点</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="模型名称">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.auto_agent.model_name"
|
||||||
|
placeholder="ui-tars-1.5-7b@q4_k_m"
|
||||||
|
/>
|
||||||
|
<template #feedback>例如: ui-tars-1.5-7b, ui-tars-2.0-7b-sft</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="API Key">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.auto_agent.api_key"
|
||||||
|
type="password"
|
||||||
|
placeholder="可选,云端服务时需要"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="Temperature">
|
||||||
|
<n-space align="center" style="width: 100%">
|
||||||
|
<n-slider
|
||||||
|
v-model:value="formData.auto_agent.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.05"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.auto_agent.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.05"
|
||||||
|
style="width: 100px"
|
||||||
|
/>
|
||||||
|
</n-space>
|
||||||
|
<template #feedback>UI任务建议低温度(0.1-0.3)提高准确性</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="Max Tokens">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.auto_agent.max_tokens"
|
||||||
|
:min="2048"
|
||||||
|
:max="128000"
|
||||||
|
:step="1024"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<template #feedback>UI-TARS需要较大token数来处理截图,建议16384+</template>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- LLM 核心配置 -->
|
||||||
|
<n-tab-pane name="llm_core" tab="LLM 核心">
|
||||||
|
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||||
|
Yosuga LLM Core 行为配置
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-form
|
||||||
|
v-if="formData.llm_core"
|
||||||
|
:model="formData.llm_core"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="140px"
|
||||||
|
>
|
||||||
|
<n-form-item label="启用 LLM核心">
|
||||||
|
<n-switch v-model:value="formData.llm_core.enabled" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="启用历史记录">
|
||||||
|
<n-switch v-model:value="formData.llm_core.enable_history" />
|
||||||
|
<template #feedback>开启后LLM会记住对话上下文</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="最大上下文 Tokens">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="formData.llm_core.max_context_tokens"
|
||||||
|
:min="512"
|
||||||
|
:max="32768"
|
||||||
|
:step="512"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<template #feedback>上下文长度限制,超出会触发记忆清理</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="回复语言">
|
||||||
|
<n-select
|
||||||
|
v-model:value="formData.llm_core.language"
|
||||||
|
:options="[
|
||||||
|
{ label: '日本语 (Japanese)', value: '日本语' },
|
||||||
|
{ label: '中文 (Chinese)', value: 'zh_CN' },
|
||||||
|
{ label: 'English', value: 'en' }
|
||||||
|
]"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="角色设定">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.llm_core.role_character"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="输入角色设定..."
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
定义AI助手的性格、背景和行为方式
|
||||||
|
<n-button text type="primary" size="tiny" @click="resetRoleToDefault">
|
||||||
|
恢复默认
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- JSON预览模态框 -->
|
||||||
|
<n-modal v-model:show="showJson" title="当前配置 (JSON)" style="width: 800px">
|
||||||
|
<n-card>
|
||||||
|
<n-code :code="jsonPreview" language="json" style="max-height: 500px; overflow: auto;" />
|
||||||
|
<n-space justify="end" style="margin-top: 16px;">
|
||||||
|
<n-button @click="copyJson">复制到剪贴板</n-button>
|
||||||
|
<n-button @click="showJson = false">关闭</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, reactive, computed } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
RefreshOutline as RefreshIcon,
|
||||||
|
SaveOutline as SaveIcon,
|
||||||
|
CodeWorkingOutline as CodeIcon
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const activeTab = ref('ai')
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showJson = ref(false)
|
||||||
|
|
||||||
|
// 完整的配置数据结构(与后端config.py完全对应)
|
||||||
|
const formData = reactive({
|
||||||
|
ai: {
|
||||||
|
api_key: '',
|
||||||
|
base_url: '',
|
||||||
|
model_name: '',
|
||||||
|
timeout: 30,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 8192
|
||||||
|
},
|
||||||
|
tts: {
|
||||||
|
enabled: true,
|
||||||
|
api_key: null as string | null,
|
||||||
|
gpt_model_name: '',
|
||||||
|
sovits_model_name: '',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 20261,
|
||||||
|
reference_audio: './using/reference.wav',
|
||||||
|
streaming: true,
|
||||||
|
speed: 1.0
|
||||||
|
},
|
||||||
|
asr: {
|
||||||
|
enabled: true,
|
||||||
|
api_key: null as string | null,
|
||||||
|
model_name: 'fast-whisper',
|
||||||
|
url: 'http://localhost:20260/'
|
||||||
|
},
|
||||||
|
auto_agent: {
|
||||||
|
enabled: true,
|
||||||
|
api_key: null as string | null,
|
||||||
|
deployment_type: 'lmstudio',
|
||||||
|
model_name: 'ui-tars-1.5-7b@q4_k_m',
|
||||||
|
base_url: 'http://localhost:1234/v1',
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 16384
|
||||||
|
},
|
||||||
|
llm_core: {
|
||||||
|
enabled: true,
|
||||||
|
role_character: '你是由Misakiotoha开发的助手稲葉愛理ちゃん,可以和用户一起玩游戏,聊天,做各种事情,性格抽象,没事爱整整活。',
|
||||||
|
max_context_tokens: 2048,
|
||||||
|
enable_history: true,
|
||||||
|
language: '日本语'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultRole = '你是由Misakiotoha开发的助手稲葉愛理ちゃん,可以和用户一起玩游戏,聊天,做各种事情,性格抽象,没事爱整整活。'
|
||||||
|
|
||||||
|
const jsonPreview = computed(() => {
|
||||||
|
return JSON.stringify({
|
||||||
|
ai: formData.ai,
|
||||||
|
tts: formData.tts,
|
||||||
|
asr: formData.asr,
|
||||||
|
auto_agent: formData.auto_agent,
|
||||||
|
llm_core: formData.llm_core
|
||||||
|
}, null, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/config')
|
||||||
|
if (response.data.success) {
|
||||||
|
const cfg = response.data.data
|
||||||
|
|
||||||
|
// 深度合并配置(确保新字段也能显示)
|
||||||
|
Object.assign(formData.ai, cfg.ai || {})
|
||||||
|
Object.assign(formData.tts, cfg.tts || {})
|
||||||
|
Object.assign(formData.asr, cfg.asr || {})
|
||||||
|
Object.assign(formData.auto_agent, cfg.auto_agent || {})
|
||||||
|
Object.assign(formData.llm_core, cfg.llm_core || {})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取配置失败:', error)
|
||||||
|
message.error('获取配置失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 保存当前选中的tab对应的配置节
|
||||||
|
const section = activeTab.value
|
||||||
|
const data = formData[section as keyof typeof formData]
|
||||||
|
|
||||||
|
const response = await axios.post(`/api/config/${section}`, data)
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
message.success('配置已保存并生效')
|
||||||
|
} else {
|
||||||
|
message.error('保存失败: ' + (response.data.message || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存配置失败:', error)
|
||||||
|
message.error('保存失败: ' + (error.response?.data?.detail || error.message))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetRoleToDefault = () => {
|
||||||
|
formData.llm_core.role_character = defaultRole
|
||||||
|
message.info('已恢复默认角色设定')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyJson = () => {
|
||||||
|
navigator.clipboard.writeText(jsonPreview.value)
|
||||||
|
message.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-page {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-form-item-feedback) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-divider) {
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-divider__title) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<CoreControl style="margin-bottom: 16px" />
|
||||||
|
|
||||||
|
<!-- 系统体检报告卡片 -->
|
||||||
|
<n-card
|
||||||
|
v-if="lastReport"
|
||||||
|
:title="`系统体检报告 (v${lastReport.version})`"
|
||||||
|
class="diagnostics-card"
|
||||||
|
:bordered="false"
|
||||||
|
closable
|
||||||
|
@close="lastReport = null"
|
||||||
|
>
|
||||||
|
<n-space align="center" justify="space-between">
|
||||||
|
<n-space align="center">
|
||||||
|
<n-icon size="32" :color="statusColor">
|
||||||
|
<checkmark-circle-icon v-if="diagStore.overallStatus === 'success'" />
|
||||||
|
<warning-icon v-else-if="diagStore.overallStatus === 'warning'" />
|
||||||
|
<close-circle-icon v-else />
|
||||||
|
</n-icon>
|
||||||
|
<div>
|
||||||
|
<div :style="{ color: statusColor, fontWeight: 600 }">
|
||||||
|
{{ statusText }}
|
||||||
|
</div>
|
||||||
|
<div class="text-secondary">
|
||||||
|
{{ lastReport.summary.healthy }}/{{ lastReport.summary.total }} 项检查通过
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-button text type="primary" @click="showDetails = true">
|
||||||
|
查看详情
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-grid :x-gap="16" :y-gap="16" cols="1 s:2 l:4">
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="stat-card" :bordered="false">
|
||||||
|
<n-statistic label="CPU 使用率" :value="systemStats?.cpu?.percent ?? 0">
|
||||||
|
<template #suffix>%</template>
|
||||||
|
</n-statistic>
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="systemStats?.cpu?.percent ?? 0"
|
||||||
|
:indicator-placement="'inside'"
|
||||||
|
:color="getProgressColor(systemStats?.cpu?.percent ?? 0)"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="stat-card" :bordered="false">
|
||||||
|
<n-statistic label="内存使用" :value="memoryPercent">
|
||||||
|
<template #suffix>%</template>
|
||||||
|
</n-statistic>
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="memoryPercent"
|
||||||
|
:indicator-placement="'inside'"
|
||||||
|
:color="getProgressColor(memoryPercent)"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="stat-card" :bordered="false">
|
||||||
|
<n-statistic label="运行时间" :value="uptimeFormatted" />
|
||||||
|
<div class="stat-detail">进程启动至今</div>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="stat-card" :bordered="false">
|
||||||
|
<n-statistic label="服务健康" :value="`${diagStore.healthyCount}/5`" />
|
||||||
|
<div class="stat-detail">
|
||||||
|
<span :style="{ color: diagStore.hasUnhealthy ? '#d03050' : '#18a058' }">
|
||||||
|
{{ diagStore.hasUnhealthy ? '存在异常服务' : '所有服务正常' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<!-- 模块状态 - TCP端口检测 -->
|
||||||
|
<n-card title="模块状态" class="module-card" :bordered="false">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-space>
|
||||||
|
<n-tag :type="checkingAll ? 'warning' : 'success'" size="small">
|
||||||
|
<n-spin v-if="checkingAll" :size="12" style="margin-right: 4px" />
|
||||||
|
{{ checkingAll ? '检测中...' : 'TCP端口检测' }}
|
||||||
|
</n-tag>
|
||||||
|
<n-button text type="primary" size="small" @click="refreshAllModules">
|
||||||
|
<template #icon><n-icon><refresh-icon /></n-icon></template>
|
||||||
|
刷新
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-grid :x-gap="16" :y-gap="16" cols="2 s:3 l:5">
|
||||||
|
<n-gi v-for="(health, name) in moduleHealthDisplay" :key="name">
|
||||||
|
<div
|
||||||
|
class="module-item"
|
||||||
|
:class="{
|
||||||
|
active: health.status === 'healthy',
|
||||||
|
error: health.status === 'unhealthy',
|
||||||
|
checking: health.status === 'checking'
|
||||||
|
}"
|
||||||
|
@click="checkSingleModule(name)"
|
||||||
|
>
|
||||||
|
<n-icon size="24">
|
||||||
|
<checkmark-circle-icon v-if="health.status === 'healthy'" color="#18a058" />
|
||||||
|
<close-circle-icon v-else-if="health.status === 'unhealthy'" color="#d03050" />
|
||||||
|
<time-icon v-else-if="health.status === 'checking'" color="#f0a020" />
|
||||||
|
<help-circle-icon v-else color="#808080" />
|
||||||
|
</n-icon>
|
||||||
|
|
||||||
|
<div class="module-info">
|
||||||
|
<div class="module-name">{{ getModuleName(name) }}</div>
|
||||||
|
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<div class="module-status" :class="health.status">
|
||||||
|
<span v-if="health.status === 'healthy'">端口可连通</span>
|
||||||
|
<span v-else-if="health.status === 'unhealthy'">端口不可达</span>
|
||||||
|
<span v-else-if="health.status === 'checking'">检测中...</span>
|
||||||
|
<span v-else>未检测</span>
|
||||||
|
|
||||||
|
<span v-if="health.latency_ms && health.status === 'healthy'" class="latency">
|
||||||
|
({{ health.latency_ms < 1 ? '<1' : health.latency_ms.toFixed(0) }}ms)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div style="font-size: 12px;">
|
||||||
|
<div>主机: {{ health.details?.host || 'localhost' }}</div>
|
||||||
|
<div>端口: {{ health.details?.port || getDefaultPort(name) }}</div>
|
||||||
|
<div>上次检查: {{ formatTime(health.lastCheck) }}</div>
|
||||||
|
<div v-if="health.details?.error" style="color: #ff4d4f;">
|
||||||
|
错误: {{ health.details.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
v-if="health.status === 'unhealthy'"
|
||||||
|
text
|
||||||
|
type="error"
|
||||||
|
size="tiny"
|
||||||
|
@click.stop="showModuleDetails(name)"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- 快速操作 -->
|
||||||
|
<n-card title="快速操作" class="action-card" :bordered="false">
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:loading="diagStore.isRunningFullCheck"
|
||||||
|
@click="runSystemDiagnostics"
|
||||||
|
>
|
||||||
|
<template #icon><n-icon><medical-icon /></n-icon></template>
|
||||||
|
{{ diagStore.isRunningFullCheck ? '体检中...' : '系统体检' }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button @click="handleReloadConfig">
|
||||||
|
<template #icon><n-icon><refresh-icon /></n-icon></template>
|
||||||
|
重载配置
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button @click="exportConfig">
|
||||||
|
<template #icon><n-icon><download-icon /></n-icon></template>
|
||||||
|
导出配置
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-upload
|
||||||
|
action="/api/config/import"
|
||||||
|
accept=".json"
|
||||||
|
:show-file-list="false"
|
||||||
|
@finish="handleImportFinish"
|
||||||
|
@error="handleImportError"
|
||||||
|
>
|
||||||
|
<n-button>
|
||||||
|
<template #icon><n-icon><upload-icon /></n-icon></template>
|
||||||
|
导入配置
|
||||||
|
</n-button>
|
||||||
|
</n-upload>
|
||||||
|
|
||||||
|
<n-button @click="router.push('/logs')">
|
||||||
|
<template #icon><n-icon><terminal-icon /></n-icon></template>
|
||||||
|
查看日志
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- 体检详情抽屉 -->
|
||||||
|
<n-drawer v-model:show="showDetails" :width="600" placement="right">
|
||||||
|
<n-drawer-content title="详细体检报告">
|
||||||
|
<n-timeline v-if="lastReport">
|
||||||
|
<n-timeline-item
|
||||||
|
v-for="check in lastReport.checks"
|
||||||
|
:key="check.name"
|
||||||
|
:type="check.status === 'healthy' ? 'success' : check.status === 'unhealthy' ? 'error' : 'default'"
|
||||||
|
:title="check.name"
|
||||||
|
:content="check.message"
|
||||||
|
:time="`${check.latency_ms.toFixed(1)}ms`"
|
||||||
|
/>
|
||||||
|
</n-timeline>
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
|
||||||
|
<!-- 模块详情Modal -->
|
||||||
|
<n-modal v-model:show="showModuleModal" :title="`${selectedModule} 连接详情`">
|
||||||
|
<n-card style="width: 450px" v-if="selectedModule">
|
||||||
|
<n-descriptions bordered :column="1" size="small">
|
||||||
|
<n-descriptions-item label="检测方式">
|
||||||
|
<n-tag size="small">TCP端口连通性</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
|
||||||
|
<n-descriptions-item label="目标地址">
|
||||||
|
{{ currentModuleHealth?.details?.host || 'localhost' }}
|
||||||
|
:{{ currentModuleHealth?.details?.port || getDefaultPort(selectedModule) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
|
||||||
|
<n-descriptions-item label="连接状态">
|
||||||
|
<n-tag :type="currentModuleHealth?.status === 'healthy' ? 'success' : 'error'">
|
||||||
|
{{ currentModuleHealth?.status === 'healthy' ? '端口开放' : '端口关闭' }}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
|
||||||
|
<n-descriptions-item label="响应延迟" v-if="currentModuleHealth?.latency_ms">
|
||||||
|
{{ currentModuleHealth.latency_ms.toFixed(2) }} ms
|
||||||
|
</n-descriptions-item>
|
||||||
|
|
||||||
|
<n-descriptions-item label="最后检测">
|
||||||
|
{{ formatTime(currentModuleHealth?.lastCheck || 0) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
|
||||||
|
<n-descriptions-item label="错误信息" v-if="currentModuleHealth?.details?.error">
|
||||||
|
<span style="color: #d03050;">
|
||||||
|
{{ currentModuleHealth.details.error }}
|
||||||
|
</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
|
||||||
|
<n-alert
|
||||||
|
v-if="currentModuleHealth?.status === 'unhealthy'"
|
||||||
|
type="error"
|
||||||
|
style="margin-top: 16px"
|
||||||
|
:show-icon="true"
|
||||||
|
>
|
||||||
|
<template #header>排查建议</template>
|
||||||
|
|
||||||
|
<div v-if="currentModuleHealth?.details?.error === '连接被拒绝'" style="font-size: 13px;">
|
||||||
|
1. 服务可能已崩溃或未启动<br>
|
||||||
|
2. 检查防火墙是否放行端口<br>
|
||||||
|
3. 查看端口是否被其他进程占用
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentModuleHealth?.details?.error === '连接超时'" style="font-size: 13px;">
|
||||||
|
1. 服务未启动或网络不可达<br>
|
||||||
|
2. 检查IP地址和端口配置<br>
|
||||||
|
3. 确认服务监听的是0.0.0.0而非127.0.0.1
|
||||||
|
</div>
|
||||||
|
<div v-else style="font-size: 13px;">
|
||||||
|
1. 确认服务已启动<br>
|
||||||
|
2. 检查配置文件中的host/port<br>
|
||||||
|
3. 查看系统日志获取详细信息
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-divider style="margin: 8px 0;" />
|
||||||
|
<n-space vertical size="small" style="font-size: 12px;">
|
||||||
|
<div>配置文件: <code>settings.json</code></div>
|
||||||
|
<div v-if="selectedModule === 'asr'">默认端口: 20260</div>
|
||||||
|
<div v-else-if="selectedModule === 'tts'">默认端口: 20261</div>
|
||||||
|
<div v-else-if="selectedModule === 'ai' || selectedModule === 'auto_agent'">默认端口: 1234 (LM Studio)</div>
|
||||||
|
</n-space>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-space v-else justify="end" style="margin-top: 16px;">
|
||||||
|
<n-button size="small" @click="checkSingleModule(selectedModule)">
|
||||||
|
重新检测
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import axios from 'axios'
|
||||||
|
import {
|
||||||
|
CheckmarkCircleOutline as CheckmarkCircleIcon,
|
||||||
|
CloseCircleOutline as CloseCircleIcon,
|
||||||
|
RefreshOutline as RefreshIcon,
|
||||||
|
TerminalOutline as TerminalIcon,
|
||||||
|
DownloadOutline as DownloadIcon,
|
||||||
|
CloudUploadOutline as UploadIcon,
|
||||||
|
MedicalOutline as MedicalIcon,
|
||||||
|
WarningOutline as WarningIcon,
|
||||||
|
TimeOutline as TimeIcon,
|
||||||
|
HelpCircleOutline as HelpCircleIcon
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import CoreControl from '@/components/CoreControl.vue'
|
||||||
|
import { useWebSocketStore } from '@/stores/websocket'
|
||||||
|
import { useDiagnosticsStore } from '@/stores/diagnostics'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
const diagStore = useDiagnosticsStore()
|
||||||
|
|
||||||
|
const checkingAll = ref(false)
|
||||||
|
const showDetails = ref(false)
|
||||||
|
const showModuleModal = ref(false)
|
||||||
|
const selectedModule = ref<string>('')
|
||||||
|
|
||||||
|
const systemStats = computed(() => wsStore.systemStats)
|
||||||
|
const lastReport = computed(() => diagStore.lastReport)
|
||||||
|
const moduleHealthDisplay = computed(() => diagStore.moduleHealth)
|
||||||
|
|
||||||
|
const memoryPercent = computed(() => Math.round(systemStats.value?.memory?.percent ?? 0))
|
||||||
|
const uptimeFormatted = computed(() => {
|
||||||
|
const seconds = systemStats.value?.process?.uptime ?? 0
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60)
|
||||||
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
if (diagStore.overallStatus === 'success') return '#18a058'
|
||||||
|
if (diagStore.overallStatus === 'warning') return '#f0a020'
|
||||||
|
return '#d03050'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (diagStore.overallStatus === 'success') return '系统健康'
|
||||||
|
if (diagStore.overallStatus === 'warning') return '部分异常'
|
||||||
|
return '需要关注'
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModuleHealth = computed(() => {
|
||||||
|
if (!selectedModule.value) return null
|
||||||
|
return diagStore.moduleHealth[selectedModule.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const getProgressColor = (value: number) => {
|
||||||
|
if (value < 60) return '#18a058'
|
||||||
|
if (value < 80) return '#f0a020'
|
||||||
|
return '#d03050'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModuleName = (key: string) => {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
asr: '语音识别 (ASR)',
|
||||||
|
tts: '语音合成 (TTS)',
|
||||||
|
ai: 'AI 对话',
|
||||||
|
auto_agent: '自动代理',
|
||||||
|
llm_core: 'LLM 核心'
|
||||||
|
}
|
||||||
|
return names[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultPort = (module: string) => {
|
||||||
|
const ports: Record<string, string> = {
|
||||||
|
asr: '20260',
|
||||||
|
tts: '20261',
|
||||||
|
ai: '1234',
|
||||||
|
auto_agent: '1234',
|
||||||
|
llm_core: '本地'
|
||||||
|
}
|
||||||
|
return ports[module] || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
if (!timestamp) return '从未'
|
||||||
|
const diff = Date.now() - timestamp
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff/60000)}分钟前`
|
||||||
|
return new Date(timestamp).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSystemDiagnostics = async () => {
|
||||||
|
try {
|
||||||
|
await diagStore.runFullDiagnostics()
|
||||||
|
message.success('系统体检完成')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('体检失败: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSingleModule = async (name: string) => {
|
||||||
|
await diagStore.checkModule(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAllModules = async () => {
|
||||||
|
checkingAll.value = true
|
||||||
|
await diagStore.checkAllModules()
|
||||||
|
checkingAll.value = false
|
||||||
|
message.success('模块状态已刷新')
|
||||||
|
}
|
||||||
|
|
||||||
|
const showModuleDetails = (name: string) => {
|
||||||
|
selectedModule.value = name
|
||||||
|
showModuleModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReloadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/config/reload')
|
||||||
|
if (res.data.success) message.success('配置已重载')
|
||||||
|
} catch {
|
||||||
|
message.error('重载配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportConfig = () => {
|
||||||
|
window.open('/api/config/export', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportFinish = () => {
|
||||||
|
message.success('配置导入成功,正在重载...')
|
||||||
|
setTimeout(() => window.location.reload(), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportError = () => {
|
||||||
|
message.error('配置导入失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
let heartbeatCleanup: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
wsStore.connect()
|
||||||
|
diagStore.initWebSocketListeners()
|
||||||
|
heartbeatCleanup = wsStore.startHeartbeat()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
diagStore.checkAllModules()
|
||||||
|
diagStore.startAutoCheck(30000)
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (heartbeatCleanup) heartbeatCleanup()
|
||||||
|
diagStore.stopAutoCheck()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: linear-gradient(135deg, var(--n-card-color) 0%, var(--n-action-color) 100%);
|
||||||
|
border-left: 4px solid v-bind(statusColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, var(--n-card-color) 0%, var(--n-card-color) 100%);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-detail {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card,
|
||||||
|
.action-card {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--n-action-color);
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item:hover {
|
||||||
|
background-color: var(--n-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item.active {
|
||||||
|
background-color: rgba(24, 160, 88, 0.1);
|
||||||
|
border: 1px solid rgba(24, 160, 88, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item.error {
|
||||||
|
background-color: rgba(208, 48, 80, 0.1);
|
||||||
|
border: 1px solid rgba(208, 48, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item.checking {
|
||||||
|
background-color: rgba(240, 160, 32, 0.1);
|
||||||
|
border: 1px solid rgba(240, 160, 32, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-status.healthy {
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-status.unhealthy {
|
||||||
|
color: #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-status.checking {
|
||||||
|
color: #f0a020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div class="devices-page">
|
||||||
|
<n-space vertical :size="16">
|
||||||
|
<n-h2>
|
||||||
|
<n-text type="primary">嵌入式设备管理</n-text>
|
||||||
|
</n-h2>
|
||||||
|
|
||||||
|
<n-space justify="space-between" align="center">
|
||||||
|
<n-button @click="refreshDevices" :loading="loading" type="primary">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><refresh-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
刷新设备列表
|
||||||
|
</n-button>
|
||||||
|
<n-tag v-if="devices.length > 0" type="success" round>
|
||||||
|
在线设备: {{ devices.length }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-empty v-if="!loading && devices.length === 0" description="暂无在线设备">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="48"><device-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
|
||||||
|
<n-grid v-else :cols="1" :x-gap="12" :y-gap="12">
|
||||||
|
<n-grid-item v-for="device in devices" :key="device.device_id">
|
||||||
|
<n-card :title="device.name" hoverable>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-space>
|
||||||
|
<n-tag type="success" round size="small">在线</n-tag>
|
||||||
|
<n-popconfirm @positive-click="handleRemoveDevice(device.device_id)">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button circle size="small" type="error">
|
||||||
|
<template #icon><n-icon><close-icon /></n-icon></template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
确定移除设备 {{ device.name }}?
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-descriptions label-placement="left" bordered :column="1" size="small">
|
||||||
|
<n-descriptions-item label="设备ID">
|
||||||
|
<n-text code>{{ device.device_id }}</n-text>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="描述">
|
||||||
|
{{ device.description || '无描述' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="注册时间">
|
||||||
|
{{ formatTime(device.register_time) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
|
||||||
|
<n-divider />
|
||||||
|
|
||||||
|
<n-h5>可用功能</n-h5>
|
||||||
|
<n-empty v-if="!device.functions || device.functions.length === 0" description="无可用功能" />
|
||||||
|
<n-space v-else>
|
||||||
|
<n-tag v-for="fn in device.functions" :key="fn.name" type="info" size="small">
|
||||||
|
{{ fn.name }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<template #action>
|
||||||
|
<n-space justify="center">
|
||||||
|
<n-button @click="showRpcModal(device)" type="primary" size="small">
|
||||||
|
<template #icon><n-icon><terminal-icon /></n-icon></template>
|
||||||
|
发送RPC命令
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-grid-item>
|
||||||
|
</n-grid>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<!-- RPC 发送对话框 -->
|
||||||
|
<n-modal v-model:show="rpcModalVisible" title="发送 RPC 命令" preset="card" style="width: 600px">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-alert type="info" :title="'目标设备: ' + (rpcTarget?.name || '')" closable>
|
||||||
|
输入 JSON-RPC 2.0 调用字符串
|
||||||
|
</n-alert>
|
||||||
|
<n-input
|
||||||
|
v-model:value="rpcPayload"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder='{"method": "set_speed", "params": {"value": 100}, "id": 1}'
|
||||||
|
/>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="rpcModalVisible = false">取消</n-button>
|
||||||
|
<n-button @click="handleSendRpc" type="primary" :loading="rpcSending">
|
||||||
|
发送
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { useWebSocketStore } from '@/stores/websocket'
|
||||||
|
import {
|
||||||
|
RefreshOutline as RefreshIcon,
|
||||||
|
HardwareChipOutline as DeviceIcon,
|
||||||
|
CloseOutline as CloseIcon,
|
||||||
|
TerminalOutline as TerminalIcon
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const devices = ref<any[]>([])
|
||||||
|
const rpcModalVisible = ref(false)
|
||||||
|
const rpcTarget = ref<any>(null)
|
||||||
|
const rpcPayload = ref('')
|
||||||
|
const rpcSending = ref(false)
|
||||||
|
|
||||||
|
function formatTime(t: number | string) {
|
||||||
|
if (!t) return '未知'
|
||||||
|
const d = typeof t === 'number' ? new Date(t * 1000) : new Date(t)
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDevices() {
|
||||||
|
loading.value = true
|
||||||
|
const socket = wsStore.socket
|
||||||
|
if (socket?.connected) {
|
||||||
|
socket.emit('get_devices')
|
||||||
|
} else {
|
||||||
|
fetchDevicesHttp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDevicesHttp() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/devices')
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.success) {
|
||||||
|
devices.value = json.data || []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取设备列表失败', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRpcModal(device: any) {
|
||||||
|
rpcTarget.value = device
|
||||||
|
rpcPayload.value = ''
|
||||||
|
rpcModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendRpc() {
|
||||||
|
if (!rpcTarget.value || !rpcPayload.value) return
|
||||||
|
rpcSending.value = true
|
||||||
|
const socket = wsStore.socket
|
||||||
|
if (socket?.connected) {
|
||||||
|
socket.emit('send_device_rpc', {
|
||||||
|
device_id: rpcTarget.value.device_id,
|
||||||
|
rpc_call: rpcPayload.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rpcSending.value = false
|
||||||
|
rpcModalVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveDevice(deviceId: string) {
|
||||||
|
devices.value = devices.value.filter(d => d.device_id !== deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDevicesList(data: any) {
|
||||||
|
loading.value = false
|
||||||
|
if (data.success) {
|
||||||
|
devices.value = data.data || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeviceRpcResult(data: any) {
|
||||||
|
if (data.success) {
|
||||||
|
message.success(data.message || 'RPC 命令已发送到设备')
|
||||||
|
} else {
|
||||||
|
message.error(data.error || 'RPC 发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeviceRpcResponse(data: any) {
|
||||||
|
const payload = data.payload || {}
|
||||||
|
const result = payload.result
|
||||||
|
const error = payload.error
|
||||||
|
const devId = data.device_id || ''
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
message.error(`设备 ${devId} RPC 返回错误: ${JSON.stringify(error)}`)
|
||||||
|
} else {
|
||||||
|
message.success(`设备 ${devId} RPC 返回: ${JSON.stringify(result)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const socket = wsStore.socket
|
||||||
|
if (socket) {
|
||||||
|
socket.on('devices_list', onDevicesList)
|
||||||
|
socket.on('device_rpc_result', onDeviceRpcResult)
|
||||||
|
socket.on('device_rpc_response', onDeviceRpcResponse)
|
||||||
|
}
|
||||||
|
refreshDevices()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
const socket = wsStore.socket
|
||||||
|
if (socket) {
|
||||||
|
socket.off('devices_list', onDevicesList)
|
||||||
|
socket.off('device_rpc_result', onDeviceRpcResult)
|
||||||
|
socket.off('device_rpc_response', onDeviceRpcResponse)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.devices-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logs-terminal-page">
|
||||||
|
<!-- 终端工具栏 -->
|
||||||
|
<div class="terminal-toolbar">
|
||||||
|
<n-space align="center" justify="space-between" style="width: 100%">
|
||||||
|
<n-space align="center">
|
||||||
|
<n-icon size="20" color="#18a058"><terminal-icon /></n-icon>
|
||||||
|
<span class="toolbar-title">系统日志终端</span>
|
||||||
|
<n-divider vertical />
|
||||||
|
|
||||||
|
<!-- 日志统计 -->
|
||||||
|
<n-tag size="small" :type="logStats.error > 0 ? 'error' : 'default'">
|
||||||
|
ERROR: {{ logStats.error }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag size="small" type="warning">
|
||||||
|
WARN: {{ logStats.warn }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag size="small" type="info">
|
||||||
|
INFO: {{ logStats.info }}
|
||||||
|
</n-tag>
|
||||||
|
<span class="total-logs">总计: {{ wsStore.logs.length }} 行</span>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-space align="center">
|
||||||
|
<!-- 自动跟随开关(终端核心功能) -->
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
:type="isFollowing ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
@click="toggleFollow"
|
||||||
|
:class="{ 'following': isFollowing }"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<caret-down-icon v-if="isFollowing" />
|
||||||
|
<pause-icon v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
{{ isFollowing ? '跟随' : '暂停' }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<span>{{ isFollowing ? '自动滚动到底部' : '已暂停自动滚动' }}</span>
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<!-- 等级筛选 -->
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedLevel"
|
||||||
|
:options="levelOptions"
|
||||||
|
style="width: 100px"
|
||||||
|
size="small"
|
||||||
|
@update:value="handleLevelChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<n-input
|
||||||
|
v-model:value="searchText"
|
||||||
|
placeholder="搜索日志..."
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon><search-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<n-divider vertical />
|
||||||
|
|
||||||
|
<n-button size="small" @click="clearLogs" type="error" quaternary>
|
||||||
|
<template #icon><n-icon><trash-icon /></n-icon></template>
|
||||||
|
清空
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button size="small" @click="downloadLogs">
|
||||||
|
<template #icon><n-icon><download-icon /></n-icon></template>
|
||||||
|
导出
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-tag :type="wsStore.status === 'connected' ? 'success' : 'error'" size="small">
|
||||||
|
{{ wsStore.status === 'connected' ? '● 实时' : '● 离线' }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 终端主体区域 -->
|
||||||
|
<div class="terminal-container" ref="terminalContainer">
|
||||||
|
<!-- 新日志提示条(当不在底部且有新日志时显示) -->
|
||||||
|
<div
|
||||||
|
v-show="hasNewLogs && !isFollowing"
|
||||||
|
class="new-logs-indicator"
|
||||||
|
@click="scrollToBottom"
|
||||||
|
>
|
||||||
|
<n-icon><arrow-down-icon /></n-icon>
|
||||||
|
<span>{{ newLogsCount }} 条新日志 - 点击滚动到底部</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志内容区域(终端风格) -->
|
||||||
|
<div
|
||||||
|
class="terminal-content"
|
||||||
|
ref="logContent"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(log, index) in filteredLogs"
|
||||||
|
:key="index"
|
||||||
|
class="terminal-line"
|
||||||
|
:class="getLogLevelClass(log)"
|
||||||
|
>
|
||||||
|
<!-- 时间戳 -->
|
||||||
|
<span class="timestamp" v-if="showTimestamp">
|
||||||
|
{{ extractTimestamp(log) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 日志等级标签 -->
|
||||||
|
<span class="level-badge" :class="getLogLevelClass(log)">
|
||||||
|
{{ getLogLevelLabel(log) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 日志内容(可点击选择复制) -->
|
||||||
|
<span
|
||||||
|
class="log-message"
|
||||||
|
v-html="highlightSearch(log)"
|
||||||
|
@dblclick="selectLine(log)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="filteredLogs.length === 0" class="empty-terminal">
|
||||||
|
<n-empty description="暂无日志输出" size="small">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :size="40" color="#333"><terminal-outline-icon /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部锚点(用于自动滚动定位) -->
|
||||||
|
<div ref="bottomAnchor" class="bottom-anchor"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 终端状态栏 -->
|
||||||
|
<div class="terminal-statusbar">
|
||||||
|
<n-space align="center" justify="space-between" style="width: 100%">
|
||||||
|
<n-space align="center" size="small">
|
||||||
|
<span v-if="selectedLine" class="selected-info">
|
||||||
|
已选择: {{ selectedLine.substring(0, 50) }}{{ selectedLine.length > 50 ? '...' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="hint-text">
|
||||||
|
提示: 双击行选择文本 | Ctrl+F 搜索 | 滚轮查看历史
|
||||||
|
</span>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-space align="center" size="small">
|
||||||
|
<span class="status-item">滚动位置: {{ scrollPercent }}%</span>
|
||||||
|
<span class="status-item">显示: {{ filteredLogs.length }}/{{ wsStore.logs.length }}</span>
|
||||||
|
<span class="status-item" :class="{ 'following-active': isFollowing }">
|
||||||
|
{{ isFollowing ? '● 跟随模式' : '○ 手动浏览' }}
|
||||||
|
</span>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行详情弹窗(双击行时显示完整内容) -->
|
||||||
|
<n-modal v-model:show="showLineDetail" title="日志详情" style="width: 800px">
|
||||||
|
<n-card>
|
||||||
|
<div class="line-detail-content">{{ selectedLine }}</div>
|
||||||
|
<n-space justify="end" style="margin-top: 16px;">
|
||||||
|
<n-button @click="copySelectedLine">复制</n-button>
|
||||||
|
<n-button @click="showLineDetail = false">关闭</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import {
|
||||||
|
TrashOutline as TrashIcon,
|
||||||
|
DownloadOutline as DownloadIcon,
|
||||||
|
SearchOutline as SearchIcon,
|
||||||
|
TerminalOutline as TerminalIcon,
|
||||||
|
CaretDownOutline as CaretDownIcon,
|
||||||
|
PauseOutline as PauseIcon,
|
||||||
|
ArrowDownOutline as ArrowDownIcon
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { useWebSocketStore } from '@/stores/websocket'
|
||||||
|
import { useMessage, useDialog } from 'naive-ui'
|
||||||
|
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
// DOM引用
|
||||||
|
const terminalContainer = ref<HTMLElement>()
|
||||||
|
const logContent = ref<HTMLElement>()
|
||||||
|
const bottomAnchor = ref<HTMLElement>()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const selectedLevel = ref('ALL')
|
||||||
|
const searchText = ref('')
|
||||||
|
const isFollowing = ref(true) // 终端核心:是否自动跟随底部
|
||||||
|
const hasNewLogs = ref(false) // 有新日志但未跟随
|
||||||
|
const newLogsCount = ref(0)
|
||||||
|
const showTimestamp = ref(true)
|
||||||
|
const selectedLine = ref('')
|
||||||
|
const showLineDetail = ref(false)
|
||||||
|
const scrollPercent = ref(100)
|
||||||
|
|
||||||
|
// 用于判断是否在底部的阈值(像素)
|
||||||
|
const BOTTOM_THRESHOLD = 50
|
||||||
|
|
||||||
|
// 日志等级定义
|
||||||
|
const LOG_LEVELS = [
|
||||||
|
{ key: 'DEBUG', label: 'DEBUG', color: '#569cd6', weight: 1 },
|
||||||
|
{ key: 'INFO', label: 'INFO', color: '#4ec9b0', weight: 2 },
|
||||||
|
{ key: 'SUCCESS', label: 'OK', color: '#4ec9b0', weight: 2 },
|
||||||
|
{ key: 'WARNING', label: 'WARN', color: '#dcdcaa', weight: 3 },
|
||||||
|
{ key: 'ERROR', label: 'ERR', color: '#f44747', weight: 4 },
|
||||||
|
{ key: 'CRITICAL', label: 'CRIT', color: '#f44747', weight: 5 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const levelOptions = [
|
||||||
|
{ label: '全部', value: 'ALL' },
|
||||||
|
{ label: '调试', value: 'DEBUG' },
|
||||||
|
{ label: '信息', value: 'INFO' },
|
||||||
|
{ label: '警告', value: 'WARNING' },
|
||||||
|
{ label: '错误', value: 'ERROR' },
|
||||||
|
{ label: '严重', value: 'CRITICAL' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 统计各等级日志数量
|
||||||
|
const logStats = computed(() => {
|
||||||
|
const stats = { error: 0, warn: 0, info: 0, debug: 0 }
|
||||||
|
wsStore.logs.forEach(log => {
|
||||||
|
const level = getLogLevel(log)
|
||||||
|
if (level === 'error' || level === 'critical') stats.error++
|
||||||
|
else if (level === 'warning') stats.warn++
|
||||||
|
else if (level === 'info' || level === 'success') stats.info++
|
||||||
|
else stats.debug++
|
||||||
|
})
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的日志
|
||||||
|
const filteredLogs = computed(() => {
|
||||||
|
let logs = [...wsStore.logs]
|
||||||
|
|
||||||
|
// 等级过滤
|
||||||
|
if (selectedLevel.value !== 'ALL') {
|
||||||
|
const targetLevel = selectedLevel.value.toLowerCase()
|
||||||
|
logs = logs.filter(log => {
|
||||||
|
const level = getLogLevel(log)
|
||||||
|
if (selectedLevel.value === 'ERROR') {
|
||||||
|
return level === 'error' || level === 'critical'
|
||||||
|
}
|
||||||
|
return level === targetLevel
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索过滤
|
||||||
|
if (searchText.value) {
|
||||||
|
const keyword = searchText.value.toLowerCase()
|
||||||
|
logs = logs.filter(log => log.toLowerCase().includes(keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取日志等级
|
||||||
|
const getLogLevel = (log: string): string => {
|
||||||
|
for (const level of LOG_LEVELS) {
|
||||||
|
if (log.includes(`| ${level.key} |`) ||
|
||||||
|
log.includes(`[${level.key}]`) ||
|
||||||
|
log.includes(` ${level.key} `)) {
|
||||||
|
return level.key.toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLogLevelClass = (log: string): string => {
|
||||||
|
return getLogLevel(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLogLevelLabel = (log: string): string => {
|
||||||
|
const level = getLogLevel(log)
|
||||||
|
if (level === 'other') return 'LOG'
|
||||||
|
const found = LOG_LEVELS.find(l => l.key.toLowerCase() === level)
|
||||||
|
return found ? found.label : 'LOG'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取时间戳
|
||||||
|
const extractTimestamp = (log: string): string => {
|
||||||
|
const match = log.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
|
||||||
|
return match ? match[1].split(' ')[1] : '' // 只返回时间部分
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮搜索词
|
||||||
|
const highlightSearch = (log: string) => {
|
||||||
|
if (!searchText.value) return escapeHtml(log)
|
||||||
|
const regex = new RegExp(`(${escapeRegex(searchText.value)})`, 'gi')
|
||||||
|
return escapeHtml(log).replace(regex, '<mark>$1</mark>')
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (text: string): string => {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRegex = (string: string): string => {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心:滚动到底部(终端行为)
|
||||||
|
const scrollToBottom = async (smooth = false) => {
|
||||||
|
await nextTick()
|
||||||
|
if (bottomAnchor.value) {
|
||||||
|
bottomAnchor.value.scrollIntoView({
|
||||||
|
behavior: smooth ? 'smooth' : 'auto',
|
||||||
|
block: 'end'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hasNewLogs.value = false
|
||||||
|
newLogsCount.value = 0
|
||||||
|
scrollPercent.value = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否在底部
|
||||||
|
const checkIfNearBottom = (): boolean => {
|
||||||
|
if (!logContent.value) return true
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = logContent.value
|
||||||
|
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
return distanceToBottom < BOTTOM_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理滚动事件
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!logContent.value) return
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = logContent.value
|
||||||
|
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
|
||||||
|
// 计算滚动百分比
|
||||||
|
scrollPercent.value = Math.round(((scrollTop + clientHeight) / scrollHeight) * 100)
|
||||||
|
|
||||||
|
// 如果用户手动滚动离开底部,暂停自动跟随
|
||||||
|
if (distanceToBottom > BOTTOM_THRESHOLD && isFollowing.value) {
|
||||||
|
isFollowing.value = false
|
||||||
|
console.log('用户手动滚动,暂停跟随')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果用户滚动到底部附近,恢复跟随
|
||||||
|
if (distanceToBottom < BOTTOM_THRESHOLD && !isFollowing.value) {
|
||||||
|
isFollowing.value = true
|
||||||
|
hasNewLogs.value = false
|
||||||
|
newLogsCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换跟随模式
|
||||||
|
const toggleFollow = () => {
|
||||||
|
isFollowing.value = !isFollowing.value
|
||||||
|
if (isFollowing.value) {
|
||||||
|
scrollToBottom(true)
|
||||||
|
message.success('已恢复自动跟随')
|
||||||
|
} else {
|
||||||
|
message.info('已暂停自动跟随,可自由查看历史日志')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听日志变化(核心:自动滚动逻辑)
|
||||||
|
watch(() => wsStore.logs.length, (newLength, oldLength) => {
|
||||||
|
if (newLength > oldLength) {
|
||||||
|
const added = newLength - oldLength
|
||||||
|
if (!isFollowing.value) {
|
||||||
|
// 不跟随状态下累积新日志计数
|
||||||
|
hasNewLogs.value = true
|
||||||
|
newLogsCount.value += added
|
||||||
|
} else {
|
||||||
|
// 跟随状态下自动滚到底部
|
||||||
|
scrollToBottom(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听过滤条件变化(重新计算后保持在当前位置或底部)
|
||||||
|
watch([selectedLevel, searchText], () => {
|
||||||
|
if (isFollowing.value) {
|
||||||
|
nextTick(() => scrollToBottom(false))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理等级变更
|
||||||
|
const handleLevelChange = (level: string) => {
|
||||||
|
wsStore.subscribeWithLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空日志
|
||||||
|
const clearLogs = () => {
|
||||||
|
dialog.warning({
|
||||||
|
title: '清空日志',
|
||||||
|
content: '确定要清空所有日志吗?此操作不可恢复。',
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
wsStore.clearLogs()
|
||||||
|
hasNewLogs.value = false
|
||||||
|
newLogsCount.value = 0
|
||||||
|
message.success('日志已清空')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载日志
|
||||||
|
const downloadLogs = () => {
|
||||||
|
const blob = new Blob([wsStore.logs.join('\n')], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `yosuga-logs-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.log`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双击选择行
|
||||||
|
const selectLine = (log: string) => {
|
||||||
|
selectedLine.value = log
|
||||||
|
showLineDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const copySelectedLine = () => {
|
||||||
|
navigator.clipboard.writeText(selectedLine.value)
|
||||||
|
message.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.key === 'f') {
|
||||||
|
e.preventDefault()
|
||||||
|
// 聚焦搜索框
|
||||||
|
const searchInput = document.querySelector('.logs-terminal-page input') as HTMLInputElement
|
||||||
|
searchInput?.focus()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
isFollowing.value = true
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始滚动到底部
|
||||||
|
setTimeout(() => scrollToBottom(false), 100)
|
||||||
|
|
||||||
|
// 监听键盘
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
|
// 定期更新滚动位置显示(用于状态栏)
|
||||||
|
const updateInterval = setInterval(() => {
|
||||||
|
if (logContent.value && isFollowing.value) {
|
||||||
|
scrollPercent.value = 100
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(updateInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 终端页面布局 */
|
||||||
|
.logs-terminal-page {
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #1e1e1e; /* VS Code终端深色背景 */
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.terminal-toolbar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #252526;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cccccc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-logs {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #858585;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 终端主体 */
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新日志提示条 */
|
||||||
|
.new-logs-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(24, 160, 88, 0.9);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-logs-indicator:hover {
|
||||||
|
background: rgba(24, 160, 88, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日志内容区域 - 终端风格 */
|
||||||
|
.terminal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
|
||||||
|
/* 终端滚动条样式 */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #424242 #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-content::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-content::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #424242;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4f4f4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 终端行样式 */
|
||||||
|
.terminal-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-line:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间戳 */
|
||||||
|
.timestamp {
|
||||||
|
color: #858585;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等级标签 */
|
||||||
|
.level-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 45px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge.debug {
|
||||||
|
background-color: rgba(86, 156, 214, 0.15);
|
||||||
|
color: #569cd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge.info,
|
||||||
|
.level-badge.success {
|
||||||
|
background-color: rgba(78, 201, 176, 0.15);
|
||||||
|
color: #4ec9b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge.warning {
|
||||||
|
background-color: rgba(220, 220, 170, 0.15);
|
||||||
|
color: #dcdcaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge.error,
|
||||||
|
.level-badge.critical {
|
||||||
|
background-color: rgba(244, 71, 71, 0.15);
|
||||||
|
color: #f44747;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge.other {
|
||||||
|
background-color: rgba(133, 133, 133, 0.15);
|
||||||
|
color: #858585;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日志消息 */
|
||||||
|
.log-message {
|
||||||
|
flex: 1;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误行高亮 */
|
||||||
|
.terminal-line.error .log-message,
|
||||||
|
.terminal-line.critical .log-message {
|
||||||
|
color: #f44747;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-line.warning .log-message {
|
||||||
|
color: #dcdcaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-line.debug .log-message {
|
||||||
|
color: #858585;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索高亮 */
|
||||||
|
:deep(mark) {
|
||||||
|
background-color: rgba(220, 220, 170, 0.4);
|
||||||
|
color: #dcdcaa;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-terminal {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #858585;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部锚点 */
|
||||||
|
.bottom-anchor {
|
||||||
|
height: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态栏 */
|
||||||
|
.terminal-statusbar {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #252526;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #858585;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
padding: 0 8px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.following-active {
|
||||||
|
color: #4ec9b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
color: #6e6e6e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-info {
|
||||||
|
color: #4ec9b0;
|
||||||
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 跟随按钮高亮 */
|
||||||
|
.following {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行详情弹窗 */
|
||||||
|
.line-detail-content {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timestamp {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-toolbar .n-space:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div class="monitor-page">
|
||||||
|
<n-grid :x-gap="16" :y-gap="16" cols="1 l:2">
|
||||||
|
<n-gi>
|
||||||
|
<n-card title="CPU 使用率历史" :bordered="false">
|
||||||
|
<v-chart class="chart" :option="cpuChartOption" autoresize />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi>
|
||||||
|
<n-card title="内存使用历史" :bordered="false">
|
||||||
|
<v-chart class="chart" :option="memoryChartOption" autoresize />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-card title="实时指标" class="metrics-card" :bordered="false">
|
||||||
|
<n-descriptions bordered :column="3">
|
||||||
|
<n-descriptions-item label="CPU 核心数">
|
||||||
|
{{ systemStats.cpu.count }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="内存总量">
|
||||||
|
{{ formatBytes(systemStats.memory.total) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="磁盘使用率">
|
||||||
|
{{ systemStats.disk.percent?.toFixed(1) }}%
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="进程内存占用">
|
||||||
|
{{ systemStats.process.memory_percent?.toFixed(2) }}%
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="进程 CPU 占用">
|
||||||
|
{{ systemStats.process.cpu_percent?.toFixed(1) }}%
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="线程数">
|
||||||
|
{{ systemStats.process.threads }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { use } from 'echarts/core'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import { LineChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent
|
||||||
|
} from 'echarts/components'
|
||||||
|
import VChart from 'vue-echarts'
|
||||||
|
|
||||||
|
use([
|
||||||
|
CanvasRenderer,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent
|
||||||
|
])
|
||||||
|
|
||||||
|
const systemStats = ref({
|
||||||
|
cpu: { percent: 0, count: 0 },
|
||||||
|
memory: { total: 0, percent: 0 },
|
||||||
|
disk: { percent: 0 },
|
||||||
|
process: { memory_percent: 0, cpu_percent: 0, threads: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cpuHistory = ref<number[]>([])
|
||||||
|
const memoryHistory = ref<number[]>([])
|
||||||
|
const timeLabels = ref<string[]>([])
|
||||||
|
|
||||||
|
const cpuChartOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: timeLabels.value
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
max: 100,
|
||||||
|
axisLabel: { formatter: '{value}%' }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'CPU',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(24, 160, 88, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(24, 160, 88, 0.05)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lineStyle: { color: '#18a058' },
|
||||||
|
itemStyle: { color: '#18a058' },
|
||||||
|
data: cpuHistory.value
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const memoryChartOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: timeLabels.value
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
max: 100,
|
||||||
|
axisLabel: { formatter: '{value}%' }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: '内存',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(32, 128, 240, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(32, 128, 240, 0.05)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lineStyle: { color: '#2080f0' },
|
||||||
|
itemStyle: { color: '#2080f0' },
|
||||||
|
data: memoryHistory.value
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalId: NodeJS.Timeout
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/system/info')
|
||||||
|
if (res.data.success) {
|
||||||
|
systemStats.value = res.data.data
|
||||||
|
|
||||||
|
cpuHistory.value.push(res.data.data.cpu.percent)
|
||||||
|
memoryHistory.value.push(res.data.data.memory.percent)
|
||||||
|
timeLabels.value.push(new Date().toLocaleTimeString())
|
||||||
|
|
||||||
|
if (cpuHistory.value.length > 20) {
|
||||||
|
cpuHistory.value.shift()
|
||||||
|
memoryHistory.value.shift()
|
||||||
|
timeLabels.value.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取监控数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
intervalId = setInterval(fetchStats, 2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitor-page {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-card {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8089',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://localhost:8089',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../backend/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
assetsDir: 'assets',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Yosuga Server Web UI 启动脚本
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加项目根目录到路径
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from backend.app import run_server
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_server(host="0.0.0.0", port=8089, debug=False)
|
||||||
Reference in New Issue
Block a user