Files
abot/admin/dashboard/blueprints/system.py
liuwei 1446bf5f39 feat: 将LLM配置主存储迁移到MySQL
变更项: 1) 新增 t_llm_config 数据访问层与建表逻辑。 2) Robot 启动时自动初始化并在空库时从 YAML 导入。 3) 后台 system LLM API 改为读写 MySQL。 4) LLMRegistry 改为优先 MySQL 读取并回退 YAML。 5) DashboardServer 挂载 llm_config_db 提供后台访问。
2026-04-20 14:51:43 +08:00

550 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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