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 _legacy_llm_to_catalog(legacy_llm: dict) -> dict: """把旧 llm(backends/scenes) 结构转换为新目录结构(仅用于兜底展示)。 说明: 1. 该转换不写库,只用于当目录表不可用时让后台页面仍可展示; 2. 规则与 DB bootstrap 一致:dify backend 拆成 provider+dify_app,其他保留为 backend。 """ llm = legacy_llm or {} old_backends = llm.get("backends", {}) or {} old_scenes = llm.get("scenes", {}) or {} default_backend = str(llm.get("default_backend") or "").strip() providers = [] dify_apps = [] backends = [] scenes = [] dify_template_cfg = None for backend in old_backends.values(): if isinstance(backend, dict) and str(backend.get("provider") or "").strip().lower() == "dify": dify_template_cfg = dict(backend) break if dify_template_cfg: providers.append( { "name": "dify_workflow_default", "provider_type": "dify", "enabled": True, "config": { "provider": "dify", "api_base_url": dify_template_cfg.get("api_base_url", ""), "endpoint": dify_template_cfg.get("endpoint", "workflows/run"), "mode": dify_template_cfg.get("mode", "workflow"), "response_mode": dify_template_cfg.get("response_mode", "blocking"), "request_timeout": dify_template_cfg.get("request_timeout", 60), "max_retries": dify_template_cfg.get("max_retries", 3), "retry_delay_seconds": dify_template_cfg.get("retry_delay_seconds", 1.0), }, } ) for backend_name, backend_cfg in old_backends.items(): if not isinstance(backend_cfg, dict): continue provider = str(backend_cfg.get("provider") or "").strip().lower() if provider == "dify": dify_apps.append( { "name": str(backend_name), "provider_template": "dify_workflow_default", "app_key": str(backend_cfg.get("api_key") or "").strip(), "workflow_output_key": str(backend_cfg.get("workflow_output_key") or "text").strip(), "enabled": True, "config": { "endpoint": backend_cfg.get("endpoint", ""), "mode": backend_cfg.get("mode", ""), "response_mode": backend_cfg.get("response_mode", ""), "request_timeout": backend_cfg.get("request_timeout", ""), }, } ) else: backends.append( { "name": str(backend_name), "enabled": True, "config": dict(backend_cfg), } ) if isinstance(old_scenes, dict) and old_scenes: for scene_name, backend_name in old_scenes.items(): scene_name = str(scene_name or "").strip() backend_name = str(backend_name or "").strip() if not scene_name or not backend_name: continue backend_cfg = old_backends.get(backend_name, {}) or {} provider = str((backend_cfg or {}).get("provider") or "").strip().lower() scenes.append( { "name": scene_name, "target_type": "dify_app" if provider == "dify" else "backend", "target_ref": backend_name, "enabled": True, } ) elif default_backend: default_cfg = old_backends.get(default_backend, {}) or {} provider = str((default_cfg or {}).get("provider") or "").strip().lower() scenes.append( { "name": "main.default", "target_type": "dify_app" if provider == "dify" else "backend", "target_ref": default_backend, "enabled": True, } ) default_scene = scenes[0]["name"] if scenes else "" return { "default_scene": default_scene, "providers": providers, "dify_apps": dify_apps, "backends": backends, "scenes": scenes, } def _load_llm_catalog_runtime() -> dict: """读取运行时 LLM 目录配置(优先 MySQL 新模型)。""" try: server = current_app.dashboard_server llm_catalog_db = getattr(server, "llm_catalog_db", None) if llm_catalog_db: catalog = llm_catalog_db.get_catalog() or {} if catalog and catalog.get("scenes"): return catalog except Exception as e: logger.warning(f"从 MySQL 读取 LLM 目录失败,回退 YAML: {e}") # 兜底:把 YAML 的 legacy llm 转成目录结构给后台展示。 config_obj = _load_system_yaml() llm_config = config_obj.get("llm", {}) or {} if not isinstance(llm_config, dict): llm_config = {} return _legacy_llm_to_catalog(llm_config) def _save_llm_catalog_runtime(catalog: dict) -> None: """保存运行时 LLM 目录配置到 MySQL。""" server = current_app.dashboard_server llm_catalog_db = getattr(server, "llm_catalog_db", None) if not llm_catalog_db: raise RuntimeError("llm_catalog_db 未初始化,无法保存 LLM 目录到 MySQL") ok = llm_catalog_db.save_catalog(catalog or {}) 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 拓扑视图(供后台页面直观展示依赖关系)。""" catalog = _load_llm_catalog_runtime() providers = {str(item.get("name") or "").strip(): item for item in (catalog.get("providers", []) or [])} dify_apps = {str(item.get("name") or "").strip(): item for item in (catalog.get("dify_apps", []) or [])} backends = {str(item.get("name") or "").strip(): item for item in (catalog.get("backends", []) or [])} scenes = {str(item.get("name") or "").strip(): item for item in (catalog.get("scenes", []) or [])} default_scene = str(catalog.get("default_scene") 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.get(scene_name, {}) or {} target_type = str(scene.get("target_type") or "").strip().lower() target_ref = str(scene.get("target_ref") or "").strip() resolved_provider = "" resolved_target = target_ref valid_target = False if target_type == "dify_app": app = dify_apps.get(target_ref, {}) or {} provider_name = str(app.get("provider_template") or "").strip() provider = providers.get(provider_name, {}) or {} resolved_provider = str(provider.get("provider_type") or "").strip() valid_target = bool(app and provider) elif target_type == "backend": backend = backends.get(target_ref, {}) or {} backend_cfg = (backend.get("config") or {}) if isinstance(backend, dict) else {} resolved_provider = str((backend_cfg or {}).get("provider") or "").strip() valid_target = bool(backend) topology_rows.append({ "plugin": usage.get("plugin", ""), "section": usage.get("section", ""), "scene": scene_name, "target_type": target_type or "-", "target_ref": resolved_target or "-", "provider": resolved_provider or "-", "valid_scene": bool(scene_name in scenes), "valid_target": valid_target, }) return { "default_scene": default_scene, "providers": catalog.get("providers", []) or [], "dify_apps": catalog.get("dify_apps", []) or [], "backends": catalog.get("backends", []) or [], "scenes": catalog.get("scenes", []) or [], "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() # 展示运行时目录中的目标对象(backend+dify_app),便于调试 scene 绑定。 catalog = _load_llm_catalog_runtime() backend_names = [str(item.get("name") or "").strip() for item in (catalog.get("backends", []) or [])] app_names = [f"dify_app::{str(item.get('name') or '').strip()}" for item in (catalog.get("dify_apps", []) or [])] return jsonify({ "success": True, "data": config_text, "path": config_path, "llm_backends": sorted([name for name in backend_names + app_names if name]), }) 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: catalog = _load_llm_catalog_runtime() providers = sorted((catalog.get("providers", []) or []), key=lambda item: str(item.get("name") or "")) dify_apps = sorted((catalog.get("dify_apps", []) or []), key=lambda item: str(item.get("name") or "")) backends = sorted((catalog.get("backends", []) or []), key=lambda item: str(item.get("name") or "")) scenes = sorted((catalog.get("scenes", []) or []), key=lambda item: str(item.get("name") or "")) topology = _build_llm_topology() return jsonify({ "success": True, "data": { "default_scene": catalog.get("default_scene", ""), "providers": providers, "dify_apps": dify_apps, "backends": backends, "scenes": scenes, "topology_rows": topology.get("topology_rows", []), "plugin_usages": topology.get("plugin_usages", []), # 新目录模型主存储在 MySQL。 "config_path": ( "mysql:t_llm_provider_templates + t_llm_dify_apps + " "t_llm_backends + t_llm_scenes (fallback yaml)" ), } }) 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_scene = str(data.get("default_scene") or "").strip() provider_list = data.get("providers", []) or [] dify_app_list = data.get("dify_apps", []) or [] backend_list = data.get("backends", []) or [] scene_list = data.get("scenes", []) or [] if not isinstance(provider_list, list): return jsonify({"success": False, "message": "providers 格式不正确"}), 400 if not isinstance(dify_app_list, list): return jsonify({"success": False, "message": "dify_apps 格式不正确"}), 400 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 # 目录级校验:先收集名字集合,便于 scene target 引用校验。 provider_names = { str((item or {}).get("name") or "").strip() for item in provider_list if isinstance(item, dict) and str((item or {}).get("name") or "").strip() } dify_app_names = { str((item or {}).get("name") or "").strip() for item in dify_app_list if isinstance(item, dict) and str((item or {}).get("name") or "").strip() } backend_names = { str((item or {}).get("name") or "").strip() for item in backend_list if isinstance(item, dict) and str((item or {}).get("name") or "").strip() } for app in dify_app_list: if not isinstance(app, dict): continue app_name = str(app.get("name") or "").strip() if not app_name: continue provider_template = str(app.get("provider_template") or "").strip() if not provider_template: return jsonify({"success": False, "message": f"Dify应用 {app_name} 未绑定 Provider 模板"}), 400 if provider_template not in provider_names: return jsonify({"success": False, "message": f"Dify应用 {app_name} 绑定的 Provider 不存在"}), 400 app_key = str(app.get("app_key") or "").strip() if not app_key: return jsonify({"success": False, "message": f"Dify应用 {app_name} 缺少 app_key"}), 400 scene_names = set() for scene in scene_list: if not isinstance(scene, dict): continue scene_name = str(scene.get("name") or "").strip() target_type = str(scene.get("target_type") or "").strip().lower() target_ref = str(scene.get("target_ref") or "").strip() if not scene_name: continue if scene_name in scene_names: return jsonify({"success": False, "message": f"场景名重复: {scene_name}"}), 400 scene_names.add(scene_name) if target_type not in {"dify_app", "backend"}: return jsonify({"success": False, "message": f"场景 {scene_name} target_type 非法"}), 400 if not target_ref: return jsonify({"success": False, "message": f"场景 {scene_name} 未绑定目标"}), 400 if target_type == "dify_app" and target_ref not in dify_app_names: return jsonify({"success": False, "message": f"场景 {scene_name} 绑定的 dify_app 不存在"}), 400 if target_type == "backend" and target_ref not in backend_names: return jsonify({"success": False, "message": f"场景 {scene_name} 绑定的 backend 不存在"}), 400 if default_scene and default_scene not in scene_names: return jsonify({"success": False, "message": "默认场景不存在"}), 400 catalog = { "default_scene": default_scene, "providers": provider_list, "dify_apps": dify_app_list, "backends": backend_list, "scenes": scene_list, } _save_llm_catalog_runtime(catalog) 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