diff --git a/admin/dashboard/blueprints/system.py b/admin/dashboard/blueprints/system.py index 7eabebd..39b44f3 100644 --- a/admin/dashboard/blueprints/system.py +++ b/admin/dashboard/blueprints/system.py @@ -11,6 +11,7 @@ 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 # 创建系统信息蓝图 @@ -38,6 +39,113 @@ def _save_system_yaml(config_obj: dict) -> None: yaml.safe_dump(config_obj, f, allow_unicode=True, sort_keys=False) +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. 兼容两种写法:顶层 section 下直接写 backend/scene,或嵌套在 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: + """从单个配置节点收集 backend/scene 引用。""" + if not isinstance(payload, dict): + return + backend_name = str(payload.get("backend") or "").strip() + scene_name = str(payload.get("scene") or payload.get("scene_name") or "").strip() + if not backend_name and not scene_name: + return + usages.append({ + "plugin": plugin_name, + "section": section_name, + "backend": backend_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) + # 顶层兜底:兼容极少数直接写在根节点的 backend/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')}::{row.get('backend')}" + unique[key] = row + return sorted(unique.values(), key=lambda x: (x.get("plugin", ""), x.get("section", ""))) + + +def _build_llm_topology() -> dict: + """构建 LLM 拓扑视图(供后台页面直观展示依赖关系)。""" + config_obj = _load_system_yaml() + llm_config = config_obj.get("llm", {}) or {} + scenes = llm_config.get("scenes", {}) or {} + backends = llm_config.get("backends", {}) or {} + default_backend = str(llm_config.get("default_backend", "") or "").strip() + + plugin_usages = _scan_plugin_llm_usage() + topology_rows = [] + for usage in plugin_usages: + scene_name = str(usage.get("scene") or "").strip() + backend_name = str(usage.get("backend") or "").strip() + resolved_backend = backend_name + # 若插件只写了 scene,则由 scenes 路由解析后端。 + if not resolved_backend and scene_name: + resolved_backend = str(scenes.get(scene_name) or "").strip() + # 若既没有 scene 也没有 backend,则兜底 default_backend(仅展示,不改配置)。 + if not resolved_backend: + resolved_backend = default_backend + + topology_rows.append({ + "plugin": usage.get("plugin", ""), + "section": usage.get("section", ""), + "scene": scene_name, + "backend": backend_name, + "resolved_backend": resolved_backend, + "provider": str((backends.get(resolved_backend) or {}).get("provider", "") or "").strip(), + "valid_scene": bool((not scene_name) or scene_name in scenes), + "valid_backend": bool((not resolved_backend) or resolved_backend in backends), + }) + + return { + "default_backend": default_backend, + "scenes": scenes if isinstance(scenes, dict) else {}, + "backends": backends if isinstance(backends, dict) else {}, + "plugin_usages": plugin_usages, + "topology_rows": topology_rows, + } + + @system_bp.route('/api_docs') @login_required def api_docs(): @@ -235,6 +343,7 @@ def get_system_llm_config(): config_obj = _load_system_yaml() llm_config = config_obj.get("llm", {}) or {} 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): @@ -243,11 +352,29 @@ def get_system_llm_config(): item["name"] = name backend_list.append(item) backend_list.sort(key=lambda item: item.get("name", "")) + + scene_list = [] + if isinstance(scenes, dict): + for scene_name, backend_name in scenes.items(): + scene_name = str(scene_name or "").strip() + backend_name = str(backend_name or "").strip() + if not scene_name: + continue + scene_list.append({ + "name": scene_name, + "backend": backend_name, + }) + scene_list.sort(key=lambda item: item.get("name", "")) + + topology = _build_llm_topology() return jsonify({ "success": True, "data": { "default_backend": llm_config.get("default_backend", ""), "backends": backend_list, + "scenes": scene_list, + "topology_rows": topology.get("topology_rows", []), + "plugin_usages": topology.get("plugin_usages", []), "config_path": _system_config_path(), } }) @@ -264,8 +391,11 @@ def update_system_llm_config(): data = request.get_json() or {} default_backend = str(data.get("default_backend") or "").strip() backend_list = data.get("backends", []) or [] + scene_list = data.get("scenes", []) or [] if not isinstance(backend_list, list): return jsonify({"success": False, "message": "backends 格式不正确"}), 400 + if not isinstance(scene_list, list): + return jsonify({"success": False, "message": "scenes 格式不正确"}), 400 normalized_backends = {} for raw in backend_list: @@ -290,10 +420,24 @@ def update_system_llm_config(): if default_backend and default_backend not in normalized_backends: return jsonify({"success": False, "message": "默认后端不存在"}), 400 + normalized_scenes = {} + for raw in scene_list: + if not isinstance(raw, dict): + continue + scene_name = str(raw.get("name") or "").strip() + backend_name = str(raw.get("backend") or "").strip() + if not scene_name: + continue + if backend_name and backend_name not in normalized_backends: + return jsonify({"success": False, "message": f"场景 {scene_name} 绑定的后端不存在"}), 400 + # 允许场景先占位但暂不绑定后端,方便后台分步配置。 + normalized_scenes[scene_name] = backend_name + config_obj = _load_system_yaml() config_obj["llm"] = { "default_backend": default_backend, "backends": normalized_backends, + "scenes": normalized_scenes, } _save_system_yaml(config_obj) diff --git a/admin/dashboard/templates/system_llm.html b/admin/dashboard/templates/system_llm.html index ffb5a26..42986c0 100644 --- a/admin/dashboard/templates/system_llm.html +++ b/admin/dashboard/templates/system_llm.html @@ -42,6 +42,27 @@ + +
+
+

场景路由(Scene Binding)

+

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

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

插件依赖拓扑

+

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

+
+
+ + + + + + + + + + + + + + + + +
{% endblock %} @@ -145,9 +205,11 @@ return { currentView: '17', configPath: '', + topologyRows: [], llmForm: { default_backend: '', - backends: [] + backends: [], + scenes: [] } } }, @@ -164,6 +226,7 @@ }, methods: { newBackend() { + // 统一后端对象结构,保证新增/编辑时字段完整,避免后端清洗时丢键。 return { uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, name: '', @@ -185,6 +248,14 @@ stream: false }; }, + newScene() { + // Scene 是“业务场景 -> 后端”的路由单元,插件建议只依赖 scene。 + return { + uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name: '', + backend: '' + }; + }, normalizeBackend(item) { return { uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, @@ -207,15 +278,36 @@ stream: !!item.stream }; }, + normalizeScene(item) { + return { + uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name: item.name || '', + backend: item.backend || '' + }; + }, 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,避免保存时出现无效绑定。 + if (removed && removed.name) { + (this.llmForm.scenes || []).forEach(scene => { + if (scene.backend === removed.name) { + scene.backend = ''; + } + }); + } + }, + removeScene(index) { + this.llmForm.scenes.splice(index, 1); }, async loadLlmConfig() { try { @@ -225,6 +317,8 @@ 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 配置失败'); } @@ -239,6 +333,11 @@ const cleaned = { ...item }; delete cleaned.uid; return cleaned; + }), + scenes: (this.llmForm.scenes || []).map(item => { + const cleaned = { ...item }; + delete cleaned.uid; + return cleaned; }) }; try { @@ -273,6 +372,14 @@ .workspace-header p { font-size: 13px; color: #64748b; } .config-meta { display: flex; gap: 12px; color: #64748b; font-size: 12px; flex-wrap: wrap; } .backend-list { display: flex; flex-direction: column; gap: 16px; } + .scene-card, .topology-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); } + .scene-list { display: flex; flex-direction: column; gap: 10px; } + .scene-row { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto; + gap: 10px; + align-items: center; + } .backend-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); } .backend-card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .backend-grid { @@ -286,6 +393,7 @@ .page-hero { flex-direction: column; align-items: flex-start; } .workspace-header { flex-direction: column; align-items: flex-start; } .page-hero-actions { flex-wrap: wrap; } + .scene-row { grid-template-columns: 1fr; } .backend-grid { grid-template-columns: 1fr; } } diff --git a/config.yaml b/config.yaml index d3bbf1a..2cffee8 100644 --- a/config.yaml +++ b/config.yaml @@ -118,3 +118,13 @@ llm: request_timeout: 60 max_retries: 3 retry_delay_seconds: 1.0 + # 场景路由层:插件建议优先使用 scene,而不是直接绑定 backend。 + # 这样当模型或供应商切换时,只需要改这里,不需要逐个改插件配置。 + scenes: + "chat.main": "dify_workflow_chat" + "member.profile": "dify_workflow_member_context" + "summary.daily": "dify_workflow_message_summary" + "douyu.daily_report": "dify_workflow_douyu_daily_report" + "news.global": "dify_chat_global_news" + "game.task": "openai_compatible_game_task" + "auto_reply.group": "dify_workflow_ai_auto_response" diff --git a/plugins/ai_auto_response/config.toml b/plugins/ai_auto_response/config.toml index a679b08..7d3b40a 100644 --- a/plugins/ai_auto_response/config.toml +++ b/plugins/ai_auto_response/config.toml @@ -34,7 +34,8 @@ familiarity_hint = "有亲和力,但不越界装熟" aliases = ["林志玲", "lingzhiling", "温柔", "温柔版"] [api] -backend = "dify_workflow_ai_auto_response" +# 群聊自动回复统一走 auto_reply.group 场景,便于灰度切换不同供应商。 +scene = "auto_reply.group" [runtime] llm_max_concurrency = 3 diff --git a/plugins/dify/config.toml b/plugins/dify/config.toml index 86f7fdb..6dd37d7 100644 --- a/plugins/dify/config.toml +++ b/plugins/dify/config.toml @@ -1,5 +1,8 @@ [Dify] enable = true +# 业务场景优先:聊天插件只关心 chat.main,由全局 llm.scenes 决定具体后端。 +scene = "chat.main" +# 兼容字段保留:旧版本仍可读取 backend,新版本会优先走 scene 路由。 backend = "dify_workflow_chat" commands = ["聊天"] diff --git a/plugins/dify/main.py b/plugins/dify/main.py index 2c7ae40..7dbae14 100644 --- a/plugins/dify/main.py +++ b/plugins/dify/main.py @@ -107,6 +107,8 @@ class DifyPlugin(MessagePluginInterface): llm_config = dify_config.get("llm", {}) or {} if not llm_config: llm_config = { + # 优先支持场景路由:后台改 scene 绑定即可切换供应商/模型。 + "scene": dify_config.get("scene", ""), "backend": dify_config.get("backend", ""), "provider": "dify", "mode": "workflow", diff --git a/plugins/douyu/config.toml b/plugins/douyu/config.toml index 262efcb..5aca029 100644 --- a/plugins/douyu/config.toml +++ b/plugins/douyu/config.toml @@ -27,7 +27,10 @@ daily_report_send_image = true audience_stats_sample_interval_seconds = 0 [Douyu.report_api] -# 切换到 Dify 斗鱼日报专用工作流;对应配置位于根目录 config.yaml 的 llm.backends。 +# 切换到“场景路由”模式:日报插件只关心 douyu.daily_report, +# 具体绑定哪个后端由根目录 config.yaml 的 llm.scenes 统一维护。 +scene = "douyu.daily_report" +# 兼容旧配置:保留 backend 作为回退字段。 backend = "dify_workflow_douyu_daily_report" # 是否把完整结构化 payload(JSON 大对象)作为输入传给 Dify。 # 某些 Workflow 对复杂输入类型校验严格,会导致 400,默认关闭以保证可用性。 diff --git a/plugins/game_task/config.toml b/plugins/game_task/config.toml index 44b6d58..3f7e3d4 100644 --- a/plugins/game_task/config.toml +++ b/plugins/game_task/config.toml @@ -1,5 +1,7 @@ [GameTask] enable = true +# 通过 scene 绑定后端,便于后台统一切换百科出题与判分模型。 +scene = "game.task" backend = "openai_compatible_game_task" command = ["/t", "/a", "/s", "/r", "/l", "/h"] command-format = """ diff --git a/plugins/game_task/main.py b/plugins/game_task/main.py index 5c2a633..c538f96 100644 --- a/plugins/game_task/main.py +++ b/plugins/game_task/main.py @@ -86,6 +86,8 @@ class GameTaskPlugin(MessagePluginInterface): llm_config = plugin_config.get("llm", {}) or {} if not llm_config: llm_config = { + # 场景路由优先,后台改 scene 即可替换出题/判分模型。 + "scene": plugin_config.get("scene", ""), "backend": plugin_config.get("backend", ""), "provider": "openai_compatible", "authorization": self.authorization, diff --git a/plugins/global_news/config.toml b/plugins/global_news/config.toml index 4d11e61..a273573 100644 --- a/plugins/global_news/config.toml +++ b/plugins/global_news/config.toml @@ -1,6 +1,8 @@ [GlobalNews] enable = true command = ["全球新闻", "国际新闻", "环球新闻", "政经新闻", "政治经济新闻"] +# 通过 scene 绑定后端,便于后台统一切换新闻分析模型。 +scene = "news.global" backend = "dify_chat_global_news" command-format = """ 🌍全球新闻指令: diff --git a/plugins/global_news/main.py b/plugins/global_news/main.py index 34a96b1..b59cc9f 100644 --- a/plugins/global_news/main.py +++ b/plugins/global_news/main.py @@ -79,6 +79,8 @@ class GlobalNewsPlugin(MessagePluginInterface): llm_config = plugin_config.get("llm", {}) or {} if not llm_config: llm_config = { + # 场景路由优先,便于后台统一切换新闻分析后端。 + "scene": plugin_config.get("scene", ""), "backend": plugin_config.get("backend", ""), "provider": "dify", "mode": "chat", diff --git a/plugins/member_context/config.toml b/plugins/member_context/config.toml index c0254cb..b830521 100644 --- a/plugins/member_context/config.toml +++ b/plugins/member_context/config.toml @@ -3,7 +3,8 @@ enable = true [api] enable = true -backend = "dify_workflow_member_context" +# 成员画像提炼改为场景路由,便于后续替换成更强结构化模型。 +scene = "member.profile" request_timeout = 240 [profile] diff --git a/plugins/message_summary/config.toml b/plugins/message_summary/config.toml index cfc6842..fbaf3ad 100644 --- a/plugins/message_summary/config.toml +++ b/plugins/message_summary/config.toml @@ -4,7 +4,8 @@ enabled = true [api] -backend = "dify_workflow_message_summary" +# 群总结能力改为场景路由,后台切换 summary.daily 即可统一切换实现。 +scene = "summary.daily" connect_timeout_seconds = 10 retry_delays_seconds = [10, 20] diff --git a/utils/ai/llm_registry.py b/utils/ai/llm_registry.py index 353fc69..ac3f5ad 100644 --- a/utils/ai/llm_registry.py +++ b/utils/ai/llm_registry.py @@ -36,6 +36,12 @@ class LLMRegistry: llm_config = config.get("llm", {}) or {} return llm_config if isinstance(llm_config, dict) else {} + @classmethod + def get_default_backend(cls) -> str: + """读取全局默认后端名称。""" + llm_config = cls.get_llm_config() + return str(llm_config.get("default_backend", "") or "").strip() + @classmethod def get_backend(cls, backend_name: str) -> Dict[str, Any]: if not backend_name: @@ -45,9 +51,68 @@ class LLMRegistry: backend = backends.get(backend_name, {}) or {} return dict(backend) if isinstance(backend, dict) else {} + @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 + 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() + scenes = cls.get_scenes() + backend_name = str(scenes.get(name, "") or "").strip() + if backend_name: + return backend_name + return cls.get_default_backend() + + @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 + @classmethod def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: local = dict(local_config or {}) + # 兼容说明: + # 1. 新链路优先支持 scene 字段,便于“业务场景 -> 后端”集中路由; + # 2. 若未配置 scene,再按旧逻辑读取 backend,保持历史插件零改动可运行。 + scene_name = ( + local.get("scene") + or local.get("scene_name") + or local.get("scene_ref") + or "" + ) + scene_name = str(scene_name).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) + merged["scene"] = scene_name + return merged + backend_name = ( local.get("backend") or local.get("backend_name") @@ -61,4 +126,3 @@ class LLMRegistry: merged.update(local) merged["backend"] = backend_name return merged -