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 Workspace
-

全局配置

-

集中维护全局 LLM 后端,插件只引用后端名,不再分散配置密钥和地址。

+
LLM Catalog
+

LLM目录配置

+

按 Provider 模板、Dify 应用、Scene 绑定三层维护,减少重复配置和切换成本。

刷新 - 新增后端 保存配置
@@ -20,173 +19,165 @@
-

全局 LLM 配置

-

用表单统一管理 `config.yaml` 中的 `llm.backends`,系统和插件共享同一份后端配置。

+

目录元信息

+

默认场景用于兜底路由,建议始终设置一个稳定可用的场景。

- 配置文件:{% raw %}{{ configPath }}{% endraw %} - 后端数量:{% raw %}{{ llmForm.backends.length }}{% endraw %} + 配置源:{% raw %}{{ configPath }}{% endraw %}
- - - - - + + + +
- -
-
-

场景路由(Scene Binding)

-

用业务场景绑定后端。插件优先引用 scene,再由 scene 统一路由到 backend。

-
- 新增场景 + +
+
+

1. Provider 模板

+

公共连接参数只配置一次:base_url、endpoint、mode、超时等。

- -
-
- - - + 新增模板 +
+
+ +
+ {% raw %}{{ item.name || `Provider ${index + 1}` }}{% endraw %} + 删除 +
+
+ + + + - 删除 + + + + + +
-
- - - -
- -
-
- {% raw %}{{ backend.name || `后端 ${index + 1}` }}{% endraw %} -
- 删除 -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
-
+ + - - - -
-
-

插件依赖拓扑

-

展示 插件 -> scene -> backend -> provider 的实际映射,便于定位切换影响范围。

-
+ +
+
+

2. Dify 应用

+

每个应用只维护 app_key 和少量差异项,不再重复写 URL/endpoint 公共参数。

- - - - - - - - - - - - - - + 新增应用 +
+
+ +
+ {% raw %}{{ item.name || `DifyApp ${index + 1}` }}{% endraw %} + 删除 +
+
+ + + + + + + + + + +
+
+
+ +
+ + +
+
+

3. 通用 Backend(可选)

+

用于 openai_compatible 或其他非 Dify 直连能力。

+
+ 新增Backend +
+
+ +
+ {% raw %}{{ item.name || `Backend ${index + 1}` }}{% endraw %} + 删除 +
+
+ + + + + + + + +
+
+
+ +
+ + +
+
+

4. Scene 绑定

+

业务场景绑定 dify_app 或 backend,插件只配置 scene。

+
+ 新增场景 +
+
+
+ + + + + + + + + 删除 +
+
+ +
+ + +
+
+

插件依赖拓扑

+

显示 插件 -> scene -> target_type/target_ref -> provider,便于评估切换影响。

+
+
+ + + + + + + + + + + +
{% endblock %} @@ -201,18 +192,27 @@ currentView: '17', configPath: '', topologyRows: [], - llmForm: { - default_backend: '', + catalog: { + default_scene: '', + providers: [], + dify_apps: [], backends: [], scenes: [] } } }, computed: { + sceneNameOptions() { + return (this.catalog.scenes || []).map(item => item.name).filter(Boolean); + }, + providerNameOptions() { + return (this.catalog.providers || []).map(item => item.name).filter(Boolean); + }, backendNameOptions() { - return (this.llmForm.backends || []) - .map(item => item.name) - .filter(Boolean); + return (this.catalog.backends || []).map(item => item.name).filter(Boolean); + }, + difyAppNameOptions() { + return (this.catalog.dify_apps || []).map(item => item.name).filter(Boolean); } }, mounted() { @@ -220,129 +220,230 @@ this.loadLlmConfig(); }, methods: { - newBackend() { - // 统一后端对象结构,保证新增/编辑时字段完整,避免后端清洗时丢键。 + newUid() { + return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + }, + // Provider 模板:只放公共字段,避免 Dify 每个应用重复填写。 + newProvider() { return { - uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + uid: this.newUid(), name: '', - provider: 'dify', - mode: '', - model: '', - api_base_url: '', - api_url: '', - endpoint: '', - api_key: '', - response_mode: '', - workflow_output_key: '', - timeout_seconds: 60, - request_timeout: 60, - temperature: 0.7, - max_tokens: 1024, - max_retries: 3, - retry_delay_seconds: 1.0, - stream: false + provider_type: 'dify', + enabled: true, + config: { + provider: 'dify', + api_base_url: '', + endpoint: 'workflows/run', + mode: 'workflow', + response_mode: 'blocking', + request_timeout: 60, + max_retries: 3, + retry_delay_seconds: 1.0 + } + }; + }, + // Dify 应用:只保留 app_key 与少量覆盖项,维护成本最低。 + newDifyApp() { + return { + uid: this.newUid(), + name: '', + provider_template: '', + app_key: '', + workflow_output_key: 'text', + enabled: true, + config: { + mode: '', + endpoint: '', + response_mode: '', + request_timeout: 60 + } + }; + }, + newBackend() { + return { + uid: this.newUid(), + name: '', + enabled: true, + config: { + provider: 'openai_compatible', + api_base_url: '', + endpoint: 'chat/completions', + api_key: '', + model: '', + temperature: 0.7, + max_tokens: 1024 + } }; }, newScene() { - // Scene 是“业务场景 -> 后端”的路由单元,插件建议只依赖 scene。 return { - uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + uid: this.newUid(), name: '', - backend: '' + target_type: 'dify_app', + target_ref: '', + enabled: true + }; + }, + normalizeProvider(item) { + return { + uid: this.newUid(), + name: item.name || '', + provider_type: item.provider_type || 'dify', + enabled: item.enabled !== false, + config: { ...(item.config || {}) } + }; + }, + normalizeDifyApp(item) { + return { + uid: this.newUid(), + name: item.name || '', + provider_template: item.provider_template || '', + app_key: item.app_key || '', + workflow_output_key: item.workflow_output_key || 'text', + enabled: item.enabled !== false, + config: { ...(item.config || {}) } }; }, normalizeBackend(item) { return { - uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + uid: this.newUid(), name: item.name || '', - provider: item.provider || 'dify', - mode: item.mode || '', - model: item.model || '', - api_base_url: item.api_base_url || '', - api_url: item.api_url || '', - endpoint: item.endpoint || '', - api_key: item.api_key || '', - response_mode: item.response_mode || '', - workflow_output_key: item.workflow_output_key || '', - timeout_seconds: item.timeout_seconds ?? 60, - request_timeout: item.request_timeout ?? 60, - temperature: item.temperature ?? 0.7, - max_tokens: item.max_tokens ?? 1024, - max_retries: item.max_retries ?? 3, - retry_delay_seconds: item.retry_delay_seconds ?? 1.0, - stream: !!item.stream + enabled: item.enabled !== false, + config: { ...(item.config || {}) } }; }, normalizeScene(item) { return { - uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + uid: this.newUid(), name: item.name || '', - backend: item.backend || '' + target_type: item.target_type || 'dify_app', + target_ref: item.target_ref || '', + enabled: item.enabled !== false }; }, - addBackend() { - this.llmForm.backends.push(this.newBackend()); - }, - addScene() { - this.llmForm.scenes.push(this.newScene()); - }, - removeBackend(index) { - const removed = this.llmForm.backends[index]; - this.llmForm.backends.splice(index, 1); - if (removed && removed.name && this.llmForm.default_backend === removed.name) { - this.llmForm.default_backend = ''; - } - // 后端被删除后,自动清理引用它的 scene,避免保存时出现无效绑定。 + addProvider() { this.catalog.providers.push(this.newProvider()); }, + removeProvider(index) { + const removed = this.catalog.providers[index]; + this.catalog.providers.splice(index, 1); if (removed && removed.name) { - (this.llmForm.scenes || []).forEach(scene => { - if (scene.backend === removed.name) { - scene.backend = ''; + (this.catalog.dify_apps || []).forEach(app => { + if (app.provider_template === removed.name) { + app.provider_template = ''; } }); } }, + addDifyApp() { this.catalog.dify_apps.push(this.newDifyApp()); }, + removeDifyApp(index) { + const removed = this.catalog.dify_apps[index]; + this.catalog.dify_apps.splice(index, 1); + if (removed && removed.name) { + (this.catalog.scenes || []).forEach(scene => { + if (scene.target_type === 'dify_app' && scene.target_ref === removed.name) { + scene.target_ref = ''; + } + }); + } + }, + addBackend() { this.catalog.backends.push(this.newBackend()); }, + removeBackend(index) { + const removed = this.catalog.backends[index]; + this.catalog.backends.splice(index, 1); + if (removed && removed.name) { + (this.catalog.scenes || []).forEach(scene => { + if (scene.target_type === 'backend' && scene.target_ref === removed.name) { + scene.target_ref = ''; + } + }); + } + }, + addScene() { this.catalog.scenes.push(this.newScene()); }, removeScene(index) { - this.llmForm.scenes.splice(index, 1); + const removed = this.catalog.scenes[index]; + this.catalog.scenes.splice(index, 1); + if (removed && removed.name && this.catalog.default_scene === removed.name) { + this.catalog.default_scene = ''; + } + }, + getSceneTargetOptions(targetType) { + if (targetType === 'backend') { + return this.backendNameOptions; + } + return this.difyAppNameOptions; }, async loadLlmConfig() { try { const response = await axios.get('/api/system/llm_config'); - if (response.data.success) { - const data = response.data.data || {}; - this.configPath = data.config_path || ''; - this.llmForm.default_backend = data.default_backend || ''; - this.llmForm.backends = (data.backends || []).map(item => this.normalizeBackend(item)); - this.llmForm.scenes = (data.scenes || []).map(item => this.normalizeScene(item)); - this.topologyRows = data.topology_rows || []; - } else { - this.$message.error(response.data.message || '读取全局 LLM 配置失败'); + if (!response.data.success) { + this.$message.error(response.data.message || '读取 LLM 目录失败'); + return; } + const data = response.data.data || {}; + this.configPath = data.config_path || ''; + this.catalog.default_scene = data.default_scene || ''; + this.catalog.providers = (data.providers || []).map(item => this.normalizeProvider(item)); + this.catalog.dify_apps = (data.dify_apps || []).map(item => this.normalizeDifyApp(item)); + this.catalog.backends = (data.backends || []).map(item => this.normalizeBackend(item)); + this.catalog.scenes = (data.scenes || []).map(item => this.normalizeScene(item)); + this.topologyRows = data.topology_rows || []; } catch (error) { - this.$message.error(error.response?.data?.message || '读取全局 LLM 配置失败'); + this.$message.error(error.response?.data?.message || '读取 LLM 目录失败'); } }, async saveLlmConfig() { - // 前端先做一次严格校验,避免把空场景名或未绑定后端的记录提交到后端。 - const invalidScene = (this.llmForm.scenes || []).find(item => { - return !String(item.name || '').trim() || !String(item.backend || '').trim(); - }); - if (invalidScene) { - this.$message.error('场景配置不完整:请确保每一行都填写场景名并绑定后端'); + // 严格校验:防止提交残缺目录导致运行时无法路由。 + const invalidProvider = (this.catalog.providers || []).find(item => !String(item.name || '').trim()); + if (invalidProvider) { + this.$message.error('Provider 模板名称不能为空'); return; } + const invalidApp = (this.catalog.dify_apps || []).find(item => { + return !String(item.name || '').trim() + || !String(item.provider_template || '').trim() + || !String(item.app_key || '').trim(); + }); + if (invalidApp) { + this.$message.error('Dify 应用缺少必填项(name/provider_template/app_key)'); + return; + } + const invalidScene = (this.catalog.scenes || []).find(item => { + return !String(item.name || '').trim() + || !String(item.target_type || '').trim() + || !String(item.target_ref || '').trim(); + }); + if (invalidScene) { + this.$message.error('Scene 绑定缺少必填项(name/target_type/target_ref)'); + return; + } + if (this.catalog.default_scene && !this.sceneNameOptions.includes(this.catalog.default_scene)) { + this.$message.error('默认场景不存在,请重新选择'); + return; + } + const payload = { - default_backend: this.llmForm.default_backend || '', - backends: (this.llmForm.backends || []).map(item => { + default_scene: this.catalog.default_scene || '', + providers: (this.catalog.providers || []).map(item => { const cleaned = { ...item }; delete cleaned.uid; return cleaned; }), - scenes: (this.llmForm.scenes || []).map(item => { + dify_apps: (this.catalog.dify_apps || []).map(item => { + const cleaned = { ...item }; + delete cleaned.uid; + return cleaned; + }), + backends: (this.catalog.backends || []).map(item => { + const cleaned = { ...item }; + delete cleaned.uid; + return cleaned; + }), + scenes: (this.catalog.scenes || []).map(item => { const cleaned = { ...item }; delete cleaned.uid; return cleaned; }) }; + try { const response = await axios.post('/api/system/llm_config', payload); if (response.data.success) { @@ -359,45 +460,37 @@ }); {% endblock %} diff --git a/db/llm_catalog_db.py b/db/llm_catalog_db.py new file mode 100644 index 0000000..53297c6 --- /dev/null +++ b/db/llm_catalog_db.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +import json +from typing import Any, Dict, List, Optional + +from loguru import logger + +from db.connection import DBConnectionManager + + +class LLMCatalogDBOperator: + """LLM 目录配置数据库操作器(Provider 模板 / Dify 应用 / Scene 绑定)。 + + 设计原则: + 1. Provider 模板:保存公共连接参数(如 base_url/endpoint/mode/timeout); + 2. Dify 应用:只保存差异项(核心是 app_key、output_key、provider_ref); + 3. Scene 绑定:业务场景只绑定目标(dify_app 或 backend),不直接关心底层细节。 + """ + + def __init__(self, db_manager: DBConnectionManager): + self.db_manager = db_manager + self.LOG = logger + + def _loads_json(self, value: Any, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """将 JSON 字段统一解析为 dict。""" + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + obj = json.loads(value) + return obj if isinstance(obj, dict) else (default or {}) + except json.JSONDecodeError: + return default or {} + return default or {} + + def init_tables(self) -> bool: + """初始化 LLM 目录相关表。""" + conn = self.db_manager.get_mysql_connection() + try: + with conn.cursor() as cursor: + # Provider 模板表:保存供应商公共配置。 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS t_llm_provider_templates ( + name VARCHAR(128) PRIMARY KEY, + provider_type VARCHAR(64) NOT NULL, + config_json JSON NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """ + ) + # Dify 应用表:每个应用只需维护 app_key 与少量覆盖参数。 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS t_llm_dify_apps ( + name VARCHAR(128) PRIMARY KEY, + provider_template VARCHAR(128) NOT NULL, + app_key VARCHAR(255) NOT NULL, + workflow_output_key VARCHAR(128) DEFAULT 'text', + config_json JSON NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_provider_template (provider_template) + ) + """ + ) + # 通用后端表:用于非 Dify(如 openai_compatible)或特殊场景直连配置。 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS t_llm_backends ( + name VARCHAR(128) PRIMARY KEY, + config_json JSON NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """ + ) + # Scene 绑定表:业务场景绑定到 dify_app 或 backend。 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS t_llm_scenes ( + name VARCHAR(128) PRIMARY KEY, + target_type VARCHAR(32) NOT NULL, + target_ref VARCHAR(128) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_target (target_type, target_ref) + ) + """ + ) + # 元信息表:存储 default_scene 等全局参数。 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS t_llm_catalog_meta ( + meta_key VARCHAR(64) PRIMARY KEY, + meta_value VARCHAR(255) NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """ + ) + conn.commit() + return True + except Exception as e: + conn.rollback() + self.LOG.error(f"初始化 LLM 目录表失败: {e}") + return False + finally: + conn.close() + + def get_catalog(self) -> Dict[str, Any]: + """读取完整目录配置。""" + conn = self.db_manager.get_mysql_connection() + try: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT name, provider_type, config_json, enabled FROM t_llm_provider_templates ORDER BY name") + providers = cursor.fetchall() or [] + + cursor.execute( + """ + SELECT name, provider_template, app_key, workflow_output_key, config_json, enabled + FROM t_llm_dify_apps + ORDER BY name + """ + ) + dify_apps = cursor.fetchall() or [] + + cursor.execute("SELECT name, config_json, enabled FROM t_llm_backends ORDER BY name") + backends = cursor.fetchall() or [] + + cursor.execute("SELECT name, target_type, target_ref, enabled FROM t_llm_scenes ORDER BY name") + scenes = cursor.fetchall() or [] + + cursor.execute( + "SELECT meta_value FROM t_llm_catalog_meta WHERE meta_key='default_scene' LIMIT 1" + ) + meta_row = cursor.fetchone() or {} + + return { + "default_scene": str(meta_row.get("meta_value") or "").strip(), + "providers": [ + { + "name": str(row.get("name") or "").strip(), + "provider_type": str(row.get("provider_type") or "").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + "config": self._loads_json(row.get("config_json"), {}), + } + for row in providers + ], + "dify_apps": [ + { + "name": str(row.get("name") or "").strip(), + "provider_template": str(row.get("provider_template") or "").strip(), + "app_key": str(row.get("app_key") or "").strip(), + "workflow_output_key": str(row.get("workflow_output_key") or "text").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + "config": self._loads_json(row.get("config_json"), {}), + } + for row in dify_apps + ], + "backends": [ + { + "name": str(row.get("name") or "").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + "config": self._loads_json(row.get("config_json"), {}), + } + for row in backends + ], + "scenes": [ + { + "name": str(row.get("name") or "").strip(), + "target_type": str(row.get("target_type") or "").strip(), + "target_ref": str(row.get("target_ref") or "").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + } + for row in scenes + ], + } + except Exception as e: + self.LOG.error(f"读取 LLM 目录失败: {e}") + return {} + finally: + conn.close() + + def save_catalog(self, catalog: Dict[str, Any]) -> bool: + """保存完整目录配置(覆盖式)。""" + data = catalog or {} + providers = data.get("providers", []) or [] + dify_apps = data.get("dify_apps", []) or [] + backends = data.get("backends", []) or [] + scenes = data.get("scenes", []) or [] + default_scene = str(data.get("default_scene") or "").strip() + + conn = self.db_manager.get_mysql_connection() + try: + with conn.cursor() as cursor: + # 覆盖式保存前先清空旧数据,保证后台提交后的结果与数据库一致。 + cursor.execute("DELETE FROM t_llm_provider_templates") + cursor.execute("DELETE FROM t_llm_dify_apps") + cursor.execute("DELETE FROM t_llm_backends") + cursor.execute("DELETE FROM t_llm_scenes") + + for item in providers: + name = str((item or {}).get("name") or "").strip() + if not name: + continue + provider_type = str((item or {}).get("provider_type") or "dify").strip() + enabled = 1 if (item or {}).get("enabled", True) else 0 + config_json = json.dumps((item or {}).get("config", {}) or {}, ensure_ascii=False) + cursor.execute( + """ + INSERT INTO t_llm_provider_templates (name, provider_type, config_json, enabled) + VALUES (%s, %s, %s, %s) + """, + (name, provider_type, config_json, enabled), + ) + + for item in dify_apps: + name = str((item or {}).get("name") or "").strip() + if not name: + continue + provider_template = str((item or {}).get("provider_template") or "").strip() + app_key = str((item or {}).get("app_key") or "").strip() + workflow_output_key = str((item or {}).get("workflow_output_key") or "text").strip() + enabled = 1 if (item or {}).get("enabled", True) else 0 + config_json = json.dumps((item or {}).get("config", {}) or {}, ensure_ascii=False) + cursor.execute( + """ + INSERT INTO t_llm_dify_apps ( + name, provider_template, app_key, workflow_output_key, config_json, enabled + ) VALUES (%s, %s, %s, %s, %s, %s) + """, + (name, provider_template, app_key, workflow_output_key, config_json, enabled), + ) + + for item in backends: + name = str((item or {}).get("name") or "").strip() + if not name: + continue + enabled = 1 if (item or {}).get("enabled", True) else 0 + config_json = json.dumps((item or {}).get("config", {}) or {}, ensure_ascii=False) + cursor.execute( + """ + INSERT INTO t_llm_backends (name, config_json, enabled) + VALUES (%s, %s, %s) + """, + (name, config_json, enabled), + ) + + for item in scenes: + name = str((item or {}).get("name") or "").strip() + if not name: + continue + target_type = str((item or {}).get("target_type") or "dify_app").strip() + target_ref = str((item or {}).get("target_ref") or "").strip() + enabled = 1 if (item or {}).get("enabled", True) else 0 + cursor.execute( + """ + INSERT INTO t_llm_scenes (name, target_type, target_ref, enabled) + VALUES (%s, %s, %s, %s) + """, + (name, target_type, target_ref, enabled), + ) + + # default_scene 放入 meta 表,便于后续新增更多目录级参数。 + cursor.execute( + """ + INSERT INTO t_llm_catalog_meta (meta_key, meta_value) + VALUES ('default_scene', %s) + ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value) + """, + (default_scene,), + ) + conn.commit() + return True + except Exception as e: + conn.rollback() + self.LOG.error(f"保存 LLM 目录失败: {e}") + return False + finally: + conn.close() + + def bootstrap_from_legacy_llm(self, legacy_llm: Dict[str, Any]) -> bool: + """从旧版 llm(backends/scenes) 配置初始化新目录。 + + 迁移策略(简化版): + 1. 若目录已有 scenes,则不重复导入; + 2. 旧配置中 provider=dify 的 backend 自动拆成: + - 一个 provider 模板(默认名 dify_workflow_default,优先取 workflow 配置); + - 多个 dify_app(每个旧 backend 一个 app); + 3. 非 dify backend 原样放入 backends; + 4. scenes 按旧映射自动绑定: + - 指向 dify backend -> target_type=dify_app; + - 其他 -> target_type=backend。 + """ + try: + catalog = self.get_catalog() or {} + if catalog.get("scenes"): + return True + + 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() + + if not isinstance(old_backends, dict): + old_backends = {} + if not isinstance(old_scenes, dict): + old_scenes = {} + + providers: List[Dict[str, Any]] = [] + dify_apps: List[Dict[str, Any]] = [] + backends: List[Dict[str, Any]] = [] + scenes: List[Dict[str, Any]] = [] + + # 选取一个 Dify backend 作为模板来源。 + 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, + # Provider 模板只保留公共项,避免 app 层重复。 + "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), + }, + } + ) + + # 拆分旧 backends。 + 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": { + # app 级可覆盖模板项:只存差异,减少维护量。 + "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), + } + ) + + # 场景映射优先使用旧 scenes;若无 scenes 则按 default_backend 兜底生成 main.default。 + if 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() + if provider == "dify": + scenes.append( + { + "name": scene_name, + "target_type": "dify_app", + "target_ref": backend_name, + "enabled": True, + } + ) + else: + scenes.append( + { + "name": scene_name, + "target_type": "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 self.save_catalog( + { + "default_scene": default_scene, + "providers": providers, + "dify_apps": dify_apps, + "backends": backends, + "scenes": scenes, + } + ) + except Exception as e: + self.LOG.error(f"从旧 llm 配置迁移目录失败: {e}") + return False diff --git a/robot.py b/robot.py index 9eb6ef6..ed4a4c4 100644 --- a/robot.py +++ b/robot.py @@ -18,6 +18,7 @@ from configuration import Config from db.connection import DBConnectionManager from db.contacts_db import ContactsDBOperator from db.group_plugin_config_db import GroupPluginConfigDBOperator +from db.llm_catalog_db import LLMCatalogDBOperator from db.llm_config_db import LLMConfigDBOperator from db.plugin_schedule_db import PluginScheduleDBOperator from db.system_job_db import SystemJobDBOperator @@ -72,10 +73,14 @@ class Robot: self.contacts_db = ContactsDBOperator(self.db_manager) self.group_plugin_config_db = GroupPluginConfigDBOperator(self.db_manager) + self.llm_catalog_db = LLMCatalogDBOperator(self.db_manager) self.llm_config_db = LLMConfigDBOperator(self.db_manager) self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager) self.system_job_db = SystemJobDBOperator(self.db_manager) self.group_plugin_config_db.init_tables() + # 新版 LLM 目录模型(Provider 模板 / Dify 应用 / Scene)初始化。 + self.llm_catalog_db.init_tables() + self.llm_catalog_db.bootstrap_from_legacy_llm(self.config.llm) # LLM 配置迁移到 MySQL: # 1. 先确保表存在; # 2. 若库里没有配置,则从 config.yaml 的 llm 节点导入一次; diff --git a/utils/ai/llm_registry.py b/utils/ai/llm_registry.py index 4261e41..55dd223 100644 --- a/utils/ai/llm_registry.py +++ b/utils/ai/llm_registry.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import time from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import yaml @@ -11,18 +11,17 @@ from db.connection import DBConnectionManager class LLMRegistry: - """集中式 LLM 配置注册器。 + """统一 LLM 路由注册器(scene -> target -> final_config)。 - 读取优先级: - 1. 优先读取 MySQL(t_llm_config); - 2. MySQL 不可用或无数据时,回退读取 config.yaml 的 llm 节点。 + 当前支持两套数据源: + 1. 新目录模型(推荐):MySQL 的 provider_templates / dify_apps / backends / scenes; + 2. 旧模型兜底:config.yaml 的 llm(backends/scenes)。 """ _cache: Dict[str, Any] = { - # cache_until: 缓存过期时间戳,避免每次调用都打数据库; - # data: 最近一次成功读取并归一化后的 llm 配置对象。 "cache_until": 0.0, - "data": {}, + "catalog": {}, + "legacy_llm": {}, } @classmethod @@ -30,40 +29,37 @@ class LLMRegistry: return Path(__file__).resolve().parents[2] / "config.yaml" @classmethod - def _load_llm_from_yaml(cls) -> Dict[str, Any]: - """从 YAML 读取 llm 配置(兜底来源)。""" + def _load_yaml_root(cls) -> Dict[str, Any]: + """读取 YAML 根配置(仅作为迁移兜底)。""" path = cls.get_root_config_path() if not path.exists(): return {} - with open(path, "r", encoding="utf-8") as fp: - root = yaml.safe_load(fp) or {} - llm_config = root.get("llm", {}) or {} - if not isinstance(llm_config, dict): - return {} - return llm_config + return yaml.safe_load(fp) or {} + + @classmethod + def _load_yaml_legacy_llm(cls) -> Dict[str, Any]: + """读取旧 llm 结构(backends/scenes)作为回退。""" + root = cls._load_yaml_root() + llm = root.get("llm", {}) or {} + return llm if isinstance(llm, dict) else {} @staticmethod - def _loads_json(value: Any) -> Dict[str, Any]: - """把数据库 JSON 字段统一解析为 dict。""" + def _loads_json(value: Any, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """把 JSON 字段安全转为 dict。""" if isinstance(value, dict): return value if isinstance(value, str): try: obj = json.loads(value) - return obj if isinstance(obj, dict) else {} + return obj if isinstance(obj, dict) else (default or {}) except json.JSONDecodeError: - return {} - return {} + return default or {} + return default or {} @classmethod - def _load_llm_from_mysql(cls) -> Dict[str, Any]: - """从 MySQL 读取 llm 配置。 - - 注意: - 1. 该函数必须“无副作用失败”,即任何异常都返回空 dict,交由上层做 YAML 回退; - 2. 不依赖 Robot 实例,直接走 DBConnectionManager 单例,便于在插件调用链路中复用。 - """ + def _load_catalog_from_mysql(cls) -> Dict[str, Any]: + """从 MySQL 读取新目录模型。""" try: db_manager = DBConnectionManager.get_instance() if not db_manager or not db_manager.mysql_pool: @@ -72,68 +68,240 @@ class LLMRegistry: conn = db_manager.get_mysql_connection() try: with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT name, provider_type, config_json, enabled FROM t_llm_provider_templates") + provider_rows = cursor.fetchall() or [] + cursor.execute( """ - SELECT default_backend, backends_json, scenes_json - FROM t_llm_config - WHERE id = 1 - LIMIT 1 + SELECT name, provider_template, app_key, workflow_output_key, config_json, enabled + FROM t_llm_dify_apps """ ) - row = cursor.fetchone() or {} + app_rows = cursor.fetchall() or [] + + cursor.execute("SELECT name, config_json, enabled FROM t_llm_backends") + backend_rows = cursor.fetchall() or [] + + cursor.execute("SELECT name, target_type, target_ref, enabled FROM t_llm_scenes") + scene_rows = cursor.fetchall() or [] + + cursor.execute( + "SELECT meta_value FROM t_llm_catalog_meta WHERE meta_key='default_scene' LIMIT 1" + ) + meta_row = cursor.fetchone() or {} finally: conn.close() - if not row: - return {} - - return { - "default_backend": str(row.get("default_backend") or "").strip(), - "backends": cls._loads_json(row.get("backends_json")), - "scenes": cls._loads_json(row.get("scenes_json")), + providers = { + str(row.get("name") or "").strip(): { + "name": str(row.get("name") or "").strip(), + "provider_type": str(row.get("provider_type") or "").strip().lower(), + "enabled": bool(int(row.get("enabled") or 0)), + "config": cls._loads_json(row.get("config_json"), {}), + } + for row in provider_rows + if str(row.get("name") or "").strip() } + + dify_apps = { + str(row.get("name") or "").strip(): { + "name": str(row.get("name") or "").strip(), + "provider_template": str(row.get("provider_template") or "").strip(), + "app_key": str(row.get("app_key") or "").strip(), + "workflow_output_key": str(row.get("workflow_output_key") or "text").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + "config": cls._loads_json(row.get("config_json"), {}), + } + for row in app_rows + if str(row.get("name") or "").strip() + } + + backends = { + str(row.get("name") or "").strip(): { + "name": str(row.get("name") or "").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + "config": cls._loads_json(row.get("config_json"), {}), + } + for row in backend_rows + if str(row.get("name") or "").strip() + } + + scenes = { + str(row.get("name") or "").strip(): { + "name": str(row.get("name") or "").strip(), + "target_type": str(row.get("target_type") or "").strip().lower(), + "target_ref": str(row.get("target_ref") or "").strip(), + "enabled": bool(int(row.get("enabled") or 0)), + } + for row in scene_rows + if str(row.get("name") or "").strip() + } + + catalog = { + "default_scene": str(meta_row.get("meta_value") or "").strip(), + "providers": providers, + "dify_apps": dify_apps, + "backends": backends, + "scenes": scenes, + } + # 只要目录中存在场景,就认为新模型可用。 + return catalog if scenes else {} except Exception: return {} @classmethod - def _normalize_llm_config(cls, llm_config: Dict[str, Any]) -> Dict[str, Any]: - """统一规整 llm 配置结构,避免下游出现类型分支。""" - data = llm_config if isinstance(llm_config, dict) else {} - default_backend = str(data.get("default_backend") or "").strip() - backends = data.get("backends", {}) or {} - scenes = data.get("scenes", {}) or {} + def _load_runtime_snapshot(cls) -> Dict[str, Any]: + """加载运行时快照(目录模型优先,legacy 兜底)。""" + now = time.time() + if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("catalog") is not None: + return { + "catalog": cls._cache.get("catalog", {}) or {}, + "legacy_llm": cls._cache.get("legacy_llm", {}) or {}, + } + + catalog = cls._load_catalog_from_mysql() + legacy_llm = cls._load_yaml_legacy_llm() + cls._cache = { + "cache_until": now + 3.0, + "catalog": catalog or {}, + "legacy_llm": legacy_llm or {}, + } + return {"catalog": catalog or {}, "legacy_llm": legacy_llm or {}} + + @classmethod + def _merge_dify_provider_and_app(cls, provider: Dict[str, Any], app: Dict[str, Any]) -> Dict[str, Any]: + """把 Dify Provider 模板与 Dify 应用差异合并成最终调用配置。""" + provider_cfg = dict((provider or {}).get("config", {}) or {}) + app_cfg = dict((app or {}).get("config", {}) or {}) + merged = dict(provider_cfg) + merged.update(app_cfg) + + # 强制写入 Dify 必要字段:provider、api_key、workflow_output_key。 + merged["provider"] = "dify" + merged["api_key"] = str((app or {}).get("app_key") or "").strip() + merged["workflow_output_key"] = str((app or {}).get("workflow_output_key") or "text").strip() + return merged + + @classmethod + def _resolve_scene_with_catalog(cls, scene_name: str, catalog: Dict[str, Any]) -> Dict[str, Any]: + """按新目录模型解析 scene。""" + scenes = (catalog or {}).get("scenes", {}) or {} + scene = scenes.get(scene_name, {}) or {} + if not scene or not scene.get("enabled", True): + return {} + + target_type = str(scene.get("target_type") or "").strip().lower() + target_ref = str(scene.get("target_ref") or "").strip() + if not target_ref: + return {} + + if target_type == "backend": + backend = ((catalog or {}).get("backends", {}) or {}).get(target_ref, {}) or {} + if not backend or not backend.get("enabled", True): + return {} + config = dict(backend.get("config", {}) or {}) + config["backend"] = target_ref + config["scene"] = scene_name + return config + + if target_type == "dify_app": + app = ((catalog or {}).get("dify_apps", {}) or {}).get(target_ref, {}) or {} + if not app or not app.get("enabled", True): + return {} + provider_name = str(app.get("provider_template") or "").strip() + provider = ((catalog or {}).get("providers", {}) or {}).get(provider_name, {}) or {} + if not provider or not provider.get("enabled", True): + return {} + config = cls._merge_dify_provider_and_app(provider, app) + # 生成稳定 backend 标识,便于日志追踪。 + config["backend"] = f"dify_app::{target_ref}" + config["scene"] = scene_name + return config + + return {} + + @classmethod + def _resolve_scene_with_legacy(cls, scene_name: str, legacy_llm: Dict[str, Any]) -> Dict[str, Any]: + """按旧 llm(backends/scenes) 结构解析 scene(兜底)。""" + llm = legacy_llm or {} + backends = llm.get("backends", {}) or {} + scenes = llm.get("scenes", {}) or {} if not isinstance(backends, dict): backends = {} if not isinstance(scenes, dict): scenes = {} - return { - "default_backend": default_backend, - "backends": backends, - "scenes": scenes, - } + backend_name = str(scenes.get(scene_name) or "").strip() + if not backend_name: + backend_name = str(llm.get("default_backend") or "").strip() + if not backend_name: + return {} + backend = backends.get(backend_name, {}) or {} + if not isinstance(backend, dict): + return {} + config = dict(backend) + config["backend"] = backend_name + config["scene"] = scene_name + return config + + @classmethod + def get_catalog(cls) -> Dict[str, Any]: + """对外暴露运行时目录(优先 MySQL)。""" + return cls._load_runtime_snapshot().get("catalog", {}) or {} @classmethod def get_llm_config(cls) -> Dict[str, Any]: - """获取运行时 LLM 配置(优先 MySQL,失败回退 YAML)。""" - now = time.time() - if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("data"): - return cls._cache["data"] + """兼容输出:将目录模型转为 legacy llm(backends/scenes) 视图。 - llm_config = cls._load_llm_from_mysql() - if not llm_config: - llm_config = cls._load_llm_from_yaml() + 说明: + 1. 若目录模型可用,则返回“展开后”的 backends/scenes; + 2. 若目录模型不可用,则原样返回 YAML 旧结构。 + """ + snapshot = cls._load_runtime_snapshot() + catalog = snapshot.get("catalog", {}) or {} + if not catalog: + llm = snapshot.get("legacy_llm", {}) or {} + return llm if isinstance(llm, dict) else {} - normalized = cls._normalize_llm_config(llm_config) - # 轻量缓存 3 秒:兼顾“后台编辑后较快生效”和“降低高频调用的 DB 压力”。 - cls._cache = { - "cache_until": now + 3.0, - "data": normalized, + backends: Dict[str, Any] = {} + scenes_map: Dict[str, str] = {} + + for backend_name, backend in (catalog.get("backends", {}) or {}).items(): + if not (backend or {}).get("enabled", True): + continue + backends[backend_name] = dict((backend or {}).get("config", {}) or {}) + + for app_name, app in (catalog.get("dify_apps", {}) or {}).items(): + if not (app or {}).get("enabled", True): + continue + provider_name = str((app or {}).get("provider_template") or "").strip() + provider = ((catalog.get("providers", {}) or {}).get(provider_name, {}) or {}) + if not provider or not provider.get("enabled", True): + continue + synthetic_backend = f"dify_app::{app_name}" + backends[synthetic_backend] = cls._merge_dify_provider_and_app(provider, app) + + for scene_name, scene in (catalog.get("scenes", {}) or {}).items(): + if not (scene or {}).get("enabled", True): + continue + target_type = str((scene or {}).get("target_type") or "").strip().lower() + target_ref = str((scene or {}).get("target_ref") or "").strip() + if not target_ref: + continue + if target_type == "dify_app": + scenes_map[scene_name] = f"dify_app::{target_ref}" + elif target_type == "backend": + scenes_map[scene_name] = target_ref + + default_scene = str(catalog.get("default_scene") or "").strip() + default_backend = str(scenes_map.get(default_scene) or "").strip() + return { + "default_backend": default_backend, + "backends": backends, + "scenes": scenes_map, } - return normalized @classmethod def get_default_backend(cls) -> str: - """读取全局默认后端名称。""" llm_config = cls.get_llm_config() return str(llm_config.get("default_backend", "") or "").strip() @@ -148,24 +316,20 @@ class LLMRegistry: @classmethod def get_scenes(cls) -> Dict[str, str]: - """读取 llm.scenes 场景路由配置,返回 scene->backend 的映射。""" llm_config = cls.get_llm_config() raw_scenes = llm_config.get("scenes", {}) or {} if not isinstance(raw_scenes, dict): return {} - normalized: Dict[str, str] = {} for raw_scene, raw_backend in raw_scenes.items(): scene_name = str(raw_scene or "").strip() backend_name = str(raw_backend or "").strip() - if not scene_name or not backend_name: - continue - normalized[scene_name] = backend_name + if scene_name and backend_name: + normalized[scene_name] = backend_name return normalized @classmethod def get_scene_backend_name(cls, scene_name: str) -> str: - """根据场景名解析后端名;若场景不存在则自动回退 default_backend。""" name = str(scene_name or "").strip() if not name: return cls.get_default_backend() @@ -177,30 +341,37 @@ class LLMRegistry: @classmethod def resolve_by_scene(cls, scene_name: str) -> Dict[str, Any]: - """按场景解析最终后端配置,并附带 scene 字段用于链路追踪。""" - backend_name = cls.get_scene_backend_name(scene_name) - backend = cls.get_backend(backend_name) - if backend_name: - backend["backend"] = backend_name - if scene_name: - backend["scene"] = str(scene_name).strip() - return backend + """按 scene 解析最终配置(优先目录模型)。""" + name = str(scene_name or "").strip() + if not name: + return {} + snapshot = cls._load_runtime_snapshot() + catalog = snapshot.get("catalog", {}) or {} + if catalog: + resolved = cls._resolve_scene_with_catalog(name, catalog) + if resolved: + return resolved + return cls._resolve_scene_with_legacy(name, snapshot.get("legacy_llm", {}) or {}) @classmethod def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: local = dict(local_config or {}) - # 严格模式说明: - # 1. 统一只认 scene 作为路由入口,避免 backend/backend_ref 等多入口并存; - # 2. 若未声明 scene,则视为“调用方直接给出完整连接参数”,原样返回 local。 - scene_name = local.get("scene") or "" - scene_name = str(scene_name).strip() + scene_name = str(local.get("scene") or "").strip() if scene_name: merged = cls.resolve_by_scene(scene_name) merged.update(local) - # 约定:只要声明了 scene,就以 scene 路由结果为准。 - # 这样后台切换 scene 绑定时,无需改插件配置即可全局生效。 - merged["backend"] = cls.get_scene_backend_name(scene_name) + # 以场景路由结果为准,避免调用方覆盖关键路由字段。 + routed = cls.resolve_by_scene(scene_name) + if routed: + merged.update(routed) merged["scene"] = scene_name return merged - return local + + @classmethod + def get_scene_names(cls) -> List[str]: + """返回可用场景名列表(便于后台下拉框或校验)。""" + catalog = cls.get_catalog() + if catalog: + return sorted(list((catalog.get("scenes", {}) or {}).keys())) + return sorted(list(cls.get_scenes().keys()))