1. 完善了服务端web部分,使用Vue编写(见serve_view)

2. 增加了对嵌入式设备完全自定义控制的功能
This commit is contained in:
Misaki
2026-04-25 08:48:50 +08:00
parent 8ffc609388
commit 39c54a4452
51 changed files with 7070 additions and 95 deletions
+17
View File
@@ -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
+686
View File
@@ -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()
+248
View File
@@ -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()
+524
View File
@@ -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
+21
View File
@@ -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
+13
View File
@@ -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>
+29
View File
@@ -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"
}
}
+238
View File
@@ -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>
+13
View File
@@ -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服务支持OpenAIDeepSeek或本地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>
+765
View File
@@ -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>
+25
View File
@@ -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"]
}
+31
View File
@@ -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',
},
})
+14
View File
@@ -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)