889 lines
37 KiB
Python
889 lines
37 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
|
||
from utils.ai.llm_registry import LLMRegistry
|
||
from base.plugin_common.plugin_interface import PluginStatus
|
||
from utils.ai.unified_llm import UnifiedLLMClient
|
||
|
||
# 创建系统信息蓝图
|
||
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/system_health_summary')
|
||
@login_required
|
||
def api_system_health_summary():
|
||
"""聚合首页可观测性所需的关键健康信号。"""
|
||
try:
|
||
server = current_app.dashboard_server
|
||
robot = getattr(server, "robot", None)
|
||
plugin_manager = getattr(server, "plugin_manager", None)
|
||
plugin_map = getattr(plugin_manager, "plugins", {}) or {}
|
||
|
||
# 统计插件运行状态,便于首页快速判断“加载了多少、真正跑起来多少、是否有异常插件”。
|
||
plugin_status_counter = {
|
||
"total": len(plugin_map),
|
||
"running": 0,
|
||
"loaded": 0,
|
||
"stopped": 0,
|
||
"error": 0,
|
||
"unloaded": 0,
|
||
"unknown": 0,
|
||
}
|
||
for plugin in plugin_map.values():
|
||
status = getattr(plugin, "status", None)
|
||
if status == PluginStatus.RUNNING:
|
||
plugin_status_counter["running"] += 1
|
||
elif status == PluginStatus.LOADED:
|
||
plugin_status_counter["loaded"] += 1
|
||
elif status == PluginStatus.STOPPED:
|
||
plugin_status_counter["stopped"] += 1
|
||
elif status == PluginStatus.ERROR:
|
||
plugin_status_counter["error"] += 1
|
||
elif status == PluginStatus.UNLOADED:
|
||
plugin_status_counter["unloaded"] += 1
|
||
else:
|
||
plugin_status_counter["unknown"] += 1
|
||
|
||
# 错误数量直接复用现有统计库,避免为了首页卡片再单独写一套 SQL。
|
||
_, recent_error_count = server.stats_db.get_error_logs(days=1, page=1, limit=1)
|
||
|
||
# 基础设施健康:
|
||
# 1. MySQL 用最轻量的 SELECT 1 做可用性探测;
|
||
# 2. Redis 用 PING 验证连接池当前是否可拿到可用连接;
|
||
# 3. 即使探测失败也只反馈到看板,不影响主接口整体返回。
|
||
mysql_status = "healthy"
|
||
mysql_summary = "连接正常"
|
||
try:
|
||
mysql_conn = server.db_manager.get_mysql_connection()
|
||
try:
|
||
with mysql_conn.cursor() as cursor:
|
||
cursor.execute("SELECT 1")
|
||
cursor.fetchone()
|
||
finally:
|
||
mysql_conn.close()
|
||
except Exception as mysql_error:
|
||
mysql_status = "danger"
|
||
mysql_summary = f"MySQL 探测失败: {mysql_error}"
|
||
|
||
redis_status = "healthy"
|
||
redis_summary = "连接正常"
|
||
try:
|
||
redis_conn = server.db_manager.get_redis_connection()
|
||
redis_conn.ping()
|
||
except Exception as redis_error:
|
||
redis_status = "danger"
|
||
redis_summary = f"Redis 探测失败: {redis_error}"
|
||
|
||
# md2img 健康快照已经有现成实现,这里只做聚合,不主动预热运行时。
|
||
md2img_snapshot = get_md2img_health_snapshot(ensure_runtime=False) or {}
|
||
browser_ready = bool(
|
||
md2img_snapshot.get("browser_ready")
|
||
or md2img_snapshot.get("playwright_ready")
|
||
or md2img_snapshot.get("ready")
|
||
)
|
||
runtime_ready = bool(
|
||
md2img_snapshot.get("runtime_ready")
|
||
or md2img_snapshot.get("runtime_initialized")
|
||
or md2img_snapshot.get("initialized")
|
||
)
|
||
md2img_healthy = runtime_ready and browser_ready
|
||
|
||
# 首页只需要“够判断”的轻量结论,因此统一产出 status + summary 文本,前端无需重复拼装业务规则。
|
||
robot_running = bool(getattr(robot, "ipad_running", False))
|
||
robot_nickname = str(getattr(robot, "nickname", "") or "").strip()
|
||
robot_wxid = str(getattr(robot, "wxid", "") or "").strip()
|
||
robot_summary = "已连接并正在处理消息" if robot_running else "未连接或主循环未运行"
|
||
if robot_nickname or robot_wxid:
|
||
robot_summary = f"{robot_summary} · {robot_nickname or robot_wxid}"
|
||
|
||
if plugin_status_counter["error"] > 0:
|
||
plugin_status = "warning"
|
||
plugin_summary = f"异常 {plugin_status_counter['error']} 个,运行中 {plugin_status_counter['running']} / {plugin_status_counter['total']}"
|
||
elif plugin_status_counter["running"] == 0 and plugin_status_counter["total"] > 0:
|
||
plugin_status = "warning"
|
||
plugin_summary = f"暂无运行中插件,共加载 {plugin_status_counter['total']} 个"
|
||
else:
|
||
plugin_status = "healthy"
|
||
plugin_summary = f"运行中 {plugin_status_counter['running']} / {plugin_status_counter['total']}"
|
||
|
||
if recent_error_count > 0:
|
||
error_status = "warning"
|
||
error_summary = f"近 24 小时记录到 {recent_error_count} 条异常"
|
||
else:
|
||
error_status = "healthy"
|
||
error_summary = "近 24 小时未记录到异常"
|
||
|
||
if md2img_healthy:
|
||
md2img_status = "healthy"
|
||
md2img_summary = "运行时与浏览器均已就绪"
|
||
elif runtime_ready or browser_ready:
|
||
md2img_status = "warning"
|
||
md2img_summary = "运行时部分可用,建议检查预热状态"
|
||
else:
|
||
md2img_status = "danger"
|
||
md2img_summary = "运行时未就绪,相关转图能力可能不可用"
|
||
|
||
# AI 运行态:
|
||
# 1. 统一从 UnifiedLLMClient 最近调用窗口读取,避免各插件单独维护监控数据;
|
||
# 2. 若当前窗口还没有调用记录,就明确返回“暂无调用”,避免误判成异常。
|
||
ai_runtime = UnifiedLLMClient.get_runtime_snapshot()
|
||
ai_total_calls = int(ai_runtime.get("total_calls") or 0)
|
||
ai_failed_calls = int(ai_runtime.get("failed_calls") or 0)
|
||
if ai_total_calls <= 0:
|
||
ai_status = "warning"
|
||
ai_summary = "最近窗口内暂无统一 LLM 调用记录"
|
||
elif ai_failed_calls > 0:
|
||
ai_status = "warning"
|
||
ai_summary = (
|
||
f"最近 {ai_total_calls} 次调用中失败 {ai_failed_calls} 次,"
|
||
f"平均耗时 {ai_runtime.get('avg_latency_ms', 0)}ms"
|
||
)
|
||
else:
|
||
ai_status = "healthy"
|
||
ai_summary = (
|
||
f"最近 {ai_total_calls} 次调用全部成功,"
|
||
f"平均耗时 {ai_runtime.get('avg_latency_ms', 0)}ms"
|
||
)
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"robot": {
|
||
"status": "healthy" if robot_running else "danger",
|
||
"running": robot_running,
|
||
"nickname": robot_nickname,
|
||
"wxid": robot_wxid,
|
||
"summary": robot_summary,
|
||
},
|
||
"plugins": {
|
||
"status": plugin_status,
|
||
"summary": plugin_summary,
|
||
**plugin_status_counter,
|
||
},
|
||
"errors": {
|
||
"status": error_status,
|
||
"recent_24h_count": recent_error_count,
|
||
"summary": error_summary,
|
||
},
|
||
"infrastructure": {
|
||
"status": "healthy" if mysql_status == "healthy" and redis_status == "healthy" else "danger",
|
||
"summary": (
|
||
"MySQL / Redis 均正常"
|
||
if mysql_status == "healthy" and redis_status == "healthy"
|
||
else "存在基础设施连接异常"
|
||
),
|
||
"mysql": {
|
||
"status": mysql_status,
|
||
"summary": mysql_summary,
|
||
},
|
||
"redis": {
|
||
"status": redis_status,
|
||
"summary": redis_summary,
|
||
},
|
||
},
|
||
"ai_runtime": {
|
||
"status": ai_status,
|
||
"summary": ai_summary,
|
||
**ai_runtime,
|
||
},
|
||
"md2img": {
|
||
"status": md2img_status,
|
||
"healthy": md2img_healthy,
|
||
"runtime_ready": runtime_ready,
|
||
"browser_ready": browser_ready,
|
||
"summary": md2img_summary,
|
||
"detail": md2img_snapshot,
|
||
},
|
||
}
|
||
})
|
||
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()
|
||
# 保存 YAML 后立刻把运行时依赖对象同步一遍,避免必须重启进程才能读到新值。
|
||
server.robot.apply_runtime_config(reload_catalog=True)
|
||
else:
|
||
# 即便当前没有可用 robot 实例,也尽量把 LLM 路由缓存清掉,避免后续请求短时间内读旧值。
|
||
LLMRegistry.invalidate_cache()
|
||
|
||
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()
|
||
# LLM 目录保存到 MySQL 后,需要主动失效运行时缓存,保证插件下一次调用直接走新目录。
|
||
server.robot.apply_runtime_config(reload_catalog=True)
|
||
else:
|
||
LLMRegistry.invalidate_cache()
|
||
|
||
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
|