feat: 引入LLM场景路由与后台拓扑管理能力

变更项:

1. 新增 llm.scenes 场景路由层,支持 scene->backend 统一映射,并补充默认场景配置。

2. 扩展 LLMRegistry,新增 scene 解析逻辑;当声明 scene 时强制按场景路由结果生效,保持旧 backend 配置兼容。

3. 扩展后台 /api/system/llm_config 读写能力,支持 scenes 配置保存;新增插件 LLM 依赖扫描与拓扑数据输出。

4. 升级 system_llm 页面:新增场景路由管理区、插件依赖拓扑表,支持可视化查看 插件->scene->backend->provider。

5. 迁移核心插件配置到 scene 模式(保留兼容字段):dify/global_news/game_task/message_summary/ai_auto_response/member_context/douyu。

6. 调整部分插件初始化默认 llm_config,补充 scene 字段,确保后台场景切换可直接生效。
This commit is contained in:
liuwei
2026-04-20 14:36:56 +08:00
parent 09daaf956c
commit 7b6bd19781
14 changed files with 351 additions and 6 deletions

View File

@@ -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)

View File

@@ -42,6 +42,27 @@
</el-form-item>
</el-form>
<el-card class="scene-card" shadow="never">
<div slot="header" class="workspace-header">
<div>
<h3>场景路由Scene Binding</h3>
<p>用业务场景绑定后端。插件优先引用 scene再由 scene 统一路由到 backend。</p>
</div>
<el-button size="mini" @click="addScene">新增场景</el-button>
</div>
<div class="scene-list" v-if="llmForm.scenes.length">
<div class="scene-row" v-for="(scene, index) in llmForm.scenes" :key="scene.uid">
<el-input v-model="scene.name" placeholder="例如chat.main"></el-input>
<el-select v-model="scene.backend" placeholder="绑定后端" filterable clearable>
<el-option v-for="item in backendNameOptions" :key="item" :label="item" :value="item"></el-option>
</el-select>
<el-button type="text" class="danger-text" @click="removeScene(index)">删除</el-button>
</div>
</div>
<el-empty v-else description="暂无场景配置,可先新增 chat.main / summary.daily 等"></el-empty>
</el-card>
<div class="backend-list" v-if="llmForm.backends.length">
<el-card
v-for="(backend, index) in llmForm.backends"
@@ -132,6 +153,45 @@
</div>
<el-empty v-else description="暂无后端配置,先新增一个"></el-empty>
<el-card class="topology-card" shadow="never">
<div slot="header" class="workspace-header">
<div>
<h3>插件依赖拓扑</h3>
<p>展示 插件 -> scene -> backend -> provider 的实际映射,便于定位切换影响范围。</p>
</div>
</div>
<el-table :data="topologyRows" size="mini" style="width: 100%">
<el-table-column prop="plugin" label="插件" min-width="120"></el-table-column>
<el-table-column prop="section" label="配置段" min-width="120"></el-table-column>
<el-table-column prop="scene" label="Scene" min-width="140">
<template slot-scope="scope">
<el-tag v-if="scope.row.scene" size="mini" :type="scope.row.valid_scene ? 'success' : 'danger'">
{% raw %}{{ scope.row.scene }}{% endraw %}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="backend" label="配置Backend" min-width="160">
<template slot-scope="scope">
<span>{% raw %}{{ scope.row.backend || '-' }}{% endraw %}</span>
</template>
</el-table-column>
<el-table-column prop="resolved_backend" label="实际Backend" min-width="160">
<template slot-scope="scope">
<el-tag v-if="scope.row.resolved_backend" size="mini" :type="scope.row.valid_backend ? 'success' : 'danger'">
{% raw %}{{ scope.row.resolved_backend }}{% endraw %}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="provider" label="Provider" min-width="120">
<template slot-scope="scope">
<span>{% raw %}{{ scope.row.provider || '-' }}{% endraw %}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-card>
</div>
{% 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; }
}
</style>