变更项: 1) 新增 t_llm_config 数据访问层与建表逻辑。 2) Robot 启动时自动初始化并在空库时从 YAML 导入。 3) 后台 system LLM API 改为读写 MySQL。 4) LLMRegistry 改为优先 MySQL 读取并回退 YAML。 5) DashboardServer 挂载 llm_config_db 提供后台访问。
550 lines
21 KiB
Python
550 lines
21 KiB
Python
from flask import Blueprint, render_template, jsonify, request, send_from_directory, current_app, Response
|
||
from .auth import login_required
|
||
from loguru import logger
|
||
import os
|
||
import time
|
||
import subprocess
|
||
from datetime import datetime
|
||
import platform
|
||
import psutil
|
||
from collections import deque
|
||
import gzip
|
||
import json
|
||
import yaml
|
||
import toml
|
||
from utils.markdown_to_image import get_md2img_health_snapshot, warmup_md2img_browser_sync
|
||
|
||
# 创建系统信息蓝图
|
||
system_bp = Blueprint('system', __name__)
|
||
|
||
# 记录应用启动时间
|
||
APP_START_TIME = time.time()
|
||
|
||
|
||
def _system_config_path() -> str:
|
||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'config.yaml'))
|
||
|
||
|
||
def _load_system_yaml() -> dict:
|
||
config_path = _system_config_path()
|
||
if not os.path.exists(config_path):
|
||
return {}
|
||
with open(config_path, 'r', encoding='utf-8') as f:
|
||
return yaml.safe_load(f) or {}
|
||
|
||
|
||
def _save_system_yaml(config_obj: dict) -> None:
|
||
config_path = _system_config_path()
|
||
with open(config_path, 'w', encoding='utf-8') as f:
|
||
yaml.safe_dump(config_obj, f, allow_unicode=True, sort_keys=False)
|
||
|
||
|
||
def _load_llm_config_runtime() -> dict:
|
||
"""读取运行时 LLM 配置。
|
||
|
||
读取优先级:
|
||
1. 优先从机器人挂载的 MySQL 配置读取(主数据源);
|
||
2. 若数据库对象不可用或读取异常,回退到 config.yaml(兜底)。
|
||
"""
|
||
try:
|
||
server = current_app.dashboard_server
|
||
llm_config_db = getattr(server, "llm_config_db", None)
|
||
if llm_config_db:
|
||
row = llm_config_db.get_config() or {}
|
||
if row:
|
||
return {
|
||
"default_backend": row.get("default_backend", ""),
|
||
"backends": row.get("backends", {}) or {},
|
||
"scenes": row.get("scenes", {}) or {},
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"从 MySQL 读取 LLM 配置失败,回退 YAML: {e}")
|
||
|
||
config_obj = _load_system_yaml()
|
||
llm_config = config_obj.get("llm", {}) or {}
|
||
return llm_config if isinstance(llm_config, dict) else {}
|
||
|
||
|
||
def _save_llm_config_runtime(llm_config: dict) -> None:
|
||
"""保存运行时 LLM 配置到主数据源(MySQL)。"""
|
||
server = current_app.dashboard_server
|
||
llm_config_db = getattr(server, "llm_config_db", None)
|
||
if not llm_config_db:
|
||
raise RuntimeError("llm_config_db 未初始化,无法保存 LLM 配置到 MySQL")
|
||
ok = llm_config_db.save_config(llm_config or {}, source="admin")
|
||
if not ok:
|
||
raise RuntimeError("保存 LLM 配置到 MySQL 失败")
|
||
|
||
|
||
def _plugins_root_path() -> str:
|
||
"""返回插件根目录绝对路径。"""
|
||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'plugins'))
|
||
|
||
|
||
def _scan_plugin_llm_usage() -> list:
|
||
"""扫描各插件 config.toml,提取插件与 LLM 场景的引用关系。
|
||
|
||
说明:
|
||
1. 该扫描仅用于后台可视化,不会改写插件配置;
|
||
2. 严格模式只采集 scene:顶层 section 写法,或嵌套在 llm/api/report_api 等节点;
|
||
3. 返回结果用于“插件 -> scene -> backend”依赖拓扑展示。
|
||
"""
|
||
plugins_root = _plugins_root_path()
|
||
if not os.path.isdir(plugins_root):
|
||
return []
|
||
|
||
usages = []
|
||
|
||
def _collect_refs(plugin_name: str, section_name: str, payload: dict) -> None:
|
||
"""从单个配置节点收集 scene 引用。"""
|
||
if not isinstance(payload, dict):
|
||
return
|
||
scene_name = str(payload.get("scene") or "").strip()
|
||
if not scene_name:
|
||
return
|
||
usages.append({
|
||
"plugin": plugin_name,
|
||
"section": section_name,
|
||
"scene": scene_name,
|
||
})
|
||
|
||
for item in sorted(os.listdir(plugins_root)):
|
||
plugin_dir = os.path.join(plugins_root, item)
|
||
if not os.path.isdir(plugin_dir):
|
||
continue
|
||
config_path = os.path.join(plugin_dir, "config.toml")
|
||
if not os.path.exists(config_path):
|
||
continue
|
||
try:
|
||
config_obj = toml.load(config_path) or {}
|
||
except Exception as e:
|
||
logger.warning(f"扫描插件 LLM 依赖失败: plugin={item}, path={config_path}, error={e}")
|
||
continue
|
||
|
||
# 优先扫描每个 section:兼容 [Dify] / [api] / [Douyu.report_api] 等写法。
|
||
for section_name, section_value in config_obj.items():
|
||
if isinstance(section_value, dict):
|
||
_collect_refs(item, str(section_name), section_value)
|
||
# 二层兜底:处理 llm/api/report_api 等嵌套节点。
|
||
for nested_name, nested_value in section_value.items():
|
||
if isinstance(nested_value, dict):
|
||
_collect_refs(item, f"{section_name}.{nested_name}", nested_value)
|
||
# 顶层兜底:兼容极少数直接写在根节点的 scene。
|
||
_collect_refs(item, "__root__", config_obj if isinstance(config_obj, dict) else {})
|
||
|
||
# 去重:同插件同 section 仅保留一条记录,避免前后兜底重复。
|
||
unique = {}
|
||
for row in usages:
|
||
key = f"{row.get('plugin')}::{row.get('section')}::{row.get('scene')}"
|
||
unique[key] = row
|
||
return sorted(unique.values(), key=lambda x: (x.get("plugin", ""), x.get("section", "")))
|
||
|
||
|
||
def _build_llm_topology() -> dict:
|
||
"""构建 LLM 拓扑视图(供后台页面直观展示依赖关系)。"""
|
||
llm_config = _load_llm_config_runtime()
|
||
scenes = llm_config.get("scenes", {}) or {}
|
||
backends = llm_config.get("backends", {}) or {}
|
||
default_backend = str(llm_config.get("default_backend", "") or "").strip()
|
||
|
||
plugin_usages = _scan_plugin_llm_usage()
|
||
topology_rows = []
|
||
for usage in plugin_usages:
|
||
scene_name = str(usage.get("scene") or "").strip()
|
||
# 严格模式:插件必须声明 scene,后端统一由 scenes 映射解析。
|
||
resolved_backend = str(scenes.get(scene_name) or "").strip()
|
||
if not resolved_backend:
|
||
resolved_backend = default_backend
|
||
|
||
topology_rows.append({
|
||
"plugin": usage.get("plugin", ""),
|
||
"section": usage.get("section", ""),
|
||
"scene": scene_name,
|
||
"resolved_backend": resolved_backend,
|
||
"provider": str((backends.get(resolved_backend) or {}).get("provider", "") or "").strip(),
|
||
"valid_scene": bool(scene_name in scenes),
|
||
"valid_backend": bool((not resolved_backend) or resolved_backend in backends),
|
||
})
|
||
|
||
return {
|
||
"default_backend": default_backend,
|
||
"scenes": scenes if isinstance(scenes, dict) else {},
|
||
"backends": backends if isinstance(backends, dict) else {},
|
||
"plugin_usages": plugin_usages,
|
||
"topology_rows": topology_rows,
|
||
}
|
||
|
||
|
||
@system_bp.route('/api_docs')
|
||
@login_required
|
||
def api_docs():
|
||
src = request.args.get('src')
|
||
if not src:
|
||
try:
|
||
server = current_app.dashboard_server
|
||
cfg = getattr(server.robot, "ipad_config", {}) or {}
|
||
src = cfg.get("server_url", "http://127.0.0.1:8059/")
|
||
except Exception:
|
||
src = "http://127.0.0.1:8059/"
|
||
return render_template('api_docs.html', src_url=src)
|
||
|
||
|
||
@system_bp.route('/system_status')
|
||
@login_required
|
||
def system_status():
|
||
src = request.args.get('src')
|
||
if not src:
|
||
try:
|
||
server = current_app.dashboard_server
|
||
glances = getattr(server.robot, "config").glances if hasattr(server.robot, "config") else {}
|
||
host = glances.get("host", "127.0.0.1")
|
||
port = glances.get("port", 61208)
|
||
src = f"http://{host}:{port}/"
|
||
except Exception:
|
||
src = "http://127.0.0.1:61208/"
|
||
return render_template('system_status.html', src_url=src)
|
||
|
||
|
||
@system_bp.route('/system_llm')
|
||
@login_required
|
||
def system_llm():
|
||
return render_template('system_llm.html')
|
||
|
||
|
||
# 页面路由
|
||
@system_bp.route('/wx_logs')
|
||
@login_required
|
||
def wx_logs():
|
||
return render_template('wx_logs.html')
|
||
|
||
|
||
# API路由
|
||
@system_bp.route('/api/system_info')
|
||
@login_required
|
||
def api_system_info():
|
||
try:
|
||
# 获取系统信息
|
||
system_info = {
|
||
"os": platform.system(),
|
||
"os_version": platform.version(),
|
||
"python_version": platform.python_version(),
|
||
"cpu_usage": psutil.cpu_percent(),
|
||
"memory_usage": psutil.virtual_memory().percent,
|
||
"disk_usage": psutil.disk_usage('/').percent,
|
||
"uptime": time.time() - APP_START_TIME, # 使用应用启动时间计算运行时长
|
||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"open_files": len(psutil.Process(os.getpid()).open_files())
|
||
}
|
||
|
||
return jsonify({"success": True, "data": system_info})
|
||
except Exception as e:
|
||
logger.error(f"获取系统信息失败: {e}")
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/wx_logs')
|
||
@login_required
|
||
def api_wx_logs():
|
||
try:
|
||
log_type = request.args.get('type', 'info') # 默认显示info日志
|
||
lines = request.args.get('lines', 100, type=int) # 默认显示最后100行
|
||
|
||
# 修正日志文件路径计算,获取项目根目录
|
||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..','logs'))
|
||
|
||
if log_type == 'error':
|
||
log_file = os.path.join(project_root, 'wx_error.log')
|
||
elif log_type == 'debug':
|
||
log_file = os.path.join(project_root, 'wx_debug.log')
|
||
else:
|
||
log_file = os.path.join(project_root, 'wx_info.log')
|
||
|
||
log_content = []
|
||
if os.path.exists(log_file):
|
||
try:
|
||
chunk_size = 8192
|
||
with open(log_file, 'rb') as f:
|
||
f.seek(0, os.SEEK_END)
|
||
size = f.tell()
|
||
buffer = b""
|
||
pos = size
|
||
while pos > 0 and buffer.count(b'\n') <= lines:
|
||
read_size = chunk_size if pos >= chunk_size else pos
|
||
pos -= read_size
|
||
f.seek(pos)
|
||
buffer = f.read(read_size) + buffer
|
||
log_content = [b.decode('utf-8', errors='ignore') for b in buffer.splitlines()[-lines:]]
|
||
except Exception as e:
|
||
logger.error(f"高效读取日志失败,回退到常规方式: {e}")
|
||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||
log_content = list(deque(f, lines))
|
||
else:
|
||
logger.warning(f"日志文件不存在: {log_file}")
|
||
# 尝试列出项目根目录下的所有日志文件,帮助调试
|
||
try:
|
||
all_files = [f for f in os.listdir(project_root) if f.endswith('.log')]
|
||
logger.info(f"项目根目录下的日志文件: {all_files}")
|
||
except Exception as e:
|
||
logger.error(f"列出目录文件失败: {e}")
|
||
|
||
payload = {
|
||
"success": True,
|
||
"data": {
|
||
"log_type": log_type,
|
||
"log_file": log_file,
|
||
"content": log_content,
|
||
"lines": len(log_content)
|
||
}
|
||
}
|
||
accept = request.headers.get('Accept-Encoding', '')
|
||
if 'gzip' in accept.lower():
|
||
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||
gz = gzip.compress(body, compresslevel=6)
|
||
resp = Response(gz, mimetype='application/json')
|
||
resp.headers['Content-Encoding'] = 'gzip'
|
||
return resp
|
||
return jsonify(payload)
|
||
except Exception as e:
|
||
logger.error(f"获取微信日志失败: {e}")
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
# 在现有路由下添加
|
||
@system_bp.route('/api/current_user_info', methods=['GET'])
|
||
@login_required
|
||
def get_current_user_info():
|
||
"""获取当前登录的微信用户信息"""
|
||
dashboard_server = current_app.dashboard_server
|
||
result = dashboard_server.get_current_user_info()
|
||
return jsonify(result)
|
||
|
||
|
||
@system_bp.route('/api/system/config/raw', methods=['GET'])
|
||
@login_required
|
||
def get_system_config_raw():
|
||
try:
|
||
config_path = _system_config_path()
|
||
with open(config_path, 'r', encoding='utf-8') as f:
|
||
config_text = f.read()
|
||
# 这里展示“运行时有效”的 LLM 后端列表(优先 MySQL),避免与 YAML 展示不一致。
|
||
llm_config = _load_llm_config_runtime()
|
||
llm_backends = (llm_config or {}).get("backends", {})
|
||
return jsonify({
|
||
"success": True,
|
||
"data": config_text,
|
||
"path": config_path,
|
||
"llm_backends": list((llm_backends or {}).keys()),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"读取系统配置失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/system/config/update', methods=['POST'])
|
||
@login_required
|
||
def update_system_config():
|
||
try:
|
||
server = current_app.dashboard_server
|
||
data = request.get_json() or {}
|
||
config_text = data.get("config_text")
|
||
if config_text is None:
|
||
return jsonify({"success": False, "message": "缺少配置内容"}), 400
|
||
|
||
yaml.safe_load(config_text)
|
||
config_path = _system_config_path()
|
||
with open(config_path, 'w', encoding='utf-8') as f:
|
||
f.write(config_text)
|
||
|
||
if getattr(server, "robot", None) and getattr(server.robot, "config", None):
|
||
server.robot.config.reload()
|
||
|
||
return jsonify({"success": True, "message": "全局配置已保存"})
|
||
except Exception as e:
|
||
logger.error(f"保存系统配置失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/system/llm_config', methods=['GET'])
|
||
@login_required
|
||
def get_system_llm_config():
|
||
try:
|
||
llm_config = _load_llm_config_runtime()
|
||
backends = llm_config.get("backends", {}) or {}
|
||
scenes = llm_config.get("scenes", {}) or {}
|
||
backend_list = []
|
||
for name, backend in backends.items():
|
||
if not isinstance(backend, dict):
|
||
continue
|
||
item = dict(backend)
|
||
item["name"] = name
|
||
backend_list.append(item)
|
||
backend_list.sort(key=lambda item: item.get("name", ""))
|
||
|
||
scene_list = []
|
||
if isinstance(scenes, dict):
|
||
for scene_name, backend_name in scenes.items():
|
||
scene_name = str(scene_name or "").strip()
|
||
backend_name = str(backend_name or "").strip()
|
||
if not scene_name:
|
||
continue
|
||
scene_list.append({
|
||
"name": scene_name,
|
||
"backend": backend_name,
|
||
})
|
||
scene_list.sort(key=lambda item: item.get("name", ""))
|
||
|
||
topology = _build_llm_topology()
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"default_backend": llm_config.get("default_backend", ""),
|
||
"backends": backend_list,
|
||
"scenes": scene_list,
|
||
"topology_rows": topology.get("topology_rows", []),
|
||
"plugin_usages": topology.get("plugin_usages", []),
|
||
# 配置来源改为 MySQL;保留 YAML 路径用于排障与一次性导入核对。
|
||
"config_path": f"mysql:t_llm_config (fallback yaml: {_system_config_path()})",
|
||
}
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"读取全局 LLM 配置失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/system/llm_config', methods=['POST'])
|
||
@login_required
|
||
def update_system_llm_config():
|
||
try:
|
||
server = current_app.dashboard_server
|
||
data = request.get_json() or {}
|
||
default_backend = str(data.get("default_backend") or "").strip()
|
||
backend_list = data.get("backends", []) or []
|
||
scene_list = data.get("scenes", []) or []
|
||
if not isinstance(backend_list, list):
|
||
return jsonify({"success": False, "message": "backends 格式不正确"}), 400
|
||
if not isinstance(scene_list, list):
|
||
return jsonify({"success": False, "message": "scenes 格式不正确"}), 400
|
||
|
||
normalized_backends = {}
|
||
for raw in backend_list:
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
name = str(raw.get("name") or "").strip()
|
||
if not name:
|
||
continue
|
||
item = {}
|
||
for key, value in raw.items():
|
||
if key == "name":
|
||
continue
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, str):
|
||
value = value.strip()
|
||
if value == "":
|
||
continue
|
||
item[key] = value
|
||
normalized_backends[name] = item
|
||
|
||
if default_backend and default_backend not in normalized_backends:
|
||
return jsonify({"success": False, "message": "默认后端不存在"}), 400
|
||
|
||
normalized_scenes = {}
|
||
for raw in scene_list:
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
scene_name = str(raw.get("name") or "").strip()
|
||
backend_name = str(raw.get("backend") or "").strip()
|
||
if not scene_name:
|
||
continue
|
||
# 严格模式:每个 scene 必须绑定一个有效 backend,避免“空绑定”导致运行时不确定性。
|
||
if not backend_name:
|
||
return jsonify({"success": False, "message": f"场景 {scene_name} 未绑定后端"}), 400
|
||
if backend_name not in normalized_backends:
|
||
return jsonify({"success": False, "message": f"场景 {scene_name} 绑定的后端不存在"}), 400
|
||
normalized_scenes[scene_name] = backend_name
|
||
|
||
llm_config = {
|
||
"default_backend": default_backend,
|
||
"backends": normalized_backends,
|
||
"scenes": normalized_scenes,
|
||
}
|
||
_save_llm_config_runtime(llm_config)
|
||
|
||
if getattr(server, "robot", None) and getattr(server.robot, "config", None):
|
||
server.robot.config.reload()
|
||
|
||
return jsonify({"success": True, "message": "全局 LLM 配置已保存"})
|
||
except Exception as e:
|
||
logger.error(f"保存全局 LLM 配置失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/system/md2img_health', methods=['GET'])
|
||
@login_required
|
||
def get_md2img_health():
|
||
"""查询 Markdown 转图运行时健康状态。"""
|
||
try:
|
||
# 默认只读取状态,不主动拉起 runtime。
|
||
# 当后台希望“刷新并顺便拉起”时,可传 ensure_runtime=true。
|
||
ensure_runtime = str(request.args.get('ensure_runtime', 'false')).strip().lower() in {'1', 'true', 'yes', 'on'}
|
||
data = get_md2img_health_snapshot(ensure_runtime=ensure_runtime)
|
||
return jsonify({"success": True, "data": data})
|
||
except Exception as e:
|
||
logger.error(f"获取 md2img 健康状态失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/system/md2img_warmup', methods=['POST'])
|
||
@login_required
|
||
def trigger_md2img_warmup():
|
||
"""手动触发 Markdown 转图浏览器预热。"""
|
||
try:
|
||
payload = request.get_json(silent=True) or {}
|
||
timeout_seconds = int(payload.get('timeout_seconds', 45) or 45)
|
||
timeout_seconds = max(10, min(timeout_seconds, 180))
|
||
ok = warmup_md2img_browser_sync(timeout_seconds=timeout_seconds)
|
||
data = get_md2img_health_snapshot(ensure_runtime=False)
|
||
if ok:
|
||
return jsonify({
|
||
"success": True,
|
||
"message": f"预热完成(timeout={timeout_seconds}s)",
|
||
"data": data,
|
||
})
|
||
return jsonify({
|
||
"success": False,
|
||
"message": f"预热失败(timeout={timeout_seconds}s),请查看运行日志",
|
||
"data": data,
|
||
}), 500
|
||
except Exception as e:
|
||
logger.error(f"触发 md2img 预热失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|
||
|
||
|
||
@system_bp.route('/api/restart_service', methods=['POST'])
|
||
@login_required
|
||
def restart_service():
|
||
"""调用项目根目录下的 restart.sh 重启服务"""
|
||
try:
|
||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||
script_path = os.path.join(project_root, 'restart.sh')
|
||
|
||
if not os.path.exists(script_path):
|
||
return jsonify({"success": False, "message": f"未找到脚本: {script_path}"}), 404
|
||
|
||
subprocess.Popen(
|
||
['bash', script_path],
|
||
cwd=project_root,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
start_new_session=True
|
||
)
|
||
|
||
logger.warning(f"后台触发服务重启脚本: {script_path}")
|
||
return jsonify({
|
||
"success": True,
|
||
"message": "已触发重启脚本,服务将在短时间内重启"
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"触发服务重启失败: {e}")
|
||
return jsonify({"success": False, "message": str(e)}), 500
|