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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% raw %}{{ scope.row.scene }}{% endraw %}
+
+ -
+
+
+
+
+ {% raw %}{{ scope.row.backend || '-' }}{% endraw %}
+
+
+
+
+
+ {% raw %}{{ scope.row.resolved_backend }}{% endraw %}
+
+ -
+
+
+
+
+ {% raw %}{{ scope.row.provider || '-' }}{% endraw %}
+
+
+
+
{% 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
-