diff --git a/admin/dashboard/blueprints/system.py b/admin/dashboard/blueprints/system.py index e099747..766fad9 100644 --- a/admin/dashboard/blueprints/system.py +++ b/admin/dashboard/blueprints/system.py @@ -39,41 +39,143 @@ def _save_system_yaml(config_obj: dict) -> None: yaml.safe_dump(config_obj, f, allow_unicode=True, sort_keys=False) -def _load_llm_config_runtime() -> dict: - """读取运行时 LLM 配置。 +def _legacy_llm_to_catalog(legacy_llm: dict) -> dict: + """把旧 llm(backends/scenes) 结构转换为新目录结构(仅用于兜底展示)。 - 读取优先级: - 1. 优先从机器人挂载的 MySQL 配置读取(主数据源); - 2. 若数据库对象不可用或读取异常,回退到 config.yaml(兜底)。 + 说明: + 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_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 {}, - } + 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}") + logger.warning(f"从 MySQL 读取 LLM 目录失败,回退 YAML: {e}") + # 兜底:把 YAML 的 legacy llm 转成目录结构给后台展示。 config_obj = _load_system_yaml() llm_config = config_obj.get("llm", {}) or {} - return llm_config if isinstance(llm_config, dict) else {} + if not isinstance(llm_config, dict): + llm_config = {} + return _legacy_llm_to_catalog(llm_config) -def _save_llm_config_runtime(llm_config: dict) -> None: - """保存运行时 LLM 配置到主数据源(MySQL)。""" +def _save_llm_catalog_runtime(catalog: 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") + 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 失败") + raise RuntimeError("保存 LLM 目录到 MySQL 失败") def _plugins_root_path() -> str: @@ -142,34 +244,53 @@ def _scan_plugin_llm_usage() -> list: 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() + 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 映射解析。 - resolved_backend = str(scenes.get(scene_name) or "").strip() - if not resolved_backend: - resolved_backend = default_backend + 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, - "resolved_backend": resolved_backend, - "provider": str((backends.get(resolved_backend) or {}).get("provider", "") or "").strip(), + "target_type": target_type or "-", + "target_ref": resolved_target or "-", + "provider": resolved_provider or "-", "valid_scene": bool(scene_name in scenes), - "valid_backend": bool((not resolved_backend) or resolved_backend in backends), + "valid_target": valid_target, }) return { - "default_backend": default_backend, - "scenes": scenes if isinstance(scenes, dict) else {}, - "backends": backends if isinstance(backends, dict) else {}, + "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, } @@ -326,14 +447,15 @@ def get_system_config_raw(): 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", {}) + # 展示运行时目录中的目标对象(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": list((llm_backends or {}).keys()), + "llm_backends": sorted([name for name in backend_names + app_names if name]), }) except Exception as e: logger.error(f"读取系统配置失败: {e}") @@ -368,42 +490,27 @@ def update_system_config(): @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", "")) - + 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_backend": llm_config.get("default_backend", ""), - "backends": backend_list, - "scenes": scene_list, + "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;保留 YAML 路径用于排障与一次性导入核对。 - "config_path": f"mysql:t_llm_config (fallback yaml: {_system_config_path()})", + # 新目录模型主存储在 MySQL。 + "config_path": ( + "mysql:t_llm_provider_templates + t_llm_dify_apps + " + "t_llm_backends + t_llm_scenes (fallback yaml)" + ), } }) except Exception as e: @@ -417,58 +524,85 @@ 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() + 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 - 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 + # 目录级校验:先收集名字集合,便于 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() + } - 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): + for app in dify_app_list: + if not isinstance(app, dict): continue - scene_name = str(raw.get("name") or "").strip() - backend_name = str(raw.get("backend") or "").strip() + 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 - # 严格模式:每个 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 + 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 - llm_config = { - "default_backend": default_backend, - "backends": normalized_backends, - "scenes": normalized_scenes, + 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_config_runtime(llm_config) + _save_llm_catalog_runtime(catalog) if getattr(server, "robot", None) and getattr(server.robot, "config", None): server.robot.config.reload() diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index a8f79ed..0d0d782 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -51,6 +51,7 @@ class DashboardServer: self.plugin_schedule_db = robot_instance.plugin_schedule_db self.plugin_schedule_manager = robot_instance.plugin_schedule_manager self.group_plugin_config_db = robot_instance.group_plugin_config_db + self.llm_catalog_db = robot_instance.llm_catalog_db self.llm_config_db = robot_instance.llm_config_db self.group_plugin_config_service = robot_instance.group_plugin_config_service # 获取联系人管理器实例 diff --git a/admin/dashboard/templates/system_llm.html b/admin/dashboard/templates/system_llm.html index 0c5638a..f7a0b32 100644 --- a/admin/dashboard/templates/system_llm.html +++ b/admin/dashboard/templates/system_llm.html @@ -1,18 +1,17 @@ {% extends "base.html" %} -{% block title %}全局配置 - 机器人管理后台{% endblock %} +{% block title %}LLM目录配置 - 机器人管理后台{% endblock %} {% block content %}
集中维护全局 LLM 后端,插件只引用后端名,不再分散配置密钥和地址。
+按 Provider 模板、Dify 应用、Scene 绑定三层维护,减少重复配置和切换成本。
用表单统一管理 `config.yaml` 中的 `llm.backends`,系统和插件共享同一份后端配置。
+默认场景用于兜底路由,建议始终设置一个稳定可用的场景。
用业务场景绑定后端。插件优先引用 scene,再由 scene 统一路由到 backend。
-公共连接参数只配置一次:base_url、endpoint、mode、超时等。
展示 插件 -> scene -> backend -> provider 的实际映射,便于定位切换影响范围。
-每个应用只维护 app_key 和少量差异项,不再重复写 URL/endpoint 公共参数。
用于 openai_compatible 或其他非 Dify 直连能力。
+业务场景绑定 dify_app 或 backend,插件只配置 scene。
+显示 插件 -> scene -> target_type/target_ref -> provider,便于评估切换影响。
+