1. 完善了服务端web部分,使用Vue编写(见serve_view)
2. 增加了对嵌入式设备完全自定义控制的功能
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# from ide
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv
|
||||
|
||||
user_preferences.json
|
||||
@@ -0,0 +1,686 @@
|
||||
"""
|
||||
Yosuga Server Web UI - FastAPI Backend with Socket.IO
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import psutil
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Set
|
||||
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import socketio
|
||||
|
||||
# 项目根目录
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from src.server_view.backend.core_manager import (
|
||||
start_core, stop_core, get_status, get_core_status
|
||||
)
|
||||
from src.server_view.backend.diagnostics import get_diagnostics, HealthStatus, CheckResult
|
||||
|
||||
# Socket.IO 服务器
|
||||
sio = socketio.AsyncServer(
|
||||
cors_allowed_origins="*",
|
||||
async_mode="asgi",
|
||||
logger=False,
|
||||
engineio_logger=False
|
||||
)
|
||||
|
||||
# 跟踪已连接的客户端
|
||||
connected_clients: Set[str] = set()
|
||||
|
||||
# RPC 响应转发回调标志(只注册一次)
|
||||
_rpc_forwarder_registered: bool = False
|
||||
|
||||
# 日志广播处理器
|
||||
class SocketIOLogHandler(logging.Handler):
|
||||
"""将Python标准日志发送到Socket.IO"""
|
||||
def __init__(self, sio_server):
|
||||
super().__init__()
|
||||
self.sio = sio_server
|
||||
self.setLevel(logging.DEBUG)
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self._broadcast(msg, record.levelname))
|
||||
except RuntimeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _broadcast(self, message: str, level: str):
|
||||
try:
|
||||
await self.sio.emit('log_line', {
|
||||
'line': message,
|
||||
'timestamp': time.time(),
|
||||
'level': level
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def setup_logging(sio_server):
|
||||
"""配置日志系统 - 减少HTTP访问日志噪音"""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
|
||||
# 清除现有处理器
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# 控制台处理器 - 过滤掉频繁的系统信息HTTP请求日志
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(logging.INFO)
|
||||
console.setFormatter(logging.Formatter(
|
||||
'%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d - %(message)s'
|
||||
))
|
||||
|
||||
# 添加过滤器,排除频繁的HTTP轮询日志
|
||||
def filter_http_poll(record):
|
||||
msg = record.getMessage()
|
||||
# 过滤掉 /api/system/info 和 /api/core/status 的GET请求日志
|
||||
if 'GET /api/system/info' in msg or 'GET /api/core/status' in msg:
|
||||
return False
|
||||
return True
|
||||
|
||||
console.addFilter(filter_http_poll)
|
||||
root_logger.addHandler(console)
|
||||
|
||||
# Socket.IO处理器
|
||||
sio_handler = SocketIOLogHandler(sio_server)
|
||||
sio_handler.setLevel(logging.DEBUG)
|
||||
sio_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s | %(levelname)-8s | %(name)s - %(message)s'
|
||||
))
|
||||
root_logger.addHandler(sio_handler)
|
||||
|
||||
# 设置yosuga logger
|
||||
yosuga_logger = logging.getLogger("yosuga")
|
||||
yosuga_logger.setLevel(logging.DEBUG)
|
||||
|
||||
return root_logger
|
||||
|
||||
# 系统状态监控
|
||||
async def system_monitor_task():
|
||||
"""后台任务:定期采集并广播系统状态"""
|
||||
while True:
|
||||
try:
|
||||
# 只有有客户端连接时才采集数据
|
||||
if connected_clients:
|
||||
# 采集系统数据
|
||||
cpu = psutil.cpu_percent(interval=0.1)
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
proc = psutil.Process()
|
||||
|
||||
system_data = {
|
||||
"cpu": {"percent": cpu, "count": psutil.cpu_count()},
|
||||
"memory": {
|
||||
"total": mem.total, "available": mem.available,
|
||||
"percent": mem.percent, "used": mem.used, "free": mem.free
|
||||
},
|
||||
"disk": {
|
||||
"total": disk.total, "used": disk.used,
|
||||
"free": disk.free, "percent": (disk.used/disk.total)*100
|
||||
},
|
||||
"process": {
|
||||
"memory_percent": proc.memory_percent(),
|
||||
"cpu_percent": proc.cpu_percent(interval=0.1),
|
||||
"threads": proc.num_threads(),
|
||||
"uptime": time.time() - proc.create_time()
|
||||
},
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
# 广播给所有客户端
|
||||
await sio.emit('system_stats', {
|
||||
'success': True,
|
||||
'data': system_data
|
||||
})
|
||||
|
||||
# 同时广播核心状态(实时推送)
|
||||
core_status = get_core_status()
|
||||
await sio.emit('core_status', {
|
||||
'success': True,
|
||||
'data': core_status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"系统监控任务异常: {e}")
|
||||
|
||||
# 1秒间隔,比HTTP轮询更实时
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# FastAPI 应用
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期"""
|
||||
logger = setup_logging(sio)
|
||||
logger.info("Yosuga Server Web UI 启动")
|
||||
|
||||
monitor_task = asyncio.create_task(system_monitor_task())
|
||||
logger.info("系统监控任务已启动")
|
||||
|
||||
yield
|
||||
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 清理诊断模块
|
||||
try:
|
||||
diag = await get_diagnostics()
|
||||
# 诊断模块无需要关闭的资源,但保留钩子
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info("应用关闭")
|
||||
stop_core()
|
||||
fastapi_app = FastAPI(
|
||||
title="Yosuga Server Web UI",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
fastapi_app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@fastapi_app.post("/api/diagnostics/run")
|
||||
async def run_diagnostics():
|
||||
"""执行完整系统体检"""
|
||||
try:
|
||||
diag = await get_diagnostics()
|
||||
report = await diag.run_full_diagnostics()
|
||||
return report.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@fastapi_app.get("/api/diagnostics/check/{module}")
|
||||
async def check_single_module(module: str):
|
||||
"""检查单个模块状态"""
|
||||
try:
|
||||
from src.config.config import cfg
|
||||
diag = await get_diagnostics()
|
||||
|
||||
# 获取对应配置
|
||||
config_map = {
|
||||
'asr': cfg.asr,
|
||||
'tts': cfg.tts,
|
||||
'ai': cfg.ai,
|
||||
'auto_agent': cfg.auto_agent,
|
||||
'llm_core': cfg.llm_core
|
||||
}
|
||||
|
||||
if module not in config_map:
|
||||
raise HTTPException(status_code=400, detail=f"未知模块: {module}")
|
||||
|
||||
# 转换为dict
|
||||
config_dict = {}
|
||||
if hasattr(config_map[module], '__dataclass_fields__'):
|
||||
from dataclasses import asdict
|
||||
config_dict = asdict(config_map[module])
|
||||
else:
|
||||
config_dict = dict(config_map[module])
|
||||
|
||||
result = await diag.quick_check_module(module, config_dict)
|
||||
return {"success": True, "data": result.to_dict()}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@fastapi_app.get("/api/diagnostics/health")
|
||||
async def quick_health_check():
|
||||
"""快速健康检查(用于负载均衡/心跳)"""
|
||||
try:
|
||||
from src.server_view.backend.core_manager import get_status
|
||||
status = get_status()
|
||||
|
||||
# 简单检查WebSocket服务器是否活着
|
||||
ws_ok = len(connected_clients) >= 0 # 总是True,只要能响应
|
||||
|
||||
health = {
|
||||
"status": "healthy" if status.is_running else "degraded",
|
||||
"web_ui": "up",
|
||||
"core_running": status.is_running,
|
||||
"websocket_clients": len(connected_clients),
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
return health
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "unhealthy", "error": str(e)}
|
||||
|
||||
# Socket.IO ASGI应用
|
||||
app = socketio.ASGIApp(sio, fastapi_app)
|
||||
|
||||
# Socket.IO 事件
|
||||
@sio.event
|
||||
async def connect(sid, environ):
|
||||
"""客户端连接"""
|
||||
connected_clients.add(sid)
|
||||
print(f"客户端连接: {sid} (当前在线: {len(connected_clients)})")
|
||||
|
||||
# 立即发送一次当前状态(避免前端等待)
|
||||
await sio.emit('system', {
|
||||
'message': '连接成功',
|
||||
'timestamp': time.time(),
|
||||
'clients_count': len(connected_clients)
|
||||
}, to=sid)
|
||||
|
||||
# 立即推送一次系统状态(前端无需再发HTTP请求)
|
||||
try:
|
||||
core_status = get_core_status()
|
||||
await sio.emit('core_status', {
|
||||
'success': True,
|
||||
'data': core_status
|
||||
}, to=sid)
|
||||
except Exception as e:
|
||||
logging.error(f"推送初始状态失败: {e}")
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid):
|
||||
"""客户端断开"""
|
||||
connected_clients.discard(sid)
|
||||
print(f"客户端断开: {sid} (当前在线: {len(connected_clients)})")
|
||||
|
||||
@sio.on('subscribe_logs')
|
||||
async def handle_subscribe_logs(sid, data):
|
||||
"""订阅日志(可指定级别过滤)"""
|
||||
level = data.get('level', 'ALL') if isinstance(data, dict) else 'ALL'
|
||||
print(f"客户端 {sid} 订阅日志: {level}")
|
||||
await sio.emit('system', {'message': f'已订阅日志: {level}'}, to=sid)
|
||||
|
||||
@sio.on('control_core')
|
||||
async def handle_control_core(sid, data):
|
||||
"""WebSocket方式控制核心"""
|
||||
action = data.get('action') if isinstance(data, dict) else None
|
||||
|
||||
if action == 'start':
|
||||
try:
|
||||
# 检查是否已运行
|
||||
status = get_status()
|
||||
if status.is_running:
|
||||
await sio.emit('core_control_result', {
|
||||
'success': True,
|
||||
'message': '核心已在运行',
|
||||
'data': status.to_dict()
|
||||
}, to=sid)
|
||||
return
|
||||
|
||||
# 启动核心
|
||||
success, error = start_core(project_root)
|
||||
if success:
|
||||
# 等待一下确保启动成功
|
||||
await asyncio.sleep(0.5)
|
||||
new_status = get_status()
|
||||
# 广播给所有客户端(不仅仅是操作者)
|
||||
await sio.emit('core_status', {
|
||||
'success': True,
|
||||
'data': new_status.to_dict(),
|
||||
'message': '核心启动成功'
|
||||
})
|
||||
else:
|
||||
await sio.emit('core_control_result', {
|
||||
'success': False,
|
||||
'error': error or '启动失败'
|
||||
}, to=sid)
|
||||
except Exception as e:
|
||||
await sio.emit('core_control_result', {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, to=sid)
|
||||
|
||||
elif action == 'stop':
|
||||
try:
|
||||
status = get_status()
|
||||
if not status.is_running:
|
||||
await sio.emit('core_control_result', {
|
||||
'success': True,
|
||||
'message': '核心已停止',
|
||||
'data': status.to_dict()
|
||||
}, to=sid)
|
||||
return
|
||||
|
||||
success, error = stop_core()
|
||||
if success:
|
||||
await asyncio.sleep(0.5) # 等待停止完成
|
||||
new_status = get_status()
|
||||
await sio.emit('core_status', {
|
||||
'success': True,
|
||||
'data': new_status.to_dict(),
|
||||
'message': '核心已停止'
|
||||
})
|
||||
else:
|
||||
await sio.emit('core_control_result', {
|
||||
'success': False,
|
||||
'error': error or '停止失败'
|
||||
}, to=sid)
|
||||
except Exception as e:
|
||||
await sio.emit('core_control_result', {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, to=sid)
|
||||
|
||||
|
||||
# 设备管理 Socket.IO 事件
|
||||
@sio.on('get_devices')
|
||||
async def handle_get_devices(sid):
|
||||
"""获取当前所有在线设备列表"""
|
||||
try:
|
||||
from src.server_core.core import YosugaServerCore
|
||||
core = await YosugaServerCore.get_instance()
|
||||
devices = core.embedded_server.list_devices()
|
||||
await sio.emit('devices_list', {
|
||||
'success': True,
|
||||
'data': devices
|
||||
}, to=sid)
|
||||
except Exception as e:
|
||||
await sio.emit('devices_list', {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, to=sid)
|
||||
|
||||
@sio.on('send_device_rpc')
|
||||
async def handle_send_device_rpc(sid, data):
|
||||
"""向指定设备发送 RPC 命令"""
|
||||
device_id = data.get('device_id') if isinstance(data, dict) else None
|
||||
rpc_call = data.get('rpc_call') if isinstance(data, dict) else None
|
||||
if not device_id or not rpc_call:
|
||||
await sio.emit('device_rpc_result', {
|
||||
'success': False,
|
||||
'error': '缺少 device_id 或 rpc_call'
|
||||
}, to=sid)
|
||||
return
|
||||
try:
|
||||
from src.server_core.core import YosugaServerCore
|
||||
core = await YosugaServerCore.get_instance()
|
||||
|
||||
# 注册一次性的 RPC 响应转发回调
|
||||
global _rpc_forwarder_registered
|
||||
if not _rpc_forwarder_registered:
|
||||
async def forward_rpc_response(dev_id: str, payload: dict):
|
||||
await sio.emit('device_rpc_response', {
|
||||
'device_id': dev_id,
|
||||
'payload': payload
|
||||
})
|
||||
core.device_dto.on_rpc_response = forward_rpc_response
|
||||
_rpc_forwarder_registered = True
|
||||
|
||||
core.embedded_server.send_rpc(device_id, rpc_call)
|
||||
await sio.emit('device_rpc_result', {
|
||||
'success': True,
|
||||
'device_id': device_id,
|
||||
'message': 'RPC 命令已发送到设备'
|
||||
}, to=sid)
|
||||
except Exception as e:
|
||||
await sio.emit('device_rpc_result', {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, to=sid)
|
||||
|
||||
# 设备管理 REST API
|
||||
@fastapi_app.get("/api/devices")
|
||||
async def get_devices_api():
|
||||
"""获取所有在线设备(HTTP 备用)"""
|
||||
try:
|
||||
from src.server_core.core import YosugaServerCore
|
||||
core = await YosugaServerCore.get_instance()
|
||||
devices = core.embedded_server.list_devices()
|
||||
return {"success": True, "data": devices}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@sio.on('check_module_health')
|
||||
async def handle_check_module(sid, data):
|
||||
"""WebSocket方式检查模块健康"""
|
||||
module = data.get('module') if isinstance(data, dict) else None
|
||||
if not module:
|
||||
await sio.emit('module_health_result', {
|
||||
"success": False,
|
||||
"error": "未指定模块"
|
||||
}, to=sid)
|
||||
return
|
||||
|
||||
try:
|
||||
from src.config.config import cfg
|
||||
diag = await get_diagnostics()
|
||||
|
||||
config_map = {
|
||||
'asr': cfg.asr,
|
||||
'tts': cfg.tts,
|
||||
'ai': cfg.ai,
|
||||
'auto_agent': cfg.auto_agent,
|
||||
'llm_core': cfg.llm_core
|
||||
}
|
||||
|
||||
if module not in config_map:
|
||||
await sio.emit('module_health_result', {
|
||||
"success": False,
|
||||
"error": f"未知模块: {module}"
|
||||
}, to=sid)
|
||||
return
|
||||
|
||||
# 转换配置
|
||||
config_dict = {}
|
||||
if hasattr(config_map[module], '__dataclass_fields__'):
|
||||
from dataclasses import asdict
|
||||
config_dict = asdict(config_map[module])
|
||||
else:
|
||||
config_dict = dict(config_map[module])
|
||||
|
||||
result = await diag.quick_check_module(module, config_dict)
|
||||
|
||||
await sio.emit('module_health_result', {
|
||||
"success": True,
|
||||
"module": module,
|
||||
"data": result.to_dict()
|
||||
}, to=sid)
|
||||
|
||||
# 同时广播给所有客户端更新模块状态
|
||||
await sio.emit('module_status_update', {
|
||||
"module": module,
|
||||
"status": result.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await sio.emit('module_health_result', {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, to=sid)
|
||||
|
||||
# REST API
|
||||
@fastapi_app.get("/api/system/info")
|
||||
async def get_system_info():
|
||||
"""HTTP备用接口 - 减少日志"""
|
||||
try:
|
||||
cpu = psutil.cpu_percent(interval=0.1)
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
proc = psutil.Process()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"cpu": {"percent": cpu, "count": psutil.cpu_count()},
|
||||
"memory": {
|
||||
"total": mem.total, "available": mem.available,
|
||||
"percent": mem.percent, "used": mem.used, "free": mem.free
|
||||
},
|
||||
"disk": {
|
||||
"total": disk.total, "used": disk.used,
|
||||
"free": disk.free, "percent": (disk.used/disk.total)*100
|
||||
},
|
||||
"process": {
|
||||
"memory_percent": proc.memory_percent(),
|
||||
"cpu_percent": proc.cpu_percent(interval=0.1),
|
||||
"threads": proc.num_threads(),
|
||||
"uptime": time.time() - proc.create_time()
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@fastapi_app.get("/api/core/status")
|
||||
async def get_core_status_api():
|
||||
"""HTTP备用接口"""
|
||||
return {"success": True, "data": get_core_status()}
|
||||
|
||||
@fastapi_app.post("/api/core/start")
|
||||
async def start_core_api():
|
||||
"""HTTP备用接口"""
|
||||
try:
|
||||
status = get_status()
|
||||
if status.is_running:
|
||||
return {"success": True, "data": status.to_dict(), "message": "核心已在运行"}
|
||||
|
||||
success, error = start_core(project_root)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=error or "启动失败")
|
||||
|
||||
# 等待初始化完成
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.5)
|
||||
status = get_status()
|
||||
if status.is_running:
|
||||
# 通过WebSocket广播状态更新(给所有连接的客户端)
|
||||
await sio.emit('core_status', {
|
||||
'success': True,
|
||||
'data': status.to_dict(),
|
||||
'message': '核心启动成功'
|
||||
})
|
||||
return {"success": True, "data": status.to_dict(), "message": "核心启动成功"}
|
||||
|
||||
status = get_status()
|
||||
return {"success": True, "data": status.to_dict(), "message": "核心启动中..."}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
status = get_status()
|
||||
if status.is_running:
|
||||
return {"success": True, "data": status.to_dict(), "message": "核心已启动"}
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@fastapi_app.post("/api/core/stop")
|
||||
async def stop_core_api():
|
||||
"""HTTP备用接口"""
|
||||
try:
|
||||
status = get_status()
|
||||
if not status.is_running:
|
||||
return {"success": True, "data": status.to_dict(), "message": "核心已停止"}
|
||||
|
||||
success, error = stop_core()
|
||||
if success:
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.5)
|
||||
status = get_status()
|
||||
if not status.is_running:
|
||||
# 广播状态更新
|
||||
await sio.emit('core_status', {
|
||||
'success': True,
|
||||
'data': status.to_dict(),
|
||||
'message': '核心已停止'
|
||||
})
|
||||
return {"success": True, "data": status.to_dict(), "message": "核心已停止"}
|
||||
|
||||
status = get_status()
|
||||
return {"success": True, "data": status.to_dict(), "message": error or "核心停止中..."}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=error or "停止失败")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@fastapi_app.get("/api/modules/status")
|
||||
async def get_modules_status():
|
||||
"""模块状态"""
|
||||
try:
|
||||
from src.config.config import cfg
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"asr": {"enabled": cfg.asr.enabled},
|
||||
"tts": {"enabled": cfg.tts.enabled},
|
||||
"ai": {"enabled": cfg.ai.api_key is not None},
|
||||
"auto_agent": {"enabled": cfg.auto_agent.enabled},
|
||||
"llm_core": {"enabled": cfg.llm_core.enabled}
|
||||
}
|
||||
}
|
||||
except:
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"asr": {"enabled": True}, "tts": {"enabled": True},
|
||||
"ai": {"enabled": True}, "auto_agent": {"enabled": True},
|
||||
"llm_core": {"enabled": True}
|
||||
}
|
||||
}
|
||||
|
||||
@fastapi_app.get("/api/config")
|
||||
async def get_config():
|
||||
from src.config.config import cfg
|
||||
return {"success": True, "data": cfg.to_dict()}
|
||||
|
||||
@fastapi_app.post("/api/config/{section}")
|
||||
async def update_config(section: str, data: Dict[str, Any]):
|
||||
from src.config.config import cfg
|
||||
cfg.update({section: data})
|
||||
return {"success": True, "message": "配置已更新"}
|
||||
|
||||
@fastapi_app.post("/api/config/reload")
|
||||
async def reload_config():
|
||||
from src.config.config import cfg
|
||||
cfg.reload()
|
||||
return {"success": True, "message": "配置已重载"}
|
||||
|
||||
@fastapi_app.get("/api/preferences")
|
||||
async def get_preferences():
|
||||
prefs_path = Path(__file__).parent / "user_preferences.json"
|
||||
if prefs_path.exists():
|
||||
with open(prefs_path, 'r', encoding='utf-8') as f:
|
||||
return {"success": True, "data": json.load(f)}
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
@fastapi_app.post("/api/preferences")
|
||||
async def save_preferences(data: Dict[str, Any]):
|
||||
prefs_path = Path(__file__).parent / "user_preferences.json"
|
||||
with open(prefs_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return {"success": True, "message": "偏好已保存"}
|
||||
|
||||
# 静态文件
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
fastapi_app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
|
||||
def run_server(host: str = "0.0.0.0", port: int = 8089, debug: bool = False):
|
||||
import uvicorn
|
||||
uvicorn.run("backend.app:app", host=host, port=port, reload=debug,
|
||||
access_log=False) # 禁用默认访问日志(我们使用自定义过滤器)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Yosuga Server 核心进程管理器
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 全局状态
|
||||
_core_instance: Optional[Any] = None
|
||||
_core_thread: Optional[threading.Thread] = None
|
||||
_core_start_time: Optional[float] = None
|
||||
_core_stop_event: threading.Event = threading.Event() # 停止信号
|
||||
_core_lock = threading.Lock()
|
||||
_logger: Optional[logging.Logger] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoreStatus:
|
||||
is_running: bool = False
|
||||
pid: int = 0
|
||||
uptime: float = 0.0
|
||||
error: Optional[str] = None
|
||||
thread_alive: bool = False
|
||||
start_time: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"pid": self.pid,
|
||||
"uptime": round(self.uptime, 1),
|
||||
"error": self.error,
|
||||
"thread_alive": self.thread_alive,
|
||||
"start_time": self.start_time
|
||||
}
|
||||
|
||||
import os
|
||||
def get_status() -> CoreStatus:
|
||||
"""获取当前核心状态"""
|
||||
global _core_thread, _core_start_time, _core_instance
|
||||
|
||||
with _core_lock:
|
||||
status = CoreStatus()
|
||||
|
||||
if _core_thread is not None:
|
||||
status.thread_alive = _core_thread.is_alive()
|
||||
status.pid = os.getpid()
|
||||
|
||||
if _core_start_time and (status.thread_alive or _core_instance is not None):
|
||||
status.uptime = time.time() - _core_start_time
|
||||
status.is_running = True
|
||||
status.start_time = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(_core_start_time))
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def _setup_loguru_logging(log_queue: Optional[Any] = None):
|
||||
"""配置loguru,同时转发到标准logging以便Socket.IO捕获"""
|
||||
try:
|
||||
from loguru import logger
|
||||
import logging
|
||||
|
||||
logger.remove()
|
||||
|
||||
# 关键:添加一个sink将loguru日志转发到标准logging
|
||||
class LoguruToStandard:
|
||||
def write(self, message):
|
||||
# 解析loguru格式提取level
|
||||
record = message.record
|
||||
level = record["level"].name
|
||||
# 获取标准logger并发送
|
||||
std_logger = logging.getLogger("yosuga")
|
||||
if level == "DEBUG":
|
||||
std_logger.debug(record["message"])
|
||||
elif level == "INFO":
|
||||
std_logger.info(record["message"])
|
||||
elif level == "SUCCESS":
|
||||
std_logger.info(record["message"])
|
||||
elif level == "WARNING":
|
||||
std_logger.warning(record["message"])
|
||||
elif level == "ERROR":
|
||||
std_logger.error(record["message"])
|
||||
elif level == "CRITICAL":
|
||||
std_logger.critical(record["message"])
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
# 添加转发处理器
|
||||
logger.add(LoguruToStandard(), format="{message}")
|
||||
|
||||
# 文件日志
|
||||
from src.config.config import cfg
|
||||
log_dir = Path(cfg.log_dir)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.add(
|
||||
f"{log_dir}/Yosuga_server-{{time:YYYY-MM-DD_HH-mm-ss}}.log",
|
||||
encoding="utf-8",
|
||||
rotation="100 MB"
|
||||
)
|
||||
|
||||
return logger
|
||||
except Exception as e:
|
||||
print(f"Loguru配置错误: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _run_core_thread(project_root: Path):
|
||||
"""在独立线程中运行YosugaServerCore"""
|
||||
global _core_instance, _core_start_time, _core_stop_event, _logger
|
||||
|
||||
_core_stop_event.clear()
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from src.server_core.core import YosugaServerCore
|
||||
from src.config.config import cfg
|
||||
|
||||
# 配置loguru并获取logger
|
||||
logger = _setup_loguru_logging()
|
||||
if logger:
|
||||
logger.info("Yosuga_server 在线程中启动")
|
||||
_logger = logger
|
||||
|
||||
async def run_core():
|
||||
global _core_instance
|
||||
try:
|
||||
_core_instance = await YosugaServerCore.get_instance()
|
||||
|
||||
if logger:
|
||||
logger.success(f"YosugaServerCore 初始化完成,线程ID: {threading.current_thread().ident}")
|
||||
|
||||
# 运行核心,同时检查停止信号
|
||||
core_task = asyncio.create_task(_core_instance.run())
|
||||
|
||||
# 等待任务完成或收到停止信号
|
||||
while not core_task.done():
|
||||
if _core_stop_event.is_set():
|
||||
core_task.cancel()
|
||||
try:
|
||||
await core_task
|
||||
except asyncio.CancelledError:
|
||||
if logger:
|
||||
logger.info("核心收到停止信号,正在关闭...")
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if logger:
|
||||
logger.info("核心事件循环已结束")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if logger:
|
||||
logger.info("核心任务已取消")
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.exception(f"核心运行异常: {e}")
|
||||
raise
|
||||
|
||||
# 运行异步核心
|
||||
asyncio.run(run_core())
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"核心线程异常: {str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg, file=sys.stderr)
|
||||
if _logger:
|
||||
_logger.error(error_msg)
|
||||
finally:
|
||||
_core_instance = None
|
||||
if logger:
|
||||
logger.info("核心线程已退出")
|
||||
|
||||
|
||||
def start_core(project_root: Path) -> tuple[bool, Optional[str]]:
|
||||
"""启动Yosuga核心"""
|
||||
global _core_thread, _core_start_time, _core_stop_event
|
||||
|
||||
with _core_lock:
|
||||
if _core_thread is not None and _core_thread.is_alive():
|
||||
return True, None # 已经在运行
|
||||
|
||||
_core_start_time = None
|
||||
_core_stop_event.clear()
|
||||
|
||||
try:
|
||||
_core_thread = threading.Thread(
|
||||
target=_run_core_thread,
|
||||
args=(project_root,),
|
||||
name="YosugaServerCore",
|
||||
daemon=True
|
||||
)
|
||||
_core_thread.start()
|
||||
_core_start_time = time.time() # 立即记录
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if not _core_thread.is_alive():
|
||||
_core_start_time = None
|
||||
return False, "核心线程未能启动"
|
||||
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
_core_start_time = None
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def stop_core() -> tuple[bool, Optional[str]]:
|
||||
"""停止Yosuga核心"""
|
||||
global _core_thread, _core_start_time, _core_stop_event
|
||||
|
||||
with _core_lock:
|
||||
if _core_thread is None or not _core_thread.is_alive():
|
||||
_core_thread = None
|
||||
_core_start_time = None
|
||||
return True, None # 已经停止
|
||||
|
||||
try:
|
||||
# 发送停止信号
|
||||
_core_stop_event.set()
|
||||
|
||||
# 等待线程结束(带超时)
|
||||
_core_thread.join(timeout=10.0)
|
||||
|
||||
was_alive = _core_thread.is_alive()
|
||||
_core_thread = None
|
||||
|
||||
if was_alive:
|
||||
# 线程还在运行,但已经发送了停止信号
|
||||
# 由于daemon=True,主进程退出时会强制终止
|
||||
_core_start_time = None
|
||||
return True, "核心停止信号已发送,正在后台停止"
|
||||
|
||||
_core_start_time = None
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# 兼容旧接口
|
||||
def get_core_status() -> Dict[str, Any]:
|
||||
return get_status().to_dict()
|
||||
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
Yosuga Server 系统诊断模块 - TCP端口连通性版本
|
||||
生产级健康检查与自检工具(不依赖HTTP接口)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from urllib.parse import urlparse
|
||||
import psutil
|
||||
from loguru import logger
|
||||
|
||||
class HealthStatus(Enum):
|
||||
HEALTHY = "healthy"
|
||||
UNHEALTHY = "unhealthy"
|
||||
UNKNOWN = "unknown"
|
||||
CHECKING = "checking"
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""单项检查结果"""
|
||||
name: str
|
||||
status: HealthStatus
|
||||
message: str
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
latency_ms: float = 0.0
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"status": self.status.value,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
"latency_ms": round(self.latency_ms, 2),
|
||||
"timestamp": self.timestamp
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class DiagnosticsReport:
|
||||
"""完整诊断报告"""
|
||||
overall_status: HealthStatus
|
||||
checks: List[CheckResult]
|
||||
summary: Dict[str, int]
|
||||
generated_at: float = field(default_factory=time.time)
|
||||
version: str = "1.1.0"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"overall_status": self.overall_status.value,
|
||||
"checks": [c.to_dict() for c in self.checks],
|
||||
"summary": self.summary,
|
||||
"generated_at": self.generated_at,
|
||||
"version": self.version
|
||||
}
|
||||
|
||||
class SystemDiagnostics:
|
||||
"""系统诊断核心类 - TCP端口连通性检测"""
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
self.config_path = config_path or self._find_config_path()
|
||||
self._timeout_seconds = 3
|
||||
|
||||
def _find_config_path(self) -> Path:
|
||||
"""自动查找配置文件路径"""
|
||||
markers = ['settings.json', 'pyproject.toml']
|
||||
current = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
for path in [current, *current.parents]:
|
||||
if (path / 'settings.json').exists():
|
||||
return path / 'settings.json'
|
||||
if path == path.parent:
|
||||
break
|
||||
return current / 'settings.json'
|
||||
|
||||
async def _check_tcp_port(self, host: str, port: int) -> Tuple[bool, float, Optional[str]]:
|
||||
"""
|
||||
基础TCP端口连通性检查
|
||||
|
||||
Returns:
|
||||
(是否连通, 延迟ms, 错误信息)
|
||||
"""
|
||||
start = time.time()
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=self._timeout_seconds
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
latency = (time.time() - start) * 1000
|
||||
return True, latency, None
|
||||
except asyncio.TimeoutError:
|
||||
return False, self._timeout_seconds * 1000, "连接超时"
|
||||
except ConnectionRefusedError:
|
||||
return False, (time.time() - start) * 1000, "连接被拒绝"
|
||||
except Exception as e:
|
||||
return False, (time.time() - start) * 1000, str(e)
|
||||
|
||||
def _parse_url(self, url: str) -> Tuple[str, int]:
|
||||
"""
|
||||
从URL解析主机和端口
|
||||
支持 http://host:port/path 格式
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or 'localhost'
|
||||
if parsed.port:
|
||||
port = parsed.port
|
||||
elif parsed.scheme == 'https':
|
||||
port = 443
|
||||
elif parsed.scheme == 'http':
|
||||
port = 80
|
||||
else:
|
||||
port = 80
|
||||
return host, port
|
||||
except Exception:
|
||||
if ':' in url:
|
||||
parts = url.split(':')
|
||||
if len(parts) >= 2:
|
||||
last_part = parts[-1].split('/')[0]
|
||||
try:
|
||||
return parts[-2].replace('//', ''), int(last_part)
|
||||
except:
|
||||
pass
|
||||
return 'localhost', 80
|
||||
|
||||
async def check_asr(self, url: str = "http://localhost:20260") -> CheckResult:
|
||||
"""检查ASR服务 - TCP端口连通性"""
|
||||
host, port = self._parse_url(url)
|
||||
if port == 80 and '20260' in url:
|
||||
port = 20260
|
||||
|
||||
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||
|
||||
if is_open:
|
||||
return CheckResult(
|
||||
name="ASR服务",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message=f"端口可连通 {host}:{port}",
|
||||
details={"host": host, "port": port, "protocol": "TCP"},
|
||||
latency_ms=latency
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="ASR服务",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"端口不可达 {host}:{port} - {error}",
|
||||
details={"host": host, "port": port, "error": error},
|
||||
latency_ms=latency
|
||||
)
|
||||
|
||||
async def check_tts(self, host: str = "localhost", port: int = 20261) -> CheckResult:
|
||||
"""检查TTS服务 - TCP端口连通性"""
|
||||
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||
|
||||
if is_open:
|
||||
return CheckResult(
|
||||
name="TTS服务",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message=f"端口可连通 {host}:{port}",
|
||||
details={"host": host, "port": port},
|
||||
latency_ms=latency
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="TTS服务",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"端口不可达 {host}:{port} - {error}",
|
||||
details={"host": host, "port": port, "error": error},
|
||||
latency_ms=latency
|
||||
)
|
||||
|
||||
async def check_ai_service(self, base_url: str, api_key: Optional[str] = None) -> CheckResult:
|
||||
"""检查AI服务 - TCP端口连通性"""
|
||||
host, port = self._parse_url(base_url)
|
||||
|
||||
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||
|
||||
if is_open:
|
||||
return CheckResult(
|
||||
name="AI服务",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message=f"端口可连通 {host}:{port}",
|
||||
details={"host": host, "port": port, "base_url": base_url},
|
||||
latency_ms=latency
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="AI服务",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"端口不可达 {host}:{port} - {error}",
|
||||
details={"host": host, "port": port, "error": error},
|
||||
latency_ms=latency
|
||||
)
|
||||
|
||||
async def check_auto_agent(self, base_url: str) -> CheckResult:
|
||||
"""检查Auto Agent服务 - TCP端口连通性"""
|
||||
host, port = self._parse_url(base_url)
|
||||
|
||||
is_open, latency, error = await self._check_tcp_port(host, port)
|
||||
|
||||
if is_open:
|
||||
return CheckResult(
|
||||
name="自动代理服务",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message=f"端口可连通 {host}:{port}",
|
||||
details={"host": host, "port": port},
|
||||
latency_ms=latency
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="自动代理服务",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"端口不可达 {host}:{port} - {error}",
|
||||
details={"host": host, "port": port, "error": error},
|
||||
latency_ms=latency
|
||||
)
|
||||
|
||||
async def check_config_file(self) -> CheckResult:
|
||||
"""检查配置文件合法性"""
|
||||
try:
|
||||
if not self.config_path.exists():
|
||||
return CheckResult(
|
||||
name="配置文件",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"配置文件不存在: {self.config_path}"
|
||||
)
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
config = json.loads(content)
|
||||
|
||||
required_sections = ['ai', 'tts', 'asr']
|
||||
missing = [s for s in required_sections if s not in config]
|
||||
|
||||
if missing:
|
||||
return CheckResult(
|
||||
name="配置文件",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"缺少配置节: {', '.join(missing)}",
|
||||
details={"missing_sections": missing}
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="配置文件",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message=f"配置合法,含 {len(config)} 个配置节",
|
||||
details={"sections": list(config.keys())}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
return CheckResult(
|
||||
name="配置文件",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"JSON格式错误: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
name="配置文件",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"读取失败: {str(e)}"
|
||||
)
|
||||
|
||||
async def check_model_files(self, config: Optional[Dict] = None) -> CheckResult:
|
||||
"""检查模型文件存在性"""
|
||||
try:
|
||||
if config is None:
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
checks = {}
|
||||
missing = []
|
||||
project_root = self.config_path.parent
|
||||
|
||||
if 'tts' in config:
|
||||
tts = config['tts']
|
||||
gpt_path = tts.get('gpt_model_name', '')
|
||||
sovits_path = tts.get('sovits_model_name', '')
|
||||
|
||||
if gpt_path:
|
||||
full_path = project_root / gpt_path
|
||||
exists = full_path.exists()
|
||||
checks['gpt_model'] = {"path": str(full_path), "exists": exists}
|
||||
if not exists:
|
||||
missing.append(f"GPT模型: {gpt_path}")
|
||||
|
||||
if sovits_path:
|
||||
full_path = project_root / sovits_path
|
||||
exists = full_path.exists()
|
||||
checks['sovits_model'] = {"path": str(full_path), "exists": exists}
|
||||
if not exists:
|
||||
missing.append(f"SoVITS模型: {sovits_path}")
|
||||
|
||||
if missing:
|
||||
return CheckResult(
|
||||
name="模型文件",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"缺少 {len(missing)} 个模型文件",
|
||||
details={"missing": missing, "checks": checks}
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="模型文件",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="所有配置模型文件已找到",
|
||||
details={"checks": checks}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
name="模型文件",
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message=f"检查失败: {str(e)}"
|
||||
)
|
||||
|
||||
async def check_ports(self, ports: Optional[List[int]] = None) -> CheckResult:
|
||||
"""检查关键端口占用情况"""
|
||||
if ports is None:
|
||||
ports = [8089, 20260, 20261, 8765]
|
||||
|
||||
try:
|
||||
current_pid = psutil.Process().pid
|
||||
current_ports = set()
|
||||
|
||||
proc = psutil.Process(current_pid)
|
||||
for conn in proc.connections(kind='inet'):
|
||||
if conn.status == 'LISTEN':
|
||||
current_ports.add(conn.lport)
|
||||
|
||||
occupied_by_others = []
|
||||
for port in ports:
|
||||
if port in current_ports:
|
||||
continue
|
||||
|
||||
for p in psutil.process_iter(['pid', 'name']):
|
||||
try:
|
||||
for conn in p.connections(kind='inet'):
|
||||
if conn.lport == port:
|
||||
occupied_by_others.append({
|
||||
"port": port,
|
||||
"pid": p.pid,
|
||||
"name": p.name()
|
||||
})
|
||||
break
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
if occupied_by_others:
|
||||
return CheckResult(
|
||||
name="端口占用",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"{len(occupied_by_others)} 个端口被占用",
|
||||
details={"conflicts": occupied_by_others}
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="端口占用",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="关键端口可用",
|
||||
details={"checked_ports": list(ports), "self_ports": list(current_ports)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
name="端口占用",
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message=f"检查失败: {str(e)}"
|
||||
)
|
||||
|
||||
async def run_full_diagnostics(self) -> DiagnosticsReport:
|
||||
"""执行完整系统体检"""
|
||||
logger.info("开始系统体检(TCP模式)...")
|
||||
checks = []
|
||||
|
||||
checks.append(await self.check_config_file())
|
||||
checks.append(await self.check_ports())
|
||||
|
||||
service_results = await self._check_services_tcp()
|
||||
checks.extend(service_results)
|
||||
|
||||
checks.append(await self.check_model_files())
|
||||
|
||||
summary = {
|
||||
"healthy": sum(1 for c in checks if c.status == HealthStatus.HEALTHY),
|
||||
"unhealthy": sum(1 for c in checks if c.status == HealthStatus.UNHEALTHY),
|
||||
"unknown": sum(1 for c in checks if c.status == HealthStatus.UNKNOWN),
|
||||
"total": len(checks)
|
||||
}
|
||||
|
||||
if summary["unhealthy"] == 0:
|
||||
overall = HealthStatus.HEALTHY
|
||||
elif summary["unhealthy"] <= summary["healthy"]:
|
||||
overall = HealthStatus.UNKNOWN
|
||||
else:
|
||||
overall = HealthStatus.UNHEALTHY
|
||||
|
||||
report = DiagnosticsReport(
|
||||
overall_status=overall,
|
||||
checks=checks,
|
||||
summary=summary
|
||||
)
|
||||
|
||||
logger.info(f"体检完成: {summary['healthy']}/{summary['total']} 项正常")
|
||||
return report
|
||||
|
||||
async def _check_services_tcp(self) -> List[CheckResult]:
|
||||
"""检查各项服务 - 纯TCP连通性"""
|
||||
results = []
|
||||
|
||||
try:
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
if 'asr' in config:
|
||||
asr_url = config['asr'].get('url', 'http://localhost:20260')
|
||||
results.append(await self.check_asr(asr_url))
|
||||
else:
|
||||
results.append(CheckResult(
|
||||
name="ASR服务",
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message="配置中未启用ASR"
|
||||
))
|
||||
|
||||
if 'tts' in config:
|
||||
tts_cfg = config['tts']
|
||||
results.append(await self.check_tts(
|
||||
tts_cfg.get('host', 'localhost'),
|
||||
tts_cfg.get('port', 20261)
|
||||
))
|
||||
else:
|
||||
results.append(CheckResult(
|
||||
name="TTS服务",
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message="配置中未启用TTS"
|
||||
))
|
||||
|
||||
if 'ai' in config:
|
||||
ai_cfg = config['ai']
|
||||
results.append(await self.check_ai_service(
|
||||
ai_cfg.get('base_url', 'http://localhost:1234/v1')
|
||||
))
|
||||
else:
|
||||
results.append(CheckResult(
|
||||
name="AI服务",
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message="配置中未启用AI"
|
||||
))
|
||||
|
||||
if 'auto_agent' in config:
|
||||
aa_cfg = config['auto_agent']
|
||||
results.append(await self.check_auto_agent(
|
||||
aa_cfg.get('base_url', 'http://localhost:1234/v1')
|
||||
))
|
||||
else:
|
||||
results.append(CheckResult(
|
||||
name="自动代理服务",
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message="配置中未启用自动代理"
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"服务检查失败: {e}")
|
||||
results.append(CheckResult(
|
||||
name="服务检查",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message=f"配置加载失败: {str(e)}"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
async def quick_check_module(self, module_name: str, config: Dict) -> CheckResult:
|
||||
"""快速检查单个模块 - TCP连通性"""
|
||||
if module_name == "asr":
|
||||
url = config.get('url', 'http://localhost:20260')
|
||||
return await self.check_asr(url)
|
||||
elif module_name == "tts":
|
||||
return await self.check_tts(
|
||||
config.get('host', 'localhost'),
|
||||
config.get('port', 20261)
|
||||
)
|
||||
elif module_name == "ai":
|
||||
return await self.check_ai_service(
|
||||
config.get('base_url', 'http://localhost:1234/v1')
|
||||
)
|
||||
elif module_name == "auto_agent":
|
||||
return await self.check_auto_agent(
|
||||
config.get('base_url', 'http://localhost:1234/v1')
|
||||
)
|
||||
elif module_name == "llm_core":
|
||||
from src.server_view.backend.core_manager import get_status
|
||||
status = get_status()
|
||||
if status.is_running:
|
||||
return CheckResult(
|
||||
name="LLM核心",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="核心进程运行中",
|
||||
details={"uptime": status.uptime, "pid": status.pid}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="LLM核心",
|
||||
status=HealthStatus.UNHEALTHY,
|
||||
message="核心进程未启动"
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name=module_name,
|
||||
status=HealthStatus.UNKNOWN,
|
||||
message="未知模块"
|
||||
)
|
||||
|
||||
_diagnostics_instance: Optional[SystemDiagnostics] = None
|
||||
|
||||
async def get_diagnostics() -> SystemDiagnostics:
|
||||
"""获取诊断实例(单例)"""
|
||||
global _diagnostics_instance
|
||||
if _diagnostics_instance is None:
|
||||
_diagnostics_instance = SystemDiagnostics()
|
||||
return _diagnostics_instance
|
||||
@@ -0,0 +1,21 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# from ide
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv
|
||||
|
||||
node_modules
|
||||
node_modules/
|
||||
dist
|
||||
dist/
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Yosuga Server - 管理界面</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "yosuga-server-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"axios": "^1.6.0",
|
||||
"echarts": "^5.4.3",
|
||||
"naive-ui": "^2.35.0",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"vue": "^3.3.8",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<n-config-provider :theme="currentTheme" :theme-overrides="themeOverrides">
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-message-provider>
|
||||
<n-layout class="layout" position="absolute" has-sider>
|
||||
<!-- 侧边栏 -->
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="240"
|
||||
:collapsed="prefs.preferences.sidebarCollapsed"
|
||||
show-trigger
|
||||
@collapse="prefs.toggleSidebar()"
|
||||
@expand="prefs.toggleSidebar()"
|
||||
>
|
||||
<div class="logo">
|
||||
<n-icon size="32" color="#18a058">
|
||||
<logo-icon />
|
||||
</n-icon>
|
||||
<span v-if="!prefs.preferences.sidebarCollapsed" class="logo-text">Yosuga Server</span>
|
||||
</div>
|
||||
|
||||
<n-menu
|
||||
:collapsed="prefs.preferences.sidebarCollapsed"
|
||||
:collapsed-width="64"
|
||||
:collapsed-icon-size="22"
|
||||
:options="menuOptions"
|
||||
:value="activeKey"
|
||||
@update:value="handleMenuUpdate"
|
||||
/>
|
||||
</n-layout-sider>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="header">
|
||||
<div class="header-left">
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item>Yosuga</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ pageTitle }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 主题切换 -->
|
||||
<n-dropdown :options="themeOptions" @select="handleThemeSelect">
|
||||
<n-button circle>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<sunny-icon v-if="prefs.preferences.theme === 'light'" />
|
||||
<moon-icon v-else-if="prefs.preferences.theme === 'dark'" />
|
||||
<contrast-icon v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
|
||||
<n-badge :value="wsStatus === 'connected' ? 'Live' : 'Offline'"
|
||||
:type="wsStatus === 'connected' ? 'success' : 'error'">
|
||||
<n-button circle>
|
||||
<template #icon>
|
||||
<n-icon><wifi-icon /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-badge>
|
||||
</div>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { darkTheme, lightTheme, type GlobalTheme, type DropdownOption } from 'naive-ui'
|
||||
import {
|
||||
HomeOutline as HomeIcon,
|
||||
SettingsOutline as SettingsIcon,
|
||||
TerminalOutline as TerminalIcon,
|
||||
StatsChartOutline as StatsIcon,
|
||||
HardwareChipOutline as DeviceIcon,
|
||||
SunnyOutline as SunnyIcon,
|
||||
MoonOutline as MoonIcon,
|
||||
WifiOutline as WifiIcon,
|
||||
PulseOutline as LogoIcon,
|
||||
ContrastOutline as ContrastIcon
|
||||
} from '@vicons/ionicons5'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import { usePreferencesStore } from '@/stores/preferences'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { NIcon } from 'naive-ui'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const wsStore = useWebSocketStore()
|
||||
const { status: wsStatus } = storeToRefs(wsStore)
|
||||
const prefs = usePreferencesStore()
|
||||
|
||||
const activeKey = computed(() => route.name as string)
|
||||
const pageTitle = computed(() => route.meta?.title as string || 'Dashboard')
|
||||
|
||||
// 主题计算
|
||||
const currentTheme = computed<GlobalTheme | null>(() => {
|
||||
if (prefs.preferences.theme === 'dark') return darkTheme
|
||||
if (prefs.preferences.theme === 'light') return lightTheme
|
||||
return null // auto 时由 CSS 处理
|
||||
})
|
||||
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#18a058',
|
||||
primaryColorHover: '#36ad6a',
|
||||
primaryColorPressed: '#0c7a43',
|
||||
}
|
||||
}
|
||||
|
||||
// 主题选项
|
||||
const themeOptions: DropdownOption[] = [
|
||||
{ label: '浅色模式', key: 'light', icon: () => h(NIcon, null, { default: () => h(SunnyIcon) }) },
|
||||
{ label: '深色模式', key: 'dark', icon: () => h(NIcon, null, { default: () => h(MoonIcon) }) },
|
||||
{ label: '跟随系统', key: 'auto', icon: () => h(NIcon, null, { default: () => h(ContrastIcon) }) }
|
||||
]
|
||||
|
||||
const renderIcon = (icon: any) => {
|
||||
return () => h(NIcon, null, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
const menuOptions = [
|
||||
{ label: '仪表板', key: 'dashboard', icon: renderIcon(HomeIcon) },
|
||||
{ label: '系统监控', key: 'monitor', icon: renderIcon(StatsIcon) },
|
||||
{ label: '设备管理', key: 'devices', icon: renderIcon(DeviceIcon) },
|
||||
{ label: '配置管理', key: 'config', icon: renderIcon(SettingsIcon) },
|
||||
{ label: '日志查看', key: 'logs', icon: renderIcon(TerminalIcon) }
|
||||
]
|
||||
|
||||
const handleMenuUpdate = (key: string) => {
|
||||
prefs.preferences.lastVisitedPage = key
|
||||
router.push({ name: key })
|
||||
}
|
||||
|
||||
const handleThemeSelect = (key: string) => {
|
||||
prefs.setTheme(key)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
prefs.loadPreferences()
|
||||
wsStore.connect()
|
||||
|
||||
// 恢复上次访问的页面
|
||||
if (prefs.preferences.lastVisitedPage && prefs.preferences.lastVisitedPage !== 'dashboard') {
|
||||
router.push({ name: prefs.preferences.lastVisitedPage })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局主题 CSS 变量 */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* 深色模式自动适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--n-border-color);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--n-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
background-color: var(--n-color);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<n-card class="core-control" :bordered="false">
|
||||
<n-space align="center" justify="space-between">
|
||||
<n-space align="center">
|
||||
<n-badge
|
||||
dot
|
||||
:type="isRunning ? 'success' : 'error'"
|
||||
:processing="isLoading"
|
||||
>
|
||||
<n-icon size="28" :color="getStatusColor">
|
||||
<pulse-icon />
|
||||
</n-icon>
|
||||
</n-badge>
|
||||
<div>
|
||||
<div class="core-title">
|
||||
Yosuga 核心
|
||||
<n-tag v-if="isRunning" type="success" size="small" style="margin-left: 8px">
|
||||
运行中
|
||||
</n-tag>
|
||||
<n-tag v-else-if="hasError" type="error" size="small" style="margin-left: 8px">
|
||||
启动失败
|
||||
</n-tag>
|
||||
<n-tag v-else type="default" size="small" style="margin-left: 8px">
|
||||
已停止
|
||||
</n-tag>
|
||||
</div>
|
||||
<div class="core-status">
|
||||
<span v-if="isRunning">
|
||||
PID: {{ coreStatus?.pid || 0 }} |
|
||||
运行时间: {{ formatUptime(coreStatus?.uptime || 0) }} |
|
||||
线程{{ coreStatus?.thread_alive ? '存活' : '异常' }}
|
||||
</span>
|
||||
<span v-else-if="hasError" class="error-text">
|
||||
错误: {{ coreStatus?.error }}
|
||||
</span>
|
||||
<span v-else>点击启动按钮开始运行</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
|
||||
<n-space>
|
||||
<!-- 启动按钮 -->
|
||||
<n-button
|
||||
v-if="!isRunning && !isLoading"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleStart"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><play-icon /></n-icon>
|
||||
</template>
|
||||
启动核心
|
||||
</n-button>
|
||||
|
||||
<!-- 停止按钮 -->
|
||||
<n-button
|
||||
v-if="isRunning"
|
||||
type="error"
|
||||
size="large"
|
||||
:loading="isLoading"
|
||||
@click="handleStop"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><stop-icon /></n-icon>
|
||||
</template>
|
||||
停止核心
|
||||
</n-button>
|
||||
|
||||
<!-- 刷新按钮(现在主要用于强制刷新HTTP备用) -->
|
||||
<n-button circle size="large" :loading="isLoading" @click="refreshStatus">
|
||||
<template #icon>
|
||||
<n-icon><refresh-icon /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<!-- WebSocket连接状态指示器 -->
|
||||
<n-divider />
|
||||
<n-space align="center" justify="space-between" size="small">
|
||||
<n-space align="center" size="small">
|
||||
<n-badge :type="wsStore.status === 'connected' ? 'success' : 'error'" dot />
|
||||
<span style="font-size: 12px; color: var(--n-text-color-3)">
|
||||
{{ wsStore.status === 'connected' ? '实时连接正常' : '实时连接断开' }}
|
||||
<span v-if="wsStore.latency > 0">({{ wsStore.latency }}ms)</span>
|
||||
</span>
|
||||
</n-space>
|
||||
|
||||
<n-button v-if="wsStore.status !== 'connected'" text type="primary" size="small" @click="reconnect">
|
||||
重新连接
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<n-alert v-if="errorMsg" type="error" closable style="margin-top: 12px" @close="errorMsg = ''">
|
||||
{{ errorMsg }}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
PulseOutline as PulseIcon,
|
||||
PlayOutline as PlayIcon,
|
||||
StopOutline as StopIcon,
|
||||
RefreshOutline as RefreshIcon
|
||||
} from '@vicons/ionicons5'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import axios from 'axios'
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
// 本地状态
|
||||
const isLoading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
// 从WebSocket获取核心状态(实时)
|
||||
const coreStatus = computed(() => wsStore.coreStatus)
|
||||
const isRunning = computed(() => coreStatus.value?.is_running ?? false)
|
||||
const hasError = computed(() => !!coreStatus.value?.error)
|
||||
|
||||
// 状态颜色
|
||||
const getStatusColor = computed(() => {
|
||||
if (isLoading.value) return '#f0a020'
|
||||
if (isRunning.value) return '#18a058'
|
||||
if (hasError.value) return '#d03050'
|
||||
return '#808080'
|
||||
})
|
||||
|
||||
// 格式化运行时间
|
||||
const formatUptime = (seconds: number) => {
|
||||
if (!seconds) return '0s'
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
if (hrs > 0) return `${hrs}h ${mins}m`
|
||||
if (mins > 0) return `${mins}m ${secs}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
|
||||
// 使用WebSocket启动核心(更快速,无HTTP开销)
|
||||
const handleStart = async () => {
|
||||
errorMsg.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 优先使用WebSocket,如果不连接则回退到HTTP
|
||||
if (wsStore.status === 'connected') {
|
||||
wsStore.controlCore('start')
|
||||
// 等待WebSocket推送状态更新(最多5秒)
|
||||
await waitForStatusChange(true, 5000)
|
||||
} else {
|
||||
// HTTP备用方案
|
||||
const res = await axios.post('/api/core/start')
|
||||
if (!res.data.success) {
|
||||
throw new Error(res.data.error || '启动失败')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMsg.value = error.response?.data?.error || error.message || '启动失败'
|
||||
message.error(errorMsg.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 使用WebSocket停止核心
|
||||
const handleStop = async () => {
|
||||
const confirmed = confirm('确定要停止 Yosuga 核心吗?这将中断所有正在进行的对话和任务。')
|
||||
if (!confirmed) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (wsStore.status === 'connected') {
|
||||
wsStore.controlCore('stop')
|
||||
await waitForStatusChange(false, 5000)
|
||||
} else {
|
||||
const res = await axios.post('/api/core/stop')
|
||||
if (!res.data.success) {
|
||||
throw new Error(res.data.error || '停止失败')
|
||||
}
|
||||
}
|
||||
message.success('Yosuga 核心已停止')
|
||||
} catch (error: any) {
|
||||
errorMsg.value = error.response?.data?.error || error.message || '停止失败'
|
||||
message.error(errorMsg.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 等待状态变化辅助函数
|
||||
const waitForStatusChange = (targetRunning: boolean, timeout: number): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (coreStatus.value?.is_running === targetRunning) {
|
||||
clearInterval(checkInterval)
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(checkInterval)
|
||||
resolve() // 超时也不报错,依赖后续状态更新
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新状态(HTTP备用)
|
||||
const refreshStatus = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/core/status')
|
||||
if (res.data.success) {
|
||||
// 手动更新store(如果需要)
|
||||
message.success('状态已刷新')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('刷新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 重新连接WebSocket
|
||||
const reconnect = () => {
|
||||
wsStore.disconnect()
|
||||
setTimeout(() => wsStore.connect(), 500)
|
||||
}
|
||||
|
||||
// 监听WebSocket连接状态
|
||||
watch(() => wsStore.status, (newStatus) => {
|
||||
if (newStatus === 'disconnected' && isRunning.value) {
|
||||
// 如果断开但核心在运行,尝试重新连接
|
||||
setTimeout(() => wsStore.connect(), 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 确保WebSocket连接
|
||||
if (wsStore.status !== 'connected') {
|
||||
wsStore.connect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.core-control {
|
||||
background: linear-gradient(135deg, var(--n-card-color) 0%, var(--n-action-color) 100%);
|
||||
border: 1px solid var(--n-border-color);
|
||||
}
|
||||
|
||||
.core-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.core-status {
|
||||
font-size: 13px;
|
||||
color: var(--n-text-color-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #d03050;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.n-badge .n-badge-dot) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import naive from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useDiagnosticsStore } from './stores/diagnostics'
|
||||
|
||||
export { useDiagnosticsStore }
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(naive)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '@/views/Dashboard.vue'
|
||||
import Monitor from '@/views/Monitor.vue'
|
||||
import Devices from '@/views/Devices.vue'
|
||||
import Config from '@/views/Config.vue'
|
||||
import Logs from '@/views/Logs.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: Dashboard,
|
||||
meta: { title: '仪表板' }
|
||||
},
|
||||
{
|
||||
path: '/monitor',
|
||||
name: 'monitor',
|
||||
component: Monitor,
|
||||
meta: { title: '系统监控' }
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'devices',
|
||||
component: Devices,
|
||||
meta: { title: '设备管理' }
|
||||
},
|
||||
{
|
||||
path: '/config',
|
||||
name: 'config',
|
||||
component: Config,
|
||||
meta: { title: '配置管理' }
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'logs',
|
||||
component: Logs,
|
||||
meta: { title: '日志查看' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,95 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export interface ASRConfig {
|
||||
enabled: boolean
|
||||
url: string
|
||||
model_name: string
|
||||
}
|
||||
|
||||
export interface TTSConfig {
|
||||
enabled: boolean
|
||||
host: string
|
||||
port: number
|
||||
gpt_model_name: string
|
||||
sovits_model_name: string
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
api_key: string | null
|
||||
base_url: string
|
||||
model_name: string
|
||||
temperature: number
|
||||
max_tokens: number
|
||||
}
|
||||
|
||||
export interface AppConfigState {
|
||||
version: string
|
||||
debug: boolean
|
||||
ai: AIConfig
|
||||
tts: TTSConfig
|
||||
asr: ASRConfig
|
||||
auto_agent: any
|
||||
llm_core: any
|
||||
}
|
||||
|
||||
export const useConfigStore = defineStore('config', () => {
|
||||
const config = ref<AppConfigState | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/config')
|
||||
if (response.data.success) {
|
||||
config.value = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = async (section: string, data: any) => {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await axios.post(`/api/config/${section}`, data)
|
||||
if (response.data.success) {
|
||||
if (config.value) {
|
||||
(config.value as any)[section] = data
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('更新配置失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reloadConfig = async () => {
|
||||
try {
|
||||
const response = await axios.post('/api/config/reload')
|
||||
return response.data.success
|
||||
} catch (error) {
|
||||
console.error('重载配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
saving,
|
||||
fetchConfig,
|
||||
updateConfig,
|
||||
reloadConfig
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// core.ts - 现在仅作为HTTP备用,主要逻辑迁移到websocket.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export const useCoreStore = defineStore('core', () => {
|
||||
// 保留HTTP方法作为备用
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/core/status')
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
console.error('获取核心状态失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const startCore = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.post('/api/core/start')
|
||||
return response.data.success
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const stopCore = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.post('/api/core/stop')
|
||||
return response.data.success
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetchStatus,
|
||||
startCore,
|
||||
stopCore
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useWebSocketStore } from './websocket'
|
||||
|
||||
export interface CheckResult {
|
||||
name: string
|
||||
status: 'healthy' | 'unhealthy' | 'unknown' | 'checking'
|
||||
message: string
|
||||
details: {
|
||||
host?: string
|
||||
port?: number
|
||||
error?: string
|
||||
tcp_ok?: boolean
|
||||
}
|
||||
latency_ms: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface DiagnosticsReport {
|
||||
overall_status: 'healthy' | 'unhealthy' | 'unknown'
|
||||
checks: CheckResult[]
|
||||
summary: {
|
||||
healthy: number
|
||||
unhealthy: number
|
||||
unknown: number
|
||||
total: number
|
||||
}
|
||||
generated_at: number
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface ModuleHealth {
|
||||
module: string
|
||||
status: 'healthy' | 'unhealthy' | 'unknown' | 'checking'
|
||||
message: string
|
||||
details: {
|
||||
host?: string
|
||||
port?: number
|
||||
error?: string
|
||||
}
|
||||
latency_ms?: number
|
||||
lastCheck: number
|
||||
}
|
||||
|
||||
export const useDiagnosticsStore = defineStore('diagnostics', () => {
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
const isRunningFullCheck = ref(false)
|
||||
const lastReport = ref<DiagnosticsReport | null>(null)
|
||||
const moduleHealth = ref<Record<string, ModuleHealth>>({
|
||||
asr: { module: 'asr', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||
tts: { module: 'tts', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||
ai: { module: 'ai', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||
auto_agent: { module: 'auto_agent', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 },
|
||||
llm_core: { module: 'llm_core', status: 'unknown', message: '未检查', details: {}, lastCheck: 0 }
|
||||
})
|
||||
|
||||
const healthyCount = computed(() =>
|
||||
Object.values(moduleHealth.value).filter(m => m.status === 'healthy').length
|
||||
)
|
||||
|
||||
const hasUnhealthy = computed(() =>
|
||||
Object.values(moduleHealth.value).some(m => m.status === 'unhealthy')
|
||||
)
|
||||
|
||||
const overallStatus = computed(() => {
|
||||
if (hasUnhealthy.value) return 'error'
|
||||
if (healthyCount.value === 5) return 'success'
|
||||
return 'warning'
|
||||
})
|
||||
|
||||
const runFullDiagnostics = async (): Promise<DiagnosticsReport> => {
|
||||
isRunningFullCheck.value = true
|
||||
try {
|
||||
const response = await axios.post<DiagnosticsReport>('/api/diagnostics/run')
|
||||
lastReport.value = response.data
|
||||
|
||||
response.data.checks.forEach(check => {
|
||||
const moduleMap: Record<string, string> = {
|
||||
'ASR服务': 'asr',
|
||||
'TTS服务': 'tts',
|
||||
'AI服务': 'ai',
|
||||
'自动代理服务': 'auto_agent',
|
||||
'LLM核心': 'llm_core'
|
||||
}
|
||||
|
||||
const moduleKey = moduleMap[check.name]
|
||||
if (moduleKey && moduleHealth.value[moduleKey]) {
|
||||
moduleHealth.value[moduleKey] = {
|
||||
module: moduleKey,
|
||||
status: check.status,
|
||||
message: check.message,
|
||||
details: check.details,
|
||||
latency_ms: check.latency_ms,
|
||||
lastCheck: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return response.data
|
||||
} finally {
|
||||
isRunningFullCheck.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkModule = async (module: string) => {
|
||||
if (moduleHealth.value[module]) {
|
||||
moduleHealth.value[module].status = 'checking'
|
||||
}
|
||||
|
||||
try {
|
||||
if (wsStore.status === 'connected') {
|
||||
wsStore.socket?.emit('check_module_health', { module })
|
||||
return
|
||||
}
|
||||
|
||||
const response = await axios.get(`/api/diagnostics/check/${module}`)
|
||||
if (response.data.success) {
|
||||
updateModuleHealth(module, response.data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
moduleHealth.value[module] = {
|
||||
module,
|
||||
status: 'unhealthy',
|
||||
message: '检查失败',
|
||||
details: { error: '网络请求失败' },
|
||||
lastCheck: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateModuleHealth = (module: string, data: CheckResult) => {
|
||||
moduleHealth.value[module] = {
|
||||
module,
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
details: data.details,
|
||||
latency_ms: data.latency_ms,
|
||||
lastCheck: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const checkAllModules = async () => {
|
||||
const modules = ['asr', 'tts', 'ai', 'auto_agent', 'llm_core']
|
||||
await Promise.all(modules.map(m => checkModule(m)))
|
||||
}
|
||||
|
||||
const initWebSocketListeners = () => {
|
||||
wsStore.socket?.on('module_health_result', (result: {
|
||||
success: boolean
|
||||
module?: string
|
||||
data?: CheckResult
|
||||
error?: string
|
||||
}) => {
|
||||
if (result.success && result.module && result.data) {
|
||||
updateModuleHealth(result.module, result.data)
|
||||
}
|
||||
})
|
||||
|
||||
wsStore.socket?.on('module_status_update', (update: {
|
||||
module: string
|
||||
status: CheckResult
|
||||
}) => {
|
||||
updateModuleHealth(update.module, update.status)
|
||||
})
|
||||
}
|
||||
|
||||
let autoCheckInterval: NodeJS.Timeout | null = null
|
||||
|
||||
const startAutoCheck = (intervalMs = 30000) => {
|
||||
stopAutoCheck()
|
||||
autoCheckInterval = setInterval(() => {
|
||||
checkAllModules()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
const stopAutoCheck = () => {
|
||||
if (autoCheckInterval) {
|
||||
clearInterval(autoCheckInterval)
|
||||
autoCheckInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunningFullCheck,
|
||||
lastReport,
|
||||
moduleHealth,
|
||||
healthyCount,
|
||||
hasUnhealthy,
|
||||
overallStatus,
|
||||
runFullDiagnostics,
|
||||
checkModule,
|
||||
checkAllModules,
|
||||
initWebSocketListeners,
|
||||
startAutoCheck,
|
||||
stopAutoCheck
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export const usePreferencesStore = defineStore('preferences', () => {
|
||||
// 用户偏好状态
|
||||
const preferences = ref({
|
||||
theme: 'light', // 'light' | 'dark' | 'auto'
|
||||
sidebarCollapsed: false,
|
||||
logLevel: 'ALL', // 默认日志等级筛选
|
||||
logAutoScroll: true, // 日志自动滚动
|
||||
refreshInterval: 2000, // 数据刷新间隔(ms)
|
||||
lastVisitedPage: 'dashboard',
|
||||
favoriteConfigs: [] as string[], // 收藏的配置项
|
||||
notifications: {
|
||||
coreStatus: true, // 核心状态变化通知
|
||||
errors: true // 错误通知
|
||||
}
|
||||
})
|
||||
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 从服务器加载偏好
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/preferences')
|
||||
if (response.data.success) {
|
||||
preferences.value = { ...preferences.value, ...response.data.data }
|
||||
applyTheme(preferences.value.theme)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载偏好失败:', error)
|
||||
// 从 localStorage 回退
|
||||
const local = localStorage.getItem('yosuga_preferences')
|
||||
if (local) {
|
||||
preferences.value = { ...preferences.value, ...JSON.parse(local) }
|
||||
}
|
||||
} finally {
|
||||
isLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 保存偏好到服务器和本地
|
||||
const savePreferences = async () => {
|
||||
try {
|
||||
await axios.post('/api/preferences', preferences.value)
|
||||
localStorage.setItem('yosuga_preferences', JSON.stringify(preferences.value))
|
||||
} catch (error) {
|
||||
console.error('保存偏好失败:', error)
|
||||
// 至少保存到本地
|
||||
localStorage.setItem('yosuga_preferences', JSON.stringify(preferences.value))
|
||||
}
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
const applyTheme = (theme: string) => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
// auto: 跟随系统
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
const setTheme = (theme: string) => {
|
||||
preferences.value.theme = theme
|
||||
applyTheme(theme)
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
preferences.value.sidebarCollapsed = !preferences.value.sidebarCollapsed
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
// 设置日志等级
|
||||
const setLogLevel = (level: string) => {
|
||||
preferences.value.logLevel = level
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (preferences.value.theme === 'auto') {
|
||||
applyTheme('auto')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 自动保存(防抖)
|
||||
let saveTimer: NodeJS.Timeout
|
||||
watch(preferences, () => {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
savePreferences()
|
||||
}, 500)
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
preferences,
|
||||
isLoaded,
|
||||
loadPreferences,
|
||||
savePreferences,
|
||||
setTheme,
|
||||
toggleSidebar,
|
||||
setLogLevel
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
// 系统状态类型定义
|
||||
export interface SystemStats {
|
||||
cpu: { percent: number; count: number }
|
||||
memory: {
|
||||
total: number; available: number;
|
||||
percent: number; used: number; free: number
|
||||
}
|
||||
disk: { total: number; used: number; free: number; percent: number }
|
||||
process: {
|
||||
memory_percent: number; cpu_percent: number;
|
||||
threads: number; uptime: number
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface CoreStatus {
|
||||
is_running: boolean
|
||||
pid: number
|
||||
uptime: number
|
||||
error: string | null
|
||||
thread_alive: boolean
|
||||
start_time: string | null
|
||||
}
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
const socket = ref<Socket | null>(null)
|
||||
const status = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
|
||||
const logs = ref<string[]>([])
|
||||
const currentFilter = ref('ALL')
|
||||
|
||||
// 新增:系统状态(来自WebSocket实时推送)
|
||||
const systemStats = ref<SystemStats | null>(null)
|
||||
const coreStatus = ref<CoreStatus | null>(null)
|
||||
|
||||
// 连接状态时间戳
|
||||
const lastPing = ref<number>(0)
|
||||
const latency = computed(() => {
|
||||
if (!lastPing.value) return 0
|
||||
return Date.now() - lastPing.value
|
||||
})
|
||||
|
||||
const connect = () => {
|
||||
if (socket.value?.connected) return
|
||||
|
||||
status.value = 'connecting'
|
||||
socket.value = io('http://localhost:8089', {
|
||||
transports: ['websocket'],
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
})
|
||||
|
||||
socket.value.on('connect', () => {
|
||||
status.value = 'connected'
|
||||
lastPing.value = Date.now()
|
||||
// 连接时自动订阅日志
|
||||
subscribeWithLevel(currentFilter.value)
|
||||
})
|
||||
|
||||
socket.value.on('disconnect', (reason) => {
|
||||
status.value = 'disconnected'
|
||||
console.log(`WebSocket断开: ${reason}`)
|
||||
})
|
||||
|
||||
socket.value.on('connect_error', (err) => {
|
||||
console.error('WebSocket连接错误:', err)
|
||||
status.value = 'disconnected'
|
||||
})
|
||||
|
||||
// 日志接收
|
||||
socket.value.on('log_line', (data: { line: string; timestamp?: string; level?: string }) => {
|
||||
logs.value.push(data.line)
|
||||
if (logs.value.length > 1000) {
|
||||
logs.value.shift()
|
||||
}
|
||||
})
|
||||
|
||||
// 新增:系统状态实时推送
|
||||
socket.value.on('system_stats', (response: { success: boolean; data: SystemStats }) => {
|
||||
if (response.success && response.data) {
|
||||
systemStats.value = response.data
|
||||
lastPing.value = Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
// 新增:核心状态实时推送(替代HTTP轮询)
|
||||
socket.value.on('core_status', (response: { success: boolean; data: CoreStatus; message?: string }) => {
|
||||
if (response.success && response.data) {
|
||||
coreStatus.value = response.data
|
||||
console.log('核心状态更新:', response.data.is_running ? '运行中' : '已停止', response.message || '')
|
||||
}
|
||||
})
|
||||
|
||||
// 控制操作结果回调
|
||||
socket.value.on('core_control_result', (result: { success: boolean; error?: string; message?: string; data?: any }) => {
|
||||
if (!result.success) {
|
||||
console.error('核心控制失败:', result.error)
|
||||
} else {
|
||||
console.log('核心控制成功:', result.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 新增:通过WebSocket控制核心(替代HTTP请求)
|
||||
const controlCore = (action: 'start' | 'stop') => {
|
||||
socket.value?.emit('control_core', { action })
|
||||
}
|
||||
|
||||
const subscribeWithLevel = (level: string) => {
|
||||
currentFilter.value = level
|
||||
socket.value?.emit('subscribe_logs', { level })
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
socket.value?.disconnect()
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
// 心跳检测
|
||||
const startHeartbeat = () => {
|
||||
const interval = setInterval(() => {
|
||||
if (status.value === 'connected' && socket.value) {
|
||||
// 如果超过10秒没有收到system_stats,认为连接可能有问题
|
||||
if (lastPing.value && (Date.now() - lastPing.value > 10000)) {
|
||||
console.warn('WebSocket可能卡顿,尝试重新连接...')
|
||||
socket.value.connect()
|
||||
}
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
status,
|
||||
logs,
|
||||
currentFilter,
|
||||
systemStats, // 导出系统状态
|
||||
coreStatus, // 导出核心状态
|
||||
latency,
|
||||
connect,
|
||||
disconnect,
|
||||
clearLogs,
|
||||
subscribeWithLevel,
|
||||
controlCore, // 导出控制函数
|
||||
startHeartbeat
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<div class="config-page">
|
||||
<n-card title="全局配置管理" :bordered="false">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button @click="fetchConfig" :loading="loading">
|
||||
<template #icon><n-icon><refresh-icon /></n-icon></template>
|
||||
刷新
|
||||
</n-button>
|
||||
<n-button type="primary" @click="handleSave" :loading="saving">
|
||||
<template #icon><n-icon><save-icon /></n-icon></template>
|
||||
保存更改
|
||||
</n-button>
|
||||
<n-button @click="showJson = true">
|
||||
<template #icon><n-icon><code-icon /></n-icon></template>
|
||||
查看JSON
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-tabs type="line" animated v-model:value="activeTab">
|
||||
<!-- AI 模型配置 -->
|
||||
<n-tab-pane name="ai" tab="AI 模型">
|
||||
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||
配置主对话AI服务,支持OpenAI、DeepSeek或本地LM Studio
|
||||
</n-alert>
|
||||
|
||||
<n-form
|
||||
v-if="formData.ai"
|
||||
:model="formData.ai"
|
||||
label-placement="left"
|
||||
label-width="140px"
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<n-form-item label="API Key">
|
||||
<n-input
|
||||
v-model:value="formData.ai.api_key"
|
||||
type="password"
|
||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||
show-password-on="click"
|
||||
/>
|
||||
<template #feedback>留空使用本地模型或无需认证的端点</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Base URL">
|
||||
<n-input
|
||||
v-model:value="formData.ai.base_url"
|
||||
placeholder="http://localhost:1234/v1"
|
||||
/>
|
||||
<template #feedback>API端点地址,本地LM Studio通常为 http://localhost:1234/v1</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="模型名称">
|
||||
<n-input
|
||||
v-model:value="formData.ai.model_name"
|
||||
placeholder="qwen/qwen3-4b-2507"
|
||||
/>
|
||||
<template #feedback>例如: gpt-4, deepseek-chat, qwen/qwen3-4b-2507</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Temperature">
|
||||
<n-space align="center" style="width: 100%">
|
||||
<n-slider
|
||||
v-model:value="formData.ai.temperature"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<n-input-number
|
||||
v-model:value="formData.ai.temperature"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
style="width: 100px"
|
||||
/>
|
||||
</n-space>
|
||||
<template #feedback>控制随机性: 0=确定性, 1=平衡, 2=创造性</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Max Tokens">
|
||||
<n-input-number
|
||||
v-model:value="formData.ai.max_tokens"
|
||||
:min="256"
|
||||
:max="32768"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<template #feedback>单次回复最大token数,通常设为8192</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="超时时间(秒)">
|
||||
<n-input-number
|
||||
v-model:value="formData.ai.timeout"
|
||||
:min="5"
|
||||
:max="300"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<template #feedback>API请求超时时间</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- TTS 语音合成配置 -->
|
||||
<n-tab-pane name="tts" tab="语音合成 (TTS)">
|
||||
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||
GPT-SoVITS 语音合成服务配置
|
||||
</n-alert>
|
||||
|
||||
<n-form
|
||||
v-if="formData.tts"
|
||||
:model="formData.tts"
|
||||
label-placement="left"
|
||||
label-width="140px"
|
||||
>
|
||||
<n-form-item label="启用 TTS">
|
||||
<n-switch v-model:value="formData.tts.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">服务端连接</n-divider>
|
||||
|
||||
<n-form-item label="主机地址">
|
||||
<n-input
|
||||
v-model:value="formData.tts.host"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="端口">
|
||||
<n-input-number
|
||||
v-model:value="formData.tts.port"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<template #feedback>GPT-SoVITS API默认端口 9880 或 20261</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="API Key">
|
||||
<n-input
|
||||
v-model:value="formData.tts.api_key"
|
||||
type="password"
|
||||
placeholder="可选"
|
||||
show-password-on="click"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">模型配置</n-divider>
|
||||
|
||||
<n-form-item label="GPT模型路径">
|
||||
<n-input
|
||||
v-model:value="formData.tts.gpt_model_name"
|
||||
placeholder="GPT_weights_v2Pro/Yosuga_Airi-e32.ckpt"
|
||||
/>
|
||||
<template #feedback>相对于项目根目录的路径</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="SoVITS模型路径">
|
||||
<n-input
|
||||
v-model:value="formData.tts.sovits_model_name"
|
||||
placeholder="SoVITS_weights_v2Pro/Yosuga_Airi_e16_s864.pth"
|
||||
/>
|
||||
<template #feedback>相对于项目根目录的路径</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">音频设置</n-divider>
|
||||
|
||||
<n-form-item label="参考音频路径">
|
||||
<n-input
|
||||
v-model:value="formData.tts.reference_audio"
|
||||
placeholder="./using/reference.wav"
|
||||
/>
|
||||
<template #feedback>用于声音克隆的参考音频文件路径</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="流式输出">
|
||||
<n-switch v-model:value="formData.tts.streaming" />
|
||||
<template #feedback>启用流式传输可降低延迟</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="语速倍率">
|
||||
<n-space align="center" style="width: 100%">
|
||||
<n-slider
|
||||
v-model:value="formData.tts.speed"
|
||||
:min="0.6"
|
||||
:max="1.65"
|
||||
:step="0.05"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span style="min-width: 50px">{{ formData.tts.speed }}x</span>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ASR 语音识别配置 -->
|
||||
<n-tab-pane name="asr" tab="语音识别 (ASR)">
|
||||
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||
Faster-Whisper 语音识别服务配置
|
||||
</n-alert>
|
||||
|
||||
<n-form
|
||||
v-if="formData.asr"
|
||||
:model="formData.asr"
|
||||
label-placement="left"
|
||||
label-width="140px"
|
||||
>
|
||||
<n-form-item label="启用 ASR">
|
||||
<n-switch v-model:value="formData.asr.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="服务 URL">
|
||||
<n-input
|
||||
v-model:value="formData.asr.url"
|
||||
placeholder="http://localhost:20260/"
|
||||
/>
|
||||
<template #feedback>ASR API端点地址,默认端口20260</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="模型名称">
|
||||
<n-input
|
||||
v-model:value="formData.asr.model_name"
|
||||
placeholder="fast-whisper"
|
||||
/>
|
||||
<template #feedback>使用的ASR模型标识</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="API Key">
|
||||
<n-input
|
||||
v-model:value="formData.asr.api_key"
|
||||
type="password"
|
||||
placeholder="可选"
|
||||
show-password-on="click"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 自动代理配置 -->
|
||||
<n-tab-pane name="auto_agent" tab="自动代理 (Agent)">
|
||||
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||
UI-TARS 自动化操作代理配置,用于GUI控制
|
||||
</n-alert>
|
||||
|
||||
<n-form
|
||||
v-if="formData.auto_agent"
|
||||
:model="formData.auto_agent"
|
||||
label-placement="left"
|
||||
label-width="140px"
|
||||
>
|
||||
<n-form-item label="启用自动代理">
|
||||
<n-switch v-model:value="formData.auto_agent.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="部署类型">
|
||||
<n-select
|
||||
v-model:value="formData.auto_agent.deployment_type"
|
||||
:options="[
|
||||
{ label: 'LM Studio (本地)', value: 'lmstudio' },
|
||||
{ label: 'vLLM (本地服务器)', value: 'vllm' },
|
||||
{ label: 'Ollama (本地)', value: 'ollama' },
|
||||
{ label: '云端 API', value: 'cloud' }
|
||||
]"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Base URL">
|
||||
<n-input
|
||||
v-model:value="formData.auto_agent.base_url"
|
||||
placeholder="http://localhost:1234/v1"
|
||||
/>
|
||||
<template #feedback>UI-TARS模型服务端点</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="模型名称">
|
||||
<n-input
|
||||
v-model:value="formData.auto_agent.model_name"
|
||||
placeholder="ui-tars-1.5-7b@q4_k_m"
|
||||
/>
|
||||
<template #feedback>例如: ui-tars-1.5-7b, ui-tars-2.0-7b-sft</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="API Key">
|
||||
<n-input
|
||||
v-model:value="formData.auto_agent.api_key"
|
||||
type="password"
|
||||
placeholder="可选,云端服务时需要"
|
||||
show-password-on="click"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Temperature">
|
||||
<n-space align="center" style="width: 100%">
|
||||
<n-slider
|
||||
v-model:value="formData.auto_agent.temperature"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<n-input-number
|
||||
v-model:value="formData.auto_agent.temperature"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
style="width: 100px"
|
||||
/>
|
||||
</n-space>
|
||||
<template #feedback>UI任务建议低温度(0.1-0.3)提高准确性</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Max Tokens">
|
||||
<n-input-number
|
||||
v-model:value="formData.auto_agent.max_tokens"
|
||||
:min="2048"
|
||||
:max="128000"
|
||||
:step="1024"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<template #feedback>UI-TARS需要较大token数来处理截图,建议16384+</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- LLM 核心配置 -->
|
||||
<n-tab-pane name="llm_core" tab="LLM 核心">
|
||||
<n-alert type="info" style="margin-bottom: 16px" :show-icon="false">
|
||||
Yosuga LLM Core 行为配置
|
||||
</n-alert>
|
||||
|
||||
<n-form
|
||||
v-if="formData.llm_core"
|
||||
:model="formData.llm_core"
|
||||
label-placement="left"
|
||||
label-width="140px"
|
||||
>
|
||||
<n-form-item label="启用 LLM核心">
|
||||
<n-switch v-model:value="formData.llm_core.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用历史记录">
|
||||
<n-switch v-model:value="formData.llm_core.enable_history" />
|
||||
<template #feedback>开启后LLM会记住对话上下文</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="最大上下文 Tokens">
|
||||
<n-input-number
|
||||
v-model:value="formData.llm_core.max_context_tokens"
|
||||
:min="512"
|
||||
:max="32768"
|
||||
:step="512"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<template #feedback>上下文长度限制,超出会触发记忆清理</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="回复语言">
|
||||
<n-select
|
||||
v-model:value="formData.llm_core.language"
|
||||
:options="[
|
||||
{ label: '日本语 (Japanese)', value: '日本语' },
|
||||
{ label: '中文 (Chinese)', value: 'zh_CN' },
|
||||
{ label: 'English', value: 'en' }
|
||||
]"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="角色设定">
|
||||
<n-input
|
||||
v-model:value="formData.llm_core.role_character"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="输入角色设定..."
|
||||
/>
|
||||
<template #feedback>
|
||||
定义AI助手的性格、背景和行为方式
|
||||
<n-button text type="primary" size="tiny" @click="resetRoleToDefault">
|
||||
恢复默认
|
||||
</n-button>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
|
||||
<!-- JSON预览模态框 -->
|
||||
<n-modal v-model:show="showJson" title="当前配置 (JSON)" style="width: 800px">
|
||||
<n-card>
|
||||
<n-code :code="jsonPreview" language="json" style="max-height: 500px; overflow: auto;" />
|
||||
<n-space justify="end" style="margin-top: 16px;">
|
||||
<n-button @click="copyJson">复制到剪贴板</n-button>
|
||||
<n-button @click="showJson = false">关闭</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
RefreshOutline as RefreshIcon,
|
||||
SaveOutline as SaveIcon,
|
||||
CodeWorkingOutline as CodeIcon
|
||||
} from '@vicons/ionicons5'
|
||||
import axios from 'axios'
|
||||
|
||||
const message = useMessage()
|
||||
const activeTab = ref('ai')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showJson = ref(false)
|
||||
|
||||
// 完整的配置数据结构(与后端config.py完全对应)
|
||||
const formData = reactive({
|
||||
ai: {
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
model_name: '',
|
||||
timeout: 30,
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192
|
||||
},
|
||||
tts: {
|
||||
enabled: true,
|
||||
api_key: null as string | null,
|
||||
gpt_model_name: '',
|
||||
sovits_model_name: '',
|
||||
host: 'localhost',
|
||||
port: 20261,
|
||||
reference_audio: './using/reference.wav',
|
||||
streaming: true,
|
||||
speed: 1.0
|
||||
},
|
||||
asr: {
|
||||
enabled: true,
|
||||
api_key: null as string | null,
|
||||
model_name: 'fast-whisper',
|
||||
url: 'http://localhost:20260/'
|
||||
},
|
||||
auto_agent: {
|
||||
enabled: true,
|
||||
api_key: null as string | null,
|
||||
deployment_type: 'lmstudio',
|
||||
model_name: 'ui-tars-1.5-7b@q4_k_m',
|
||||
base_url: 'http://localhost:1234/v1',
|
||||
temperature: 0.1,
|
||||
max_tokens: 16384
|
||||
},
|
||||
llm_core: {
|
||||
enabled: true,
|
||||
role_character: '你是由Misakiotoha开发的助手稲葉愛理ちゃん,可以和用户一起玩游戏,聊天,做各种事情,性格抽象,没事爱整整活。',
|
||||
max_context_tokens: 2048,
|
||||
enable_history: true,
|
||||
language: '日本语'
|
||||
}
|
||||
})
|
||||
|
||||
const defaultRole = '你是由Misakiotoha开发的助手稲葉愛理ちゃん,可以和用户一起玩游戏,聊天,做各种事情,性格抽象,没事爱整整活。'
|
||||
|
||||
const jsonPreview = computed(() => {
|
||||
return JSON.stringify({
|
||||
ai: formData.ai,
|
||||
tts: formData.tts,
|
||||
asr: formData.asr,
|
||||
auto_agent: formData.auto_agent,
|
||||
llm_core: formData.llm_core
|
||||
}, null, 2)
|
||||
})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/config')
|
||||
if (response.data.success) {
|
||||
const cfg = response.data.data
|
||||
|
||||
// 深度合并配置(确保新字段也能显示)
|
||||
Object.assign(formData.ai, cfg.ai || {})
|
||||
Object.assign(formData.tts, cfg.tts || {})
|
||||
Object.assign(formData.asr, cfg.asr || {})
|
||||
Object.assign(formData.auto_agent, cfg.auto_agent || {})
|
||||
Object.assign(formData.llm_core, cfg.llm_core || {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
message.error('获取配置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 保存当前选中的tab对应的配置节
|
||||
const section = activeTab.value
|
||||
const data = formData[section as keyof typeof formData]
|
||||
|
||||
const response = await axios.post(`/api/config/${section}`, data)
|
||||
|
||||
if (response.data.success) {
|
||||
message.success('配置已保存并生效')
|
||||
} else {
|
||||
message.error('保存失败: ' + (response.data.message || '未知错误'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存配置失败:', error)
|
||||
message.error('保存失败: ' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetRoleToDefault = () => {
|
||||
formData.llm_core.role_character = defaultRole
|
||||
message.info('已恢复默认角色设定')
|
||||
}
|
||||
|
||||
const copyJson = () => {
|
||||
navigator.clipboard.writeText(jsonPreview.value)
|
||||
message.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:deep(.n-form-item-feedback) {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:deep(.n-divider) {
|
||||
margin: 24px 0 16px 0;
|
||||
}
|
||||
|
||||
:deep(.n-divider__title) {
|
||||
font-size: 13px;
|
||||
color: var(--n-text-color-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<CoreControl style="margin-bottom: 16px" />
|
||||
|
||||
<!-- 系统体检报告卡片 -->
|
||||
<n-card
|
||||
v-if="lastReport"
|
||||
:title="`系统体检报告 (v${lastReport.version})`"
|
||||
class="diagnostics-card"
|
||||
:bordered="false"
|
||||
closable
|
||||
@close="lastReport = null"
|
||||
>
|
||||
<n-space align="center" justify="space-between">
|
||||
<n-space align="center">
|
||||
<n-icon size="32" :color="statusColor">
|
||||
<checkmark-circle-icon v-if="diagStore.overallStatus === 'success'" />
|
||||
<warning-icon v-else-if="diagStore.overallStatus === 'warning'" />
|
||||
<close-circle-icon v-else />
|
||||
</n-icon>
|
||||
<div>
|
||||
<div :style="{ color: statusColor, fontWeight: 600 }">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
{{ lastReport.summary.healthy }}/{{ lastReport.summary.total }} 项检查通过
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
|
||||
<n-button text type="primary" @click="showDetails = true">
|
||||
查看详情
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-grid :x-gap="16" :y-gap="16" cols="1 s:2 l:4">
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<n-statistic label="CPU 使用率" :value="systemStats?.cpu?.percent ?? 0">
|
||||
<template #suffix>%</template>
|
||||
</n-statistic>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="systemStats?.cpu?.percent ?? 0"
|
||||
:indicator-placement="'inside'"
|
||||
:color="getProgressColor(systemStats?.cpu?.percent ?? 0)"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<n-statistic label="内存使用" :value="memoryPercent">
|
||||
<template #suffix>%</template>
|
||||
</n-statistic>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="memoryPercent"
|
||||
:indicator-placement="'inside'"
|
||||
:color="getProgressColor(memoryPercent)"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<n-statistic label="运行时间" :value="uptimeFormatted" />
|
||||
<div class="stat-detail">进程启动至今</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<n-statistic label="服务健康" :value="`${diagStore.healthyCount}/5`" />
|
||||
<div class="stat-detail">
|
||||
<span :style="{ color: diagStore.hasUnhealthy ? '#d03050' : '#18a058' }">
|
||||
{{ diagStore.hasUnhealthy ? '存在异常服务' : '所有服务正常' }}
|
||||
</span>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 模块状态 - TCP端口检测 -->
|
||||
<n-card title="模块状态" class="module-card" :bordered="false">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-tag :type="checkingAll ? 'warning' : 'success'" size="small">
|
||||
<n-spin v-if="checkingAll" :size="12" style="margin-right: 4px" />
|
||||
{{ checkingAll ? '检测中...' : 'TCP端口检测' }}
|
||||
</n-tag>
|
||||
<n-button text type="primary" size="small" @click="refreshAllModules">
|
||||
<template #icon><n-icon><refresh-icon /></n-icon></template>
|
||||
刷新
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-grid :x-gap="16" :y-gap="16" cols="2 s:3 l:5">
|
||||
<n-gi v-for="(health, name) in moduleHealthDisplay" :key="name">
|
||||
<div
|
||||
class="module-item"
|
||||
:class="{
|
||||
active: health.status === 'healthy',
|
||||
error: health.status === 'unhealthy',
|
||||
checking: health.status === 'checking'
|
||||
}"
|
||||
@click="checkSingleModule(name)"
|
||||
>
|
||||
<n-icon size="24">
|
||||
<checkmark-circle-icon v-if="health.status === 'healthy'" color="#18a058" />
|
||||
<close-circle-icon v-else-if="health.status === 'unhealthy'" color="#d03050" />
|
||||
<time-icon v-else-if="health.status === 'checking'" color="#f0a020" />
|
||||
<help-circle-icon v-else color="#808080" />
|
||||
</n-icon>
|
||||
|
||||
<div class="module-info">
|
||||
<div class="module-name">{{ getModuleName(name) }}</div>
|
||||
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<div class="module-status" :class="health.status">
|
||||
<span v-if="health.status === 'healthy'">端口可连通</span>
|
||||
<span v-else-if="health.status === 'unhealthy'">端口不可达</span>
|
||||
<span v-else-if="health.status === 'checking'">检测中...</span>
|
||||
<span v-else>未检测</span>
|
||||
|
||||
<span v-if="health.latency_ms && health.status === 'healthy'" class="latency">
|
||||
({{ health.latency_ms < 1 ? '<1' : health.latency_ms.toFixed(0) }}ms)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="font-size: 12px;">
|
||||
<div>主机: {{ health.details?.host || 'localhost' }}</div>
|
||||
<div>端口: {{ health.details?.port || getDefaultPort(name) }}</div>
|
||||
<div>上次检查: {{ formatTime(health.lastCheck) }}</div>
|
||||
<div v-if="health.details?.error" style="color: #ff4d4f;">
|
||||
错误: {{ health.details.error }}
|
||||
</div>
|
||||
</div>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<n-button
|
||||
v-if="health.status === 'unhealthy'"
|
||||
text
|
||||
type="error"
|
||||
size="tiny"
|
||||
@click.stop="showModuleDetails(name)"
|
||||
>
|
||||
详情
|
||||
</n-button>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<n-card title="快速操作" class="action-card" :bordered="false">
|
||||
<n-space>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="diagStore.isRunningFullCheck"
|
||||
@click="runSystemDiagnostics"
|
||||
>
|
||||
<template #icon><n-icon><medical-icon /></n-icon></template>
|
||||
{{ diagStore.isRunningFullCheck ? '体检中...' : '系统体检' }}
|
||||
</n-button>
|
||||
|
||||
<n-button @click="handleReloadConfig">
|
||||
<template #icon><n-icon><refresh-icon /></n-icon></template>
|
||||
重载配置
|
||||
</n-button>
|
||||
|
||||
<n-button @click="exportConfig">
|
||||
<template #icon><n-icon><download-icon /></n-icon></template>
|
||||
导出配置
|
||||
</n-button>
|
||||
|
||||
<n-upload
|
||||
action="/api/config/import"
|
||||
accept=".json"
|
||||
:show-file-list="false"
|
||||
@finish="handleImportFinish"
|
||||
@error="handleImportError"
|
||||
>
|
||||
<n-button>
|
||||
<template #icon><n-icon><upload-icon /></n-icon></template>
|
||||
导入配置
|
||||
</n-button>
|
||||
</n-upload>
|
||||
|
||||
<n-button @click="router.push('/logs')">
|
||||
<template #icon><n-icon><terminal-icon /></n-icon></template>
|
||||
查看日志
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 体检详情抽屉 -->
|
||||
<n-drawer v-model:show="showDetails" :width="600" placement="right">
|
||||
<n-drawer-content title="详细体检报告">
|
||||
<n-timeline v-if="lastReport">
|
||||
<n-timeline-item
|
||||
v-for="check in lastReport.checks"
|
||||
:key="check.name"
|
||||
:type="check.status === 'healthy' ? 'success' : check.status === 'unhealthy' ? 'error' : 'default'"
|
||||
:title="check.name"
|
||||
:content="check.message"
|
||||
:time="`${check.latency_ms.toFixed(1)}ms`"
|
||||
/>
|
||||
</n-timeline>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<!-- 模块详情Modal -->
|
||||
<n-modal v-model:show="showModuleModal" :title="`${selectedModule} 连接详情`">
|
||||
<n-card style="width: 450px" v-if="selectedModule">
|
||||
<n-descriptions bordered :column="1" size="small">
|
||||
<n-descriptions-item label="检测方式">
|
||||
<n-tag size="small">TCP端口连通性</n-tag>
|
||||
</n-descriptions-item>
|
||||
|
||||
<n-descriptions-item label="目标地址">
|
||||
{{ currentModuleHealth?.details?.host || 'localhost' }}
|
||||
:{{ currentModuleHealth?.details?.port || getDefaultPort(selectedModule) }}
|
||||
</n-descriptions-item>
|
||||
|
||||
<n-descriptions-item label="连接状态">
|
||||
<n-tag :type="currentModuleHealth?.status === 'healthy' ? 'success' : 'error'">
|
||||
{{ currentModuleHealth?.status === 'healthy' ? '端口开放' : '端口关闭' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
|
||||
<n-descriptions-item label="响应延迟" v-if="currentModuleHealth?.latency_ms">
|
||||
{{ currentModuleHealth.latency_ms.toFixed(2) }} ms
|
||||
</n-descriptions-item>
|
||||
|
||||
<n-descriptions-item label="最后检测">
|
||||
{{ formatTime(currentModuleHealth?.lastCheck || 0) }}
|
||||
</n-descriptions-item>
|
||||
|
||||
<n-descriptions-item label="错误信息" v-if="currentModuleHealth?.details?.error">
|
||||
<span style="color: #d03050;">
|
||||
{{ currentModuleHealth.details.error }}
|
||||
</span>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<n-alert
|
||||
v-if="currentModuleHealth?.status === 'unhealthy'"
|
||||
type="error"
|
||||
style="margin-top: 16px"
|
||||
:show-icon="true"
|
||||
>
|
||||
<template #header>排查建议</template>
|
||||
|
||||
<div v-if="currentModuleHealth?.details?.error === '连接被拒绝'" style="font-size: 13px;">
|
||||
1. 服务可能已崩溃或未启动<br>
|
||||
2. 检查防火墙是否放行端口<br>
|
||||
3. 查看端口是否被其他进程占用
|
||||
</div>
|
||||
<div v-else-if="currentModuleHealth?.details?.error === '连接超时'" style="font-size: 13px;">
|
||||
1. 服务未启动或网络不可达<br>
|
||||
2. 检查IP地址和端口配置<br>
|
||||
3. 确认服务监听的是0.0.0.0而非127.0.0.1
|
||||
</div>
|
||||
<div v-else style="font-size: 13px;">
|
||||
1. 确认服务已启动<br>
|
||||
2. 检查配置文件中的host/port<br>
|
||||
3. 查看系统日志获取详细信息
|
||||
</div>
|
||||
|
||||
<n-divider style="margin: 8px 0;" />
|
||||
<n-space vertical size="small" style="font-size: 12px;">
|
||||
<div>配置文件: <code>settings.json</code></div>
|
||||
<div v-if="selectedModule === 'asr'">默认端口: 20260</div>
|
||||
<div v-else-if="selectedModule === 'tts'">默认端口: 20261</div>
|
||||
<div v-else-if="selectedModule === 'ai' || selectedModule === 'auto_agent'">默认端口: 1234 (LM Studio)</div>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
|
||||
<n-space v-else justify="end" style="margin-top: 16px;">
|
||||
<n-button size="small" @click="checkSingleModule(selectedModule)">
|
||||
重新检测
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
CheckmarkCircleOutline as CheckmarkCircleIcon,
|
||||
CloseCircleOutline as CloseCircleIcon,
|
||||
RefreshOutline as RefreshIcon,
|
||||
TerminalOutline as TerminalIcon,
|
||||
DownloadOutline as DownloadIcon,
|
||||
CloudUploadOutline as UploadIcon,
|
||||
MedicalOutline as MedicalIcon,
|
||||
WarningOutline as WarningIcon,
|
||||
TimeOutline as TimeIcon,
|
||||
HelpCircleOutline as HelpCircleIcon
|
||||
} from '@vicons/ionicons5'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import CoreControl from '@/components/CoreControl.vue'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import { useDiagnosticsStore } from '@/stores/diagnostics'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const wsStore = useWebSocketStore()
|
||||
const diagStore = useDiagnosticsStore()
|
||||
|
||||
const checkingAll = ref(false)
|
||||
const showDetails = ref(false)
|
||||
const showModuleModal = ref(false)
|
||||
const selectedModule = ref<string>('')
|
||||
|
||||
const systemStats = computed(() => wsStore.systemStats)
|
||||
const lastReport = computed(() => diagStore.lastReport)
|
||||
const moduleHealthDisplay = computed(() => diagStore.moduleHealth)
|
||||
|
||||
const memoryPercent = computed(() => Math.round(systemStats.value?.memory?.percent ?? 0))
|
||||
const uptimeFormatted = computed(() => {
|
||||
const seconds = systemStats.value?.process?.uptime ?? 0
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (diagStore.overallStatus === 'success') return '#18a058'
|
||||
if (diagStore.overallStatus === 'warning') return '#f0a020'
|
||||
return '#d03050'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (diagStore.overallStatus === 'success') return '系统健康'
|
||||
if (diagStore.overallStatus === 'warning') return '部分异常'
|
||||
return '需要关注'
|
||||
})
|
||||
|
||||
const currentModuleHealth = computed(() => {
|
||||
if (!selectedModule.value) return null
|
||||
return diagStore.moduleHealth[selectedModule.value]
|
||||
})
|
||||
|
||||
const getProgressColor = (value: number) => {
|
||||
if (value < 60) return '#18a058'
|
||||
if (value < 80) return '#f0a020'
|
||||
return '#d03050'
|
||||
}
|
||||
|
||||
const getModuleName = (key: string) => {
|
||||
const names: Record<string, string> = {
|
||||
asr: '语音识别 (ASR)',
|
||||
tts: '语音合成 (TTS)',
|
||||
ai: 'AI 对话',
|
||||
auto_agent: '自动代理',
|
||||
llm_core: 'LLM 核心'
|
||||
}
|
||||
return names[key] || key
|
||||
}
|
||||
|
||||
const getDefaultPort = (module: string) => {
|
||||
const ports: Record<string, string> = {
|
||||
asr: '20260',
|
||||
tts: '20261',
|
||||
ai: '1234',
|
||||
auto_agent: '1234',
|
||||
llm_core: '本地'
|
||||
}
|
||||
return ports[module] || '-'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return '从未'
|
||||
const diff = Date.now() - timestamp
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff/60000)}分钟前`
|
||||
return new Date(timestamp).toLocaleTimeString()
|
||||
}
|
||||
|
||||
const runSystemDiagnostics = async () => {
|
||||
try {
|
||||
await diagStore.runFullDiagnostics()
|
||||
message.success('系统体检完成')
|
||||
} catch (error) {
|
||||
message.error('体检失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const checkSingleModule = async (name: string) => {
|
||||
await diagStore.checkModule(name)
|
||||
}
|
||||
|
||||
const refreshAllModules = async () => {
|
||||
checkingAll.value = true
|
||||
await diagStore.checkAllModules()
|
||||
checkingAll.value = false
|
||||
message.success('模块状态已刷新')
|
||||
}
|
||||
|
||||
const showModuleDetails = (name: string) => {
|
||||
selectedModule.value = name
|
||||
showModuleModal.value = true
|
||||
}
|
||||
|
||||
const handleReloadConfig = async () => {
|
||||
try {
|
||||
const res = await axios.post('/api/config/reload')
|
||||
if (res.data.success) message.success('配置已重载')
|
||||
} catch {
|
||||
message.error('重载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const exportConfig = () => {
|
||||
window.open('/api/config/export', '_blank')
|
||||
}
|
||||
|
||||
const handleImportFinish = () => {
|
||||
message.success('配置导入成功,正在重载...')
|
||||
setTimeout(() => window.location.reload(), 1000)
|
||||
}
|
||||
|
||||
const handleImportError = () => {
|
||||
message.error('配置导入失败')
|
||||
}
|
||||
|
||||
let heartbeatCleanup: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
wsStore.connect()
|
||||
diagStore.initWebSocketListeners()
|
||||
heartbeatCleanup = wsStore.startHeartbeat()
|
||||
|
||||
setTimeout(() => {
|
||||
diagStore.checkAllModules()
|
||||
diagStore.startAutoCheck(30000)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (heartbeatCleanup) heartbeatCleanup()
|
||||
diagStore.stopAutoCheck()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.diagnostics-card {
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, var(--n-card-color) 0%, var(--n-action-color) 100%);
|
||||
border-left: 4px solid v-bind(statusColor);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, var(--n-card-color) 0%, var(--n-card-color) 100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.module-card,
|
||||
.action-card {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.module-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--n-action-color);
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-item:hover {
|
||||
background-color: var(--n-hover-color);
|
||||
}
|
||||
|
||||
.module-item.active {
|
||||
background-color: rgba(24, 160, 88, 0.1);
|
||||
border: 1px solid rgba(24, 160, 88, 0.3);
|
||||
}
|
||||
|
||||
.module-item.error {
|
||||
background-color: rgba(208, 48, 80, 0.1);
|
||||
border: 1px solid rgba(208, 48, 80, 0.3);
|
||||
}
|
||||
|
||||
.module-item.checking {
|
||||
background-color: rgba(240, 160, 32, 0.1);
|
||||
border: 1px solid rgba(240, 160, 32, 0.3);
|
||||
}
|
||||
|
||||
.module-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.module-status {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3);
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.module-status.healthy {
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.module-status.unhealthy {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.module-status.checking {
|
||||
color: #f0a020;
|
||||
}
|
||||
|
||||
.latency {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
margin-left: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="devices-page">
|
||||
<n-space vertical :size="16">
|
||||
<n-h2>
|
||||
<n-text type="primary">嵌入式设备管理</n-text>
|
||||
</n-h2>
|
||||
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-button @click="refreshDevices" :loading="loading" type="primary">
|
||||
<template #icon>
|
||||
<n-icon><refresh-icon /></n-icon>
|
||||
</template>
|
||||
刷新设备列表
|
||||
</n-button>
|
||||
<n-tag v-if="devices.length > 0" type="success" round>
|
||||
在线设备: {{ devices.length }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
|
||||
<n-empty v-if="!loading && devices.length === 0" description="暂无在线设备">
|
||||
<template #icon>
|
||||
<n-icon size="48"><device-icon /></n-icon>
|
||||
</template>
|
||||
</n-empty>
|
||||
|
||||
<n-grid v-else :cols="1" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="device in devices" :key="device.device_id">
|
||||
<n-card :title="device.name" hoverable>
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-tag type="success" round size="small">在线</n-tag>
|
||||
<n-popconfirm @positive-click="handleRemoveDevice(device.device_id)">
|
||||
<template #trigger>
|
||||
<n-button circle size="small" type="error">
|
||||
<template #icon><n-icon><close-icon /></n-icon></template>
|
||||
</n-button>
|
||||
</template>
|
||||
确定移除设备 {{ device.name }}?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-descriptions label-placement="left" bordered :column="1" size="small">
|
||||
<n-descriptions-item label="设备ID">
|
||||
<n-text code>{{ device.device_id }}</n-text>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="描述">
|
||||
{{ device.description || '无描述' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="注册时间">
|
||||
{{ formatTime(device.register_time) }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-h5>可用功能</n-h5>
|
||||
<n-empty v-if="!device.functions || device.functions.length === 0" description="无可用功能" />
|
||||
<n-space v-else>
|
||||
<n-tag v-for="fn in device.functions" :key="fn.name" type="info" size="small">
|
||||
{{ fn.name }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
|
||||
<template #action>
|
||||
<n-space justify="center">
|
||||
<n-button @click="showRpcModal(device)" type="primary" size="small">
|
||||
<template #icon><n-icon><terminal-icon /></n-icon></template>
|
||||
发送RPC命令
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-space>
|
||||
|
||||
<!-- RPC 发送对话框 -->
|
||||
<n-modal v-model:show="rpcModalVisible" title="发送 RPC 命令" preset="card" style="width: 600px">
|
||||
<n-space vertical>
|
||||
<n-alert type="info" :title="'目标设备: ' + (rpcTarget?.name || '')" closable>
|
||||
输入 JSON-RPC 2.0 调用字符串
|
||||
</n-alert>
|
||||
<n-input
|
||||
v-model:value="rpcPayload"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder='{"method": "set_speed", "params": {"value": 100}, "id": 1}'
|
||||
/>
|
||||
<n-space justify="end">
|
||||
<n-button @click="rpcModalVisible = false">取消</n-button>
|
||||
<n-button @click="handleSendRpc" type="primary" :loading="rpcSending">
|
||||
发送
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import {
|
||||
RefreshOutline as RefreshIcon,
|
||||
HardwareChipOutline as DeviceIcon,
|
||||
CloseOutline as CloseIcon,
|
||||
TerminalOutline as TerminalIcon
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
const message = useMessage()
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const devices = ref<any[]>([])
|
||||
const rpcModalVisible = ref(false)
|
||||
const rpcTarget = ref<any>(null)
|
||||
const rpcPayload = ref('')
|
||||
const rpcSending = ref(false)
|
||||
|
||||
function formatTime(t: number | string) {
|
||||
if (!t) return '未知'
|
||||
const d = typeof t === 'number' ? new Date(t * 1000) : new Date(t)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function refreshDevices() {
|
||||
loading.value = true
|
||||
const socket = wsStore.socket
|
||||
if (socket?.connected) {
|
||||
socket.emit('get_devices')
|
||||
} else {
|
||||
fetchDevicesHttp()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevicesHttp() {
|
||||
try {
|
||||
const res = await fetch('/api/devices')
|
||||
const json = await res.json()
|
||||
if (json.success) {
|
||||
devices.value = json.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取设备列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showRpcModal(device: any) {
|
||||
rpcTarget.value = device
|
||||
rpcPayload.value = ''
|
||||
rpcModalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSendRpc() {
|
||||
if (!rpcTarget.value || !rpcPayload.value) return
|
||||
rpcSending.value = true
|
||||
const socket = wsStore.socket
|
||||
if (socket?.connected) {
|
||||
socket.emit('send_device_rpc', {
|
||||
device_id: rpcTarget.value.device_id,
|
||||
rpc_call: rpcPayload.value
|
||||
})
|
||||
}
|
||||
rpcSending.value = false
|
||||
rpcModalVisible.value = false
|
||||
}
|
||||
|
||||
function handleRemoveDevice(deviceId: string) {
|
||||
devices.value = devices.value.filter(d => d.device_id !== deviceId)
|
||||
}
|
||||
|
||||
function onDevicesList(data: any) {
|
||||
loading.value = false
|
||||
if (data.success) {
|
||||
devices.value = data.data || []
|
||||
}
|
||||
}
|
||||
|
||||
function onDeviceRpcResult(data: any) {
|
||||
if (data.success) {
|
||||
message.success(data.message || 'RPC 命令已发送到设备')
|
||||
} else {
|
||||
message.error(data.error || 'RPC 发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDeviceRpcResponse(data: any) {
|
||||
const payload = data.payload || {}
|
||||
const result = payload.result
|
||||
const error = payload.error
|
||||
const devId = data.device_id || ''
|
||||
|
||||
if (error) {
|
||||
message.error(`设备 ${devId} RPC 返回错误: ${JSON.stringify(error)}`)
|
||||
} else {
|
||||
message.success(`设备 ${devId} RPC 返回: ${JSON.stringify(result)}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const socket = wsStore.socket
|
||||
if (socket) {
|
||||
socket.on('devices_list', onDevicesList)
|
||||
socket.on('device_rpc_result', onDeviceRpcResult)
|
||||
socket.on('device_rpc_response', onDeviceRpcResponse)
|
||||
}
|
||||
refreshDevices()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const socket = wsStore.socket
|
||||
if (socket) {
|
||||
socket.off('devices_list', onDevicesList)
|
||||
socket.off('device_rpc_result', onDeviceRpcResult)
|
||||
socket.off('device_rpc_response', onDeviceRpcResponse)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.devices-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,765 @@
|
||||
<template>
|
||||
<div class="logs-terminal-page">
|
||||
<!-- 终端工具栏 -->
|
||||
<div class="terminal-toolbar">
|
||||
<n-space align="center" justify="space-between" style="width: 100%">
|
||||
<n-space align="center">
|
||||
<n-icon size="20" color="#18a058"><terminal-icon /></n-icon>
|
||||
<span class="toolbar-title">系统日志终端</span>
|
||||
<n-divider vertical />
|
||||
|
||||
<!-- 日志统计 -->
|
||||
<n-tag size="small" :type="logStats.error > 0 ? 'error' : 'default'">
|
||||
ERROR: {{ logStats.error }}
|
||||
</n-tag>
|
||||
<n-tag size="small" type="warning">
|
||||
WARN: {{ logStats.warn }}
|
||||
</n-tag>
|
||||
<n-tag size="small" type="info">
|
||||
INFO: {{ logStats.info }}
|
||||
</n-tag>
|
||||
<span class="total-logs">总计: {{ wsStore.logs.length }} 行</span>
|
||||
</n-space>
|
||||
|
||||
<n-space align="center">
|
||||
<!-- 自动跟随开关(终端核心功能) -->
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
:type="isFollowing ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="toggleFollow"
|
||||
:class="{ 'following': isFollowing }"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<caret-down-icon v-if="isFollowing" />
|
||||
<pause-icon v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ isFollowing ? '跟随' : '暂停' }}
|
||||
</n-button>
|
||||
</template>
|
||||
<span>{{ isFollowing ? '自动滚动到底部' : '已暂停自动滚动' }}</span>
|
||||
</n-tooltip>
|
||||
|
||||
<!-- 等级筛选 -->
|
||||
<n-select
|
||||
v-model:value="selectedLevel"
|
||||
:options="levelOptions"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
@update:value="handleLevelChange"
|
||||
/>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<n-input
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索日志..."
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon><search-icon /></n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-divider vertical />
|
||||
|
||||
<n-button size="small" @click="clearLogs" type="error" quaternary>
|
||||
<template #icon><n-icon><trash-icon /></n-icon></template>
|
||||
清空
|
||||
</n-button>
|
||||
|
||||
<n-button size="small" @click="downloadLogs">
|
||||
<template #icon><n-icon><download-icon /></n-icon></template>
|
||||
导出
|
||||
</n-button>
|
||||
|
||||
<n-tag :type="wsStore.status === 'connected' ? 'success' : 'error'" size="small">
|
||||
{{ wsStore.status === 'connected' ? '● 实时' : '● 离线' }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- 终端主体区域 -->
|
||||
<div class="terminal-container" ref="terminalContainer">
|
||||
<!-- 新日志提示条(当不在底部且有新日志时显示) -->
|
||||
<div
|
||||
v-show="hasNewLogs && !isFollowing"
|
||||
class="new-logs-indicator"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<n-icon><arrow-down-icon /></n-icon>
|
||||
<span>{{ newLogsCount }} 条新日志 - 点击滚动到底部</span>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容区域(终端风格) -->
|
||||
<div
|
||||
class="terminal-content"
|
||||
ref="logContent"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
class="terminal-line"
|
||||
:class="getLogLevelClass(log)"
|
||||
>
|
||||
<!-- 时间戳 -->
|
||||
<span class="timestamp" v-if="showTimestamp">
|
||||
{{ extractTimestamp(log) }}
|
||||
</span>
|
||||
|
||||
<!-- 日志等级标签 -->
|
||||
<span class="level-badge" :class="getLogLevelClass(log)">
|
||||
{{ getLogLevelLabel(log) }}
|
||||
</span>
|
||||
|
||||
<!-- 日志内容(可点击选择复制) -->
|
||||
<span
|
||||
class="log-message"
|
||||
v-html="highlightSearch(log)"
|
||||
@dblclick="selectLine(log)"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredLogs.length === 0" class="empty-terminal">
|
||||
<n-empty description="暂无日志输出" size="small">
|
||||
<template #icon>
|
||||
<n-icon :size="40" color="#333"><terminal-outline-icon /></n-icon>
|
||||
</template>
|
||||
</n-empty>
|
||||
</div>
|
||||
|
||||
<!-- 底部锚点(用于自动滚动定位) -->
|
||||
<div ref="bottomAnchor" class="bottom-anchor"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终端状态栏 -->
|
||||
<div class="terminal-statusbar">
|
||||
<n-space align="center" justify="space-between" style="width: 100%">
|
||||
<n-space align="center" size="small">
|
||||
<span v-if="selectedLine" class="selected-info">
|
||||
已选择: {{ selectedLine.substring(0, 50) }}{{ selectedLine.length > 50 ? '...' : '' }}
|
||||
</span>
|
||||
<span v-else class="hint-text">
|
||||
提示: 双击行选择文本 | Ctrl+F 搜索 | 滚轮查看历史
|
||||
</span>
|
||||
</n-space>
|
||||
|
||||
<n-space align="center" size="small">
|
||||
<span class="status-item">滚动位置: {{ scrollPercent }}%</span>
|
||||
<span class="status-item">显示: {{ filteredLogs.length }}/{{ wsStore.logs.length }}</span>
|
||||
<span class="status-item" :class="{ 'following-active': isFollowing }">
|
||||
{{ isFollowing ? '● 跟随模式' : '○ 手动浏览' }}
|
||||
</span>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- 行详情弹窗(双击行时显示完整内容) -->
|
||||
<n-modal v-model:show="showLineDetail" title="日志详情" style="width: 800px">
|
||||
<n-card>
|
||||
<div class="line-detail-content">{{ selectedLine }}</div>
|
||||
<n-space justify="end" style="margin-top: 16px;">
|
||||
<n-button @click="copySelectedLine">复制</n-button>
|
||||
<n-button @click="showLineDetail = false">关闭</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
TrashOutline as TrashIcon,
|
||||
DownloadOutline as DownloadIcon,
|
||||
SearchOutline as SearchIcon,
|
||||
TerminalOutline as TerminalIcon,
|
||||
CaretDownOutline as CaretDownIcon,
|
||||
PauseOutline as PauseIcon,
|
||||
ArrowDownOutline as ArrowDownIcon
|
||||
} from '@vicons/ionicons5'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import { useMessage, useDialog } from 'naive-ui'
|
||||
|
||||
const wsStore = useWebSocketStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
// DOM引用
|
||||
const terminalContainer = ref<HTMLElement>()
|
||||
const logContent = ref<HTMLElement>()
|
||||
const bottomAnchor = ref<HTMLElement>()
|
||||
|
||||
// 状态
|
||||
const selectedLevel = ref('ALL')
|
||||
const searchText = ref('')
|
||||
const isFollowing = ref(true) // 终端核心:是否自动跟随底部
|
||||
const hasNewLogs = ref(false) // 有新日志但未跟随
|
||||
const newLogsCount = ref(0)
|
||||
const showTimestamp = ref(true)
|
||||
const selectedLine = ref('')
|
||||
const showLineDetail = ref(false)
|
||||
const scrollPercent = ref(100)
|
||||
|
||||
// 用于判断是否在底部的阈值(像素)
|
||||
const BOTTOM_THRESHOLD = 50
|
||||
|
||||
// 日志等级定义
|
||||
const LOG_LEVELS = [
|
||||
{ key: 'DEBUG', label: 'DEBUG', color: '#569cd6', weight: 1 },
|
||||
{ key: 'INFO', label: 'INFO', color: '#4ec9b0', weight: 2 },
|
||||
{ key: 'SUCCESS', label: 'OK', color: '#4ec9b0', weight: 2 },
|
||||
{ key: 'WARNING', label: 'WARN', color: '#dcdcaa', weight: 3 },
|
||||
{ key: 'ERROR', label: 'ERR', color: '#f44747', weight: 4 },
|
||||
{ key: 'CRITICAL', label: 'CRIT', color: '#f44747', weight: 5 }
|
||||
]
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '全部', value: 'ALL' },
|
||||
{ label: '调试', value: 'DEBUG' },
|
||||
{ label: '信息', value: 'INFO' },
|
||||
{ label: '警告', value: 'WARNING' },
|
||||
{ label: '错误', value: 'ERROR' },
|
||||
{ label: '严重', value: 'CRITICAL' }
|
||||
]
|
||||
|
||||
// 统计各等级日志数量
|
||||
const logStats = computed(() => {
|
||||
const stats = { error: 0, warn: 0, info: 0, debug: 0 }
|
||||
wsStore.logs.forEach(log => {
|
||||
const level = getLogLevel(log)
|
||||
if (level === 'error' || level === 'critical') stats.error++
|
||||
else if (level === 'warning') stats.warn++
|
||||
else if (level === 'info' || level === 'success') stats.info++
|
||||
else stats.debug++
|
||||
})
|
||||
return stats
|
||||
})
|
||||
|
||||
// 过滤后的日志
|
||||
const filteredLogs = computed(() => {
|
||||
let logs = [...wsStore.logs]
|
||||
|
||||
// 等级过滤
|
||||
if (selectedLevel.value !== 'ALL') {
|
||||
const targetLevel = selectedLevel.value.toLowerCase()
|
||||
logs = logs.filter(log => {
|
||||
const level = getLogLevel(log)
|
||||
if (selectedLevel.value === 'ERROR') {
|
||||
return level === 'error' || level === 'critical'
|
||||
}
|
||||
return level === targetLevel
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchText.value) {
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
logs = logs.filter(log => log.toLowerCase().includes(keyword))
|
||||
}
|
||||
|
||||
return logs
|
||||
})
|
||||
|
||||
// 获取日志等级
|
||||
const getLogLevel = (log: string): string => {
|
||||
for (const level of LOG_LEVELS) {
|
||||
if (log.includes(`| ${level.key} |`) ||
|
||||
log.includes(`[${level.key}]`) ||
|
||||
log.includes(` ${level.key} `)) {
|
||||
return level.key.toLowerCase()
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const getLogLevelClass = (log: string): string => {
|
||||
return getLogLevel(log)
|
||||
}
|
||||
|
||||
const getLogLevelLabel = (log: string): string => {
|
||||
const level = getLogLevel(log)
|
||||
if (level === 'other') return 'LOG'
|
||||
const found = LOG_LEVELS.find(l => l.key.toLowerCase() === level)
|
||||
return found ? found.label : 'LOG'
|
||||
}
|
||||
|
||||
// 提取时间戳
|
||||
const extractTimestamp = (log: string): string => {
|
||||
const match = log.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
|
||||
return match ? match[1].split(' ')[1] : '' // 只返回时间部分
|
||||
}
|
||||
|
||||
// 高亮搜索词
|
||||
const highlightSearch = (log: string) => {
|
||||
if (!searchText.value) return escapeHtml(log)
|
||||
const regex = new RegExp(`(${escapeRegex(searchText.value)})`, 'gi')
|
||||
return escapeHtml(log).replace(regex, '<mark>$1</mark>')
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string): string => {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
const escapeRegex = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// 核心:滚动到底部(终端行为)
|
||||
const scrollToBottom = async (smooth = false) => {
|
||||
await nextTick()
|
||||
if (bottomAnchor.value) {
|
||||
bottomAnchor.value.scrollIntoView({
|
||||
behavior: smooth ? 'smooth' : 'auto',
|
||||
block: 'end'
|
||||
})
|
||||
}
|
||||
hasNewLogs.value = false
|
||||
newLogsCount.value = 0
|
||||
scrollPercent.value = 100
|
||||
}
|
||||
|
||||
// 检查是否在底部
|
||||
const checkIfNearBottom = (): boolean => {
|
||||
if (!logContent.value) return true
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContent.value
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
return distanceToBottom < BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = () => {
|
||||
if (!logContent.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContent.value
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// 计算滚动百分比
|
||||
scrollPercent.value = Math.round(((scrollTop + clientHeight) / scrollHeight) * 100)
|
||||
|
||||
// 如果用户手动滚动离开底部,暂停自动跟随
|
||||
if (distanceToBottom > BOTTOM_THRESHOLD && isFollowing.value) {
|
||||
isFollowing.value = false
|
||||
console.log('用户手动滚动,暂停跟随')
|
||||
}
|
||||
|
||||
// 如果用户滚动到底部附近,恢复跟随
|
||||
if (distanceToBottom < BOTTOM_THRESHOLD && !isFollowing.value) {
|
||||
isFollowing.value = true
|
||||
hasNewLogs.value = false
|
||||
newLogsCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 切换跟随模式
|
||||
const toggleFollow = () => {
|
||||
isFollowing.value = !isFollowing.value
|
||||
if (isFollowing.value) {
|
||||
scrollToBottom(true)
|
||||
message.success('已恢复自动跟随')
|
||||
} else {
|
||||
message.info('已暂停自动跟随,可自由查看历史日志')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听日志变化(核心:自动滚动逻辑)
|
||||
watch(() => wsStore.logs.length, (newLength, oldLength) => {
|
||||
if (newLength > oldLength) {
|
||||
const added = newLength - oldLength
|
||||
if (!isFollowing.value) {
|
||||
// 不跟随状态下累积新日志计数
|
||||
hasNewLogs.value = true
|
||||
newLogsCount.value += added
|
||||
} else {
|
||||
// 跟随状态下自动滚到底部
|
||||
scrollToBottom(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听过滤条件变化(重新计算后保持在当前位置或底部)
|
||||
watch([selectedLevel, searchText], () => {
|
||||
if (isFollowing.value) {
|
||||
nextTick(() => scrollToBottom(false))
|
||||
}
|
||||
})
|
||||
|
||||
// 处理等级变更
|
||||
const handleLevelChange = (level: string) => {
|
||||
wsStore.subscribeWithLevel(level)
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
const clearLogs = () => {
|
||||
dialog.warning({
|
||||
title: '清空日志',
|
||||
content: '确定要清空所有日志吗?此操作不可恢复。',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
wsStore.clearLogs()
|
||||
hasNewLogs.value = false
|
||||
newLogsCount.value = 0
|
||||
message.success('日志已清空')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下载日志
|
||||
const downloadLogs = () => {
|
||||
const blob = new Blob([wsStore.logs.join('\n')], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `yosuga-logs-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.log`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 双击选择行
|
||||
const selectLine = (log: string) => {
|
||||
selectedLine.value = log
|
||||
showLineDetail.value = true
|
||||
}
|
||||
|
||||
const copySelectedLine = () => {
|
||||
navigator.clipboard.writeText(selectedLine.value)
|
||||
message.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
// 键盘快捷键
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
// 聚焦搜索框
|
||||
const searchInput = document.querySelector('.logs-terminal-page input') as HTMLInputElement
|
||||
searchInput?.focus()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
isFollowing.value = true
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始滚动到底部
|
||||
setTimeout(() => scrollToBottom(false), 100)
|
||||
|
||||
// 监听键盘
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
// 定期更新滚动位置显示(用于状态栏)
|
||||
const updateInterval = setInterval(() => {
|
||||
if (logContent.value && isFollowing.value) {
|
||||
scrollPercent.value = 100
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(updateInterval)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 终端页面布局 */
|
||||
.logs-terminal-page {
|
||||
height: calc(100vh - 140px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e; /* VS Code终端深色背景 */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.terminal-toolbar {
|
||||
padding: 12px 16px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-weight: 600;
|
||||
color: #cccccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.total-logs {
|
||||
font-size: 12px;
|
||||
color: #858585;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 终端主体 */
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 新日志提示条 */
|
||||
.new-logs-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(24, 160, 88, 0.9);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.3s;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.new-logs-indicator:hover {
|
||||
background: rgba(24, 160, 88, 1);
|
||||
}
|
||||
|
||||
/* 日志内容区域 - 终端风格 */
|
||||
.terminal-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
|
||||
/* 终端滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #424242 #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #4f4f4f;
|
||||
}
|
||||
|
||||
/* 终端行样式 */
|
||||
.terminal-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.terminal-line:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* 时间戳 */
|
||||
.timestamp {
|
||||
color: #858585;
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 等级标签 */
|
||||
.level-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 45px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.level-badge.debug {
|
||||
background-color: rgba(86, 156, 214, 0.15);
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.level-badge.info,
|
||||
.level-badge.success {
|
||||
background-color: rgba(78, 201, 176, 0.15);
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-badge.warning {
|
||||
background-color: rgba(220, 220, 170, 0.15);
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-badge.error,
|
||||
.level-badge.critical {
|
||||
background-color: rgba(244, 71, 71, 0.15);
|
||||
color: #f44747;
|
||||
}
|
||||
|
||||
.level-badge.other {
|
||||
background-color: rgba(133, 133, 133, 0.15);
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
/* 日志消息 */
|
||||
.log-message {
|
||||
flex: 1;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
/* 错误行高亮 */
|
||||
.terminal-line.error .log-message,
|
||||
.terminal-line.critical .log-message {
|
||||
color: #f44747;
|
||||
}
|
||||
|
||||
.terminal-line.warning .log-message {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.terminal-line.debug .log-message {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
/* 搜索高亮 */
|
||||
:deep(mark) {
|
||||
background-color: rgba(220, 220, 170, 0.4);
|
||||
color: #dcdcaa;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-terminal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
/* 底部锚点 */
|
||||
.bottom-anchor {
|
||||
height: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.terminal-statusbar {
|
||||
padding: 8px 16px;
|
||||
background: #252526;
|
||||
border-top: 1px solid #333;
|
||||
font-size: 12px;
|
||||
color: #858585;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
padding: 0 8px;
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
.status-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.following-active {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: #6e6e6e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
color: #4ec9b0;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 跟随按钮高亮 */
|
||||
.following {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* 行详情弹窗 */
|
||||
.line-detail-content {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.timestamp {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-toolbar .n-space:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<n-grid :x-gap="16" :y-gap="16" cols="1 l:2">
|
||||
<n-gi>
|
||||
<n-card title="CPU 使用率历史" :bordered="false">
|
||||
<v-chart class="chart" :option="cpuChartOption" autoresize />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi>
|
||||
<n-card title="内存使用历史" :bordered="false">
|
||||
<v-chart class="chart" :option="memoryChartOption" autoresize />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-card title="实时指标" class="metrics-card" :bordered="false">
|
||||
<n-descriptions bordered :column="3">
|
||||
<n-descriptions-item label="CPU 核心数">
|
||||
{{ systemStats.cpu.count }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="内存总量">
|
||||
{{ formatBytes(systemStats.memory.total) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="磁盘使用率">
|
||||
{{ systemStats.disk.percent?.toFixed(1) }}%
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="进程内存占用">
|
||||
{{ systemStats.process.memory_percent?.toFixed(2) }}%
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="进程 CPU 占用">
|
||||
{{ systemStats.process.cpu_percent?.toFixed(1) }}%
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="线程数">
|
||||
{{ systemStats.process.threads }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent
|
||||
} from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent
|
||||
])
|
||||
|
||||
const systemStats = ref({
|
||||
cpu: { percent: 0, count: 0 },
|
||||
memory: { total: 0, percent: 0 },
|
||||
disk: { percent: 0 },
|
||||
process: { memory_percent: 0, cpu_percent: 0, threads: 0 }
|
||||
})
|
||||
|
||||
const cpuHistory = ref<number[]>([])
|
||||
const memoryHistory = ref<number[]>([])
|
||||
const timeLabels = ref<string[]>([])
|
||||
|
||||
const cpuChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: timeLabels.value
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: [{
|
||||
name: 'CPU',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(24, 160, 88, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(24, 160, 88, 0.05)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
lineStyle: { color: '#18a058' },
|
||||
itemStyle: { color: '#18a058' },
|
||||
data: cpuHistory.value
|
||||
}]
|
||||
}))
|
||||
|
||||
const memoryChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: timeLabels.value
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: [{
|
||||
name: '内存',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(32, 128, 240, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(32, 128, 240, 0.05)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
lineStyle: { color: '#2080f0' },
|
||||
itemStyle: { color: '#2080f0' },
|
||||
data: memoryHistory.value
|
||||
}]
|
||||
}))
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
let intervalId: NodeJS.Timeout
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/system/info')
|
||||
if (res.data.success) {
|
||||
systemStats.value = res.data.data
|
||||
|
||||
cpuHistory.value.push(res.data.data.cpu.percent)
|
||||
memoryHistory.value.push(res.data.data.memory.percent)
|
||||
timeLabels.value.push(new Date().toLocaleTimeString())
|
||||
|
||||
if (cpuHistory.value.length > 20) {
|
||||
cpuHistory.value.shift()
|
||||
memoryHistory.value.shift()
|
||||
timeLabels.value.shift()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取监控数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
intervalId = setInterval(fetchStats, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metrics-card {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8089',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:8089',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Yosuga Server Web UI 启动脚本
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到路径
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from backend.app import run_server
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_server(host="0.0.0.0", port=8089, debug=False)
|
||||
Reference in New Issue
Block a user