feat: 重构LLM配置为Provider模板+Dify应用+Scene绑定

变更项:

1. 新增 LLM 目录数据层(t_llm_provider_templates/t_llm_dify_apps/t_llm_backends/t_llm_scenes/t_llm_catalog_meta),支持三层配置管理。

2. Robot 启动接入 llm_catalog_db:自动建表并从旧 llm(backends/scenes) 配置迁移初始化。

3. LLMRegistry 改为优先读取目录模型并按 scene 解析:dify_app 自动合并 Provider 模板与 app_key 差异,降低重复配置。

4. system 蓝图 /api/system/llm_config 改为目录模型读写,新增完整校验(provider引用、app_key、scene目标合法性)。

5. system_llm 页面重构为四块:Provider 模板、Dify 应用、通用 Backend、Scene 绑定,并展示插件依赖拓扑。

6. 保留 YAML 旧结构兜底展示与运行时回退,保证目录表异常时系统仍可运行。
This commit is contained in:
liuwei
2026-04-20 15:09:24 +08:00
parent 1446bf5f39
commit 061f2b8084
6 changed files with 1284 additions and 453 deletions

View File

@@ -39,41 +39,143 @@ def _save_system_yaml(config_obj: dict) -> None:
yaml.safe_dump(config_obj, f, allow_unicode=True, sort_keys=False) yaml.safe_dump(config_obj, f, allow_unicode=True, sort_keys=False)
def _load_llm_config_runtime() -> dict: def _legacy_llm_to_catalog(legacy_llm: dict) -> dict:
"""读取运行时 LLM 配置 """把旧 llm(backends/scenes) 结构转换为新目录结构(仅用于兜底展示)
读取优先级 说明
1. 优先从机器人挂载的 MySQL 配置读取(主数据源) 1. 该转换不写库,只用于当目录表不可用时让后台页面仍可展示
2. 若数据库对象不可用或读取异常,回退到 config.yaml兜底 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: try:
server = current_app.dashboard_server server = current_app.dashboard_server
llm_config_db = getattr(server, "llm_config_db", None) llm_catalog_db = getattr(server, "llm_catalog_db", None)
if llm_config_db: if llm_catalog_db:
row = llm_config_db.get_config() or {} catalog = llm_catalog_db.get_catalog() or {}
if row: if catalog and catalog.get("scenes"):
return { return catalog
"default_backend": row.get("default_backend", ""),
"backends": row.get("backends", {}) or {},
"scenes": row.get("scenes", {}) or {},
}
except Exception as e: 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() config_obj = _load_system_yaml()
llm_config = config_obj.get("llm", {}) or {} 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: def _save_llm_catalog_runtime(catalog: dict) -> None:
"""保存运行时 LLM 配置到主数据源(MySQL""" """保存运行时 LLM 目录配置到 MySQL。"""
server = current_app.dashboard_server server = current_app.dashboard_server
llm_config_db = getattr(server, "llm_config_db", None) llm_catalog_db = getattr(server, "llm_catalog_db", None)
if not llm_config_db: if not llm_catalog_db:
raise RuntimeError("llm_config_db 未初始化,无法保存 LLM 配置到 MySQL") raise RuntimeError("llm_catalog_db 未初始化,无法保存 LLM 目录到 MySQL")
ok = llm_config_db.save_config(llm_config or {}, source="admin") ok = llm_catalog_db.save_catalog(catalog or {})
if not ok: if not ok:
raise RuntimeError("保存 LLM 配置到 MySQL 失败") raise RuntimeError("保存 LLM 目录到 MySQL 失败")
def _plugins_root_path() -> str: def _plugins_root_path() -> str:
@@ -142,34 +244,53 @@ def _scan_plugin_llm_usage() -> list:
def _build_llm_topology() -> dict: def _build_llm_topology() -> dict:
"""构建 LLM 拓扑视图(供后台页面直观展示依赖关系)。""" """构建 LLM 拓扑视图(供后台页面直观展示依赖关系)。"""
llm_config = _load_llm_config_runtime() catalog = _load_llm_catalog_runtime()
scenes = llm_config.get("scenes", {}) or {} providers = {str(item.get("name") or "").strip(): item for item in (catalog.get("providers", []) or [])}
backends = llm_config.get("backends", {}) or {} dify_apps = {str(item.get("name") or "").strip(): item for item in (catalog.get("dify_apps", []) or [])}
default_backend = str(llm_config.get("default_backend", "") or "").strip() 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() plugin_usages = _scan_plugin_llm_usage()
topology_rows = [] topology_rows = []
for usage in plugin_usages: for usage in plugin_usages:
scene_name = str(usage.get("scene") or "").strip() scene_name = str(usage.get("scene") or "").strip()
# 严格模式:插件必须声明 scene后端统一由 scenes 映射解析。 scene = scenes.get(scene_name, {}) or {}
resolved_backend = str(scenes.get(scene_name) or "").strip() target_type = str(scene.get("target_type") or "").strip().lower()
if not resolved_backend: target_ref = str(scene.get("target_ref") or "").strip()
resolved_backend = default_backend
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({ topology_rows.append({
"plugin": usage.get("plugin", ""), "plugin": usage.get("plugin", ""),
"section": usage.get("section", ""), "section": usage.get("section", ""),
"scene": scene_name, "scene": scene_name,
"resolved_backend": resolved_backend, "target_type": target_type or "-",
"provider": str((backends.get(resolved_backend) or {}).get("provider", "") or "").strip(), "target_ref": resolved_target or "-",
"provider": resolved_provider or "-",
"valid_scene": bool(scene_name in scenes), "valid_scene": bool(scene_name in scenes),
"valid_backend": bool((not resolved_backend) or resolved_backend in backends), "valid_target": valid_target,
}) })
return { return {
"default_backend": default_backend, "default_scene": default_scene,
"scenes": scenes if isinstance(scenes, dict) else {}, "providers": catalog.get("providers", []) or [],
"backends": backends if isinstance(backends, dict) else {}, "dify_apps": catalog.get("dify_apps", []) or [],
"backends": catalog.get("backends", []) or [],
"scenes": catalog.get("scenes", []) or [],
"plugin_usages": plugin_usages, "plugin_usages": plugin_usages,
"topology_rows": topology_rows, "topology_rows": topology_rows,
} }
@@ -326,14 +447,15 @@ def get_system_config_raw():
config_path = _system_config_path() config_path = _system_config_path()
with open(config_path, 'r', encoding='utf-8') as f: with open(config_path, 'r', encoding='utf-8') as f:
config_text = f.read() config_text = f.read()
# 这里展示运行时有效”的 LLM 后端列表(优先 MySQL避免与 YAML 展示不一致 # 展示运行时目录中的目标对象backend+dify_app便于调试 scene 绑定
llm_config = _load_llm_config_runtime() catalog = _load_llm_catalog_runtime()
llm_backends = (llm_config or {}).get("backends", {}) 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({ return jsonify({
"success": True, "success": True,
"data": config_text, "data": config_text,
"path": config_path, "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: except Exception as e:
logger.error(f"读取系统配置失败: {e}") logger.error(f"读取系统配置失败: {e}")
@@ -368,42 +490,27 @@ def update_system_config():
@login_required @login_required
def get_system_llm_config(): def get_system_llm_config():
try: try:
llm_config = _load_llm_config_runtime() catalog = _load_llm_catalog_runtime()
backends = llm_config.get("backends", {}) or {} providers = sorted((catalog.get("providers", []) or []), key=lambda item: str(item.get("name") or ""))
scenes = llm_config.get("scenes", {}) or {} dify_apps = sorted((catalog.get("dify_apps", []) or []), key=lambda item: str(item.get("name") or ""))
backend_list = [] backends = sorted((catalog.get("backends", []) or []), key=lambda item: str(item.get("name") or ""))
for name, backend in backends.items(): scenes = sorted((catalog.get("scenes", []) or []), key=lambda item: str(item.get("name") or ""))
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", ""))
topology = _build_llm_topology() topology = _build_llm_topology()
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": {
"default_backend": llm_config.get("default_backend", ""), "default_scene": catalog.get("default_scene", ""),
"backends": backend_list, "providers": providers,
"scenes": scene_list, "dify_apps": dify_apps,
"backends": backends,
"scenes": scenes,
"topology_rows": topology.get("topology_rows", []), "topology_rows": topology.get("topology_rows", []),
"plugin_usages": topology.get("plugin_usages", []), "plugin_usages": topology.get("plugin_usages", []),
# 配置来源改为 MySQL保留 YAML 路径用于排障与一次性导入核对 # 新目录模型主存储在 MySQL
"config_path": f"mysql:t_llm_config (fallback yaml: {_system_config_path()})", "config_path": (
"mysql:t_llm_provider_templates + t_llm_dify_apps + "
"t_llm_backends + t_llm_scenes (fallback yaml)"
),
} }
}) })
except Exception as e: except Exception as e:
@@ -417,58 +524,85 @@ def update_system_llm_config():
try: try:
server = current_app.dashboard_server server = current_app.dashboard_server
data = request.get_json() or {} 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 [] backend_list = data.get("backends", []) or []
scene_list = data.get("scenes", []) 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): if not isinstance(backend_list, list):
return jsonify({"success": False, "message": "backends 格式不正确"}), 400 return jsonify({"success": False, "message": "backends 格式不正确"}), 400
if not isinstance(scene_list, list): if not isinstance(scene_list, list):
return jsonify({"success": False, "message": "scenes 格式不正确"}), 400 return jsonify({"success": False, "message": "scenes 格式不正确"}), 400
normalized_backends = {} # 目录级校验:先收集名字集合,便于 scene target 引用校验。
for raw in backend_list: provider_names = {
if not isinstance(raw, dict): str((item or {}).get("name") or "").strip()
continue for item in provider_list
name = str(raw.get("name") or "").strip() if isinstance(item, dict) and str((item or {}).get("name") or "").strip()
if not name: }
continue dify_app_names = {
item = {} str((item or {}).get("name") or "").strip()
for key, value in raw.items(): for item in dify_app_list
if key == "name": if isinstance(item, dict) and str((item or {}).get("name") or "").strip()
continue }
if value is None: backend_names = {
continue str((item or {}).get("name") or "").strip()
if isinstance(value, str): for item in backend_list
value = value.strip() if isinstance(item, dict) and str((item or {}).get("name") or "").strip()
if value == "": }
continue
item[key] = value
normalized_backends[name] = item
if default_backend and default_backend not in normalized_backends: for app in dify_app_list:
return jsonify({"success": False, "message": "默认后端不存在"}), 400 if not isinstance(app, dict):
normalized_scenes = {}
for raw in scene_list:
if not isinstance(raw, dict):
continue continue
scene_name = str(raw.get("name") or "").strip() app_name = str(app.get("name") or "").strip()
backend_name = str(raw.get("backend") 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: if not scene_name:
continue continue
# 严格模式:每个 scene 必须绑定一个有效 backend避免“空绑定”导致运行时不确定性。 if scene_name in scene_names:
if not backend_name: return jsonify({"success": False, "message": f"场景名重复: {scene_name}"}), 400
return jsonify({"success": False, "message": f"场景 {scene_name} 未绑定后端"}), 400 scene_names.add(scene_name)
if backend_name not in normalized_backends: if target_type not in {"dify_app", "backend"}:
return jsonify({"success": False, "message": f"场景 {scene_name} 绑定的后端不存在"}), 400 return jsonify({"success": False, "message": f"场景 {scene_name} target_type 非法"}), 400
normalized_scenes[scene_name] = backend_name 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 = { if default_scene and default_scene not in scene_names:
"default_backend": default_backend, return jsonify({"success": False, "message": "默认场景不存在"}), 400
"backends": normalized_backends,
"scenes": normalized_scenes, 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): if getattr(server, "robot", None) and getattr(server.robot, "config", None):
server.robot.config.reload() server.robot.config.reload()

View File

@@ -51,6 +51,7 @@ class DashboardServer:
self.plugin_schedule_db = robot_instance.plugin_schedule_db self.plugin_schedule_db = robot_instance.plugin_schedule_db
self.plugin_schedule_manager = robot_instance.plugin_schedule_manager self.plugin_schedule_manager = robot_instance.plugin_schedule_manager
self.group_plugin_config_db = robot_instance.group_plugin_config_db 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.llm_config_db = robot_instance.llm_config_db
self.group_plugin_config_service = robot_instance.group_plugin_config_service self.group_plugin_config_service = robot_instance.group_plugin_config_service
# 获取联系人管理器实例 # 获取联系人管理器实例

View File

@@ -1,18 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}全局配置 - 机器人管理后台{% endblock %} {% block title %}LLM目录配置 - 机器人管理后台{% endblock %}
{% block content %} {% block content %}
<div class="page-shell system-page"> <div class="page-shell system-page">
<div class="page-hero"> <div class="page-hero">
<div class="page-hero-copy"> <div class="page-hero-copy">
<div class="page-eyebrow">LLM Workspace</div> <div class="page-eyebrow">LLM Catalog</div>
<h1>全局配置</h1> <h1>LLM目录配置</h1>
<p>集中维护全局 LLM 后端,插件只引用后端名,不再分散配置密钥和地址</p> <p>按 Provider 模板、Dify 应用、Scene 绑定三层维护,减少重复配置和切换成本</p>
</div> </div>
<div class="page-hero-actions"> <div class="page-hero-actions">
<el-button size="mini" plain @click="loadLlmConfig">刷新</el-button> <el-button size="mini" plain @click="loadLlmConfig">刷新</el-button>
<el-button size="mini" @click="addBackend">新增后端</el-button>
<el-button size="mini" type="success" @click="saveLlmConfig">保存配置</el-button> <el-button size="mini" type="success" @click="saveLlmConfig">保存配置</el-button>
</div> </div>
</div> </div>
@@ -20,173 +19,165 @@
<el-card class="workspace-card" shadow="hover"> <el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header"> <div slot="header" class="workspace-header">
<div> <div>
<h3>全局 LLM 配置</h3> <h3>目录元信息</h3>
<p>用表单统一管理 `config.yaml` 中的 `llm.backends`,系统和插件共享同一份后端配置</p> <p>默认场景用于兜底路由,建议始终设置一个稳定可用的场景</p>
</div> </div>
<div class="config-meta" v-if="configPath"> <div class="config-meta" v-if="configPath">
<span>配置文件{% raw %}{{ configPath }}{% endraw %}</span> <span>配置{% raw %}{{ configPath }}{% endraw %}</span>
<span>后端数量:{% raw %}{{ llmForm.backends.length }}{% endraw %}</span>
</div> </div>
</div> </div>
<el-form label-width="110px" class="llm-form"> <el-form label-width="110px" class="llm-form">
<el-form-item label="默认后端"> <el-form-item label="默认场景">
<el-select v-model="llmForm.default_backend" placeholder="请选择默认后端" filterable clearable style="width: 360px;"> <el-select v-model="catalog.default_scene" placeholder="请选择默认场景" filterable clearable style="width: 360px;">
<el-option <el-option v-for="item in sceneNameOptions" :key="item" :label="item" :value="item"></el-option>
v-for="item in backendNameOptions"
:key="item"
:label="item"
:value="item">
</el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card>
<el-card class="scene-card" shadow="never"> <el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header"> <div slot="header" class="workspace-header">
<div> <div>
<h3>场景路由Scene Binding</h3> <h3>1. Provider 模板</h3>
<p>用业务场景绑定后端。插件优先引用 scene再由 scene 统一路由到 backend</p> <p>公共连接参数只配置一次base_url、endpoint、mode、超时等</p>
</div>
<el-button size="mini" @click="addScene">新增场景</el-button>
</div> </div>
<el-button size="mini" @click="addProvider">新增模板</el-button>
<div class="scene-list" v-if="llmForm.scenes.length"> </div>
<div class="scene-row" v-for="(scene, index) in llmForm.scenes" :key="scene.uid"> <div class="section-list" v-if="catalog.providers.length">
<el-input v-model="scene.name" placeholder="例如chat.main"></el-input> <el-card v-for="(item, index) in catalog.providers" :key="item.uid" class="entry-card" shadow="never">
<el-select v-model="scene.backend" placeholder="绑定后端" filterable> <div slot="header" class="entry-header">
<el-option v-for="item in backendNameOptions" :key="item" :label="item" :value="item"></el-option> <strong>{% raw %}{{ item.name || `Provider ${index + 1}` }}{% endraw %}</strong>
<el-button type="text" class="danger-text" @click="removeProvider(index)">删除</el-button>
</div>
<div class="entry-grid">
<el-input v-model="item.name" placeholder="模板名例如dify_workflow_default"></el-input>
<el-select v-model="item.provider_type" placeholder="Provider类型">
<el-option label="dify" value="dify"></el-option>
<el-option label="openai_compatible" value="openai_compatible"></el-option>
</el-select> </el-select>
<el-button type="text" class="danger-text" @click="removeScene(index)">删除</el-button> <el-input v-model="item.config.api_base_url" placeholder="API Base URL"></el-input>
<el-input v-model="item.config.endpoint" placeholder="Endpoint例如workflows/run"></el-input>
<el-input v-model="item.config.mode" placeholder="mode例如workflow"></el-input>
<el-input v-model="item.config.response_mode" placeholder="response_mode例如blocking"></el-input>
<el-input-number v-model="item.config.request_timeout" :min="1" :step="1" placeholder="request_timeout"></el-input-number>
<el-input-number v-model="item.config.max_retries" :min="1" :step="1" placeholder="max_retries"></el-input-number>
</div> </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"
:key="backend.uid"
class="backend-card"
shadow="never">
<div slot="header" class="backend-card-header">
<div>
<strong>{% raw %}{{ backend.name || `后端 ${index + 1}` }}{% endraw %}</strong>
</div>
<el-button type="text" class="danger-text" @click="removeBackend(index)">删除</el-button>
</div>
<el-form label-width="110px" class="backend-form">
<div class="backend-grid">
<el-form-item label="后端名称">
<el-input v-model="backend.name" placeholder="例如dify_workflow_chat"></el-input>
</el-form-item>
<el-form-item label="Provider">
<el-select v-model="backend.provider" placeholder="请选择">
<el-option label="dify" value="dify"></el-option>
<el-option label="openai_compatible" value="openai_compatible"></el-option>
</el-select>
</el-form-item>
<el-form-item label="Mode">
<el-select v-model="backend.mode" placeholder="请选择" clearable>
<el-option label="workflow" value="workflow"></el-option>
<el-option label="chat" value="chat"></el-option>
<el-option label="completion" value="completion"></el-option>
</el-select>
</el-form-item>
<el-form-item label="模型">
<el-input v-model="backend.model" placeholder="例如gpt-5.4"></el-input>
</el-form-item>
<el-form-item label="API Base URL">
<el-input v-model="backend.api_base_url" placeholder="例如http://127.0.0.1:3000/v1"></el-input>
</el-form-item>
<el-form-item label="API URL">
<el-input v-model="backend.api_url" placeholder="完整地址,和 Base URL 二选一"></el-input>
</el-form-item>
<el-form-item label="Endpoint">
<el-input v-model="backend.endpoint" placeholder="例如chat/completions"></el-input>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="backend.api_key" placeholder="请输入 API Key" show-password></el-input>
</el-form-item>
<el-form-item label="响应模式">
<el-select v-model="backend.response_mode" placeholder="请选择" clearable>
<el-option label="blocking" value="blocking"></el-option>
<el-option label="streaming" value="streaming"></el-option>
</el-select>
</el-form-item>
<el-form-item label="输出字段">
<el-input v-model="backend.workflow_output_key" placeholder="例如text"></el-input>
</el-form-item>
<el-form-item label="超时(秒)">
<el-input-number v-model="backend.timeout_seconds" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="请求超时">
<el-input-number v-model="backend.request_timeout" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="温度">
<el-input-number v-model="backend.temperature" :min="0" :max="2" :step="0.05"></el-input-number>
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="backend.max_tokens" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="最大重试">
<el-input-number v-model="backend.max_retries" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="重试间隔">
<el-input-number v-model="backend.retry_delay_seconds" :min="0" :step="0.5"></el-input-number>
</el-form-item>
</div>
<div class="backend-switches">
<el-switch v-model="backend.stream" active-text="Stream"></el-switch>
</div>
</el-form>
</el-card> </el-card>
</div> </div>
<el-empty v-else description="暂无 Provider 模板,先新增一个"></el-empty>
</el-card>
<el-empty v-else description="暂无后端配置,先新增一个"></el-empty> <el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<el-card class="topology-card" shadow="never"> <div>
<div slot="header" class="workspace-header"> <h3>2. Dify 应用</h3>
<div> <p>每个应用只维护 app_key 和少量差异项,不再重复写 URL/endpoint 公共参数。</p>
<h3>插件依赖拓扑</h3>
<p>展示 插件 -> scene -> backend -> provider 的实际映射,便于定位切换影响范围。</p>
</div>
</div> </div>
<el-table :data="topologyRows" size="mini" style="width: 100%"> <el-button size="mini" @click="addDifyApp">新增应用</el-button>
<el-table-column prop="plugin" label="插件" min-width="120"></el-table-column> </div>
<el-table-column prop="section" label="配置段" min-width="120"></el-table-column> <div class="section-list" v-if="catalog.dify_apps.length">
<el-table-column prop="scene" label="Scene" min-width="140"> <el-card v-for="(item, index) in catalog.dify_apps" :key="item.uid" class="entry-card" shadow="never">
<template slot-scope="scope"> <div slot="header" class="entry-header">
<el-tag v-if="scope.row.scene" size="mini" :type="scope.row.valid_scene ? 'success' : 'danger'"> <strong>{% raw %}{{ item.name || `DifyApp ${index + 1}` }}{% endraw %}</strong>
{% raw %}{{ scope.row.scene }}{% endraw %} <el-button type="text" class="danger-text" @click="removeDifyApp(index)">删除</el-button>
</el-tag> </div>
<span v-else>-</span> <div class="entry-grid">
</template> <el-input v-model="item.name" placeholder="应用名例如chat_main"></el-input>
</el-table-column> <el-select v-model="item.provider_template" placeholder="绑定 Provider 模板" filterable>
<el-table-column prop="resolved_backend" label="实际Backend" min-width="160"> <el-option v-for="name in providerNameOptions" :key="name" :label="name" :value="name"></el-option>
<template slot-scope="scope"> </el-select>
<el-tag v-if="scope.row.resolved_backend" size="mini" :type="scope.row.valid_backend ? 'success' : 'danger'"> <el-input v-model="item.app_key" placeholder="Dify app key例如app-xxxx"></el-input>
{% raw %}{{ scope.row.resolved_backend }}{% endraw %} <el-input v-model="item.workflow_output_key" placeholder="workflow_output_key例如text"></el-input>
</el-tag> <el-input v-model="item.config.mode" placeholder="覆盖 mode可选"></el-input>
<span v-else>-</span> <el-input v-model="item.config.endpoint" placeholder="覆盖 endpoint可选"></el-input>
</template> <el-input v-model="item.config.response_mode" placeholder="覆盖 response_mode可选"></el-input>
</el-table-column> <el-input-number v-model="item.config.request_timeout" :min="1" :step="1" placeholder="覆盖 request_timeout可选"></el-input-number>
<el-table-column prop="provider" label="Provider" min-width="120"> </div>
<template slot-scope="scope"> </el-card>
<span>{% raw %}{{ scope.row.provider || '-' }}{% endraw %}</span> </div>
</template> <el-empty v-else description="暂无 Dify 应用"></el-empty>
</el-table-column> </el-card>
</el-table>
</el-card> <el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>3. 通用 Backend可选</h3>
<p>用于 openai_compatible 或其他非 Dify 直连能力。</p>
</div>
<el-button size="mini" @click="addBackend">新增Backend</el-button>
</div>
<div class="section-list" v-if="catalog.backends.length">
<el-card v-for="(item, index) in catalog.backends" :key="item.uid" class="entry-card" shadow="never">
<div slot="header" class="entry-header">
<strong>{% raw %}{{ item.name || `Backend ${index + 1}` }}{% endraw %}</strong>
<el-button type="text" class="danger-text" @click="removeBackend(index)">删除</el-button>
</div>
<div class="entry-grid">
<el-input v-model="item.name" placeholder="backend名例如openai_main"></el-input>
<el-input v-model="item.config.provider" placeholder="provider例如openai_compatible"></el-input>
<el-input v-model="item.config.api_base_url" placeholder="api_base_url"></el-input>
<el-input v-model="item.config.endpoint" placeholder="endpoint"></el-input>
<el-input v-model="item.config.api_key" placeholder="api_key"></el-input>
<el-input v-model="item.config.model" placeholder="model"></el-input>
<el-input-number v-model="item.config.temperature" :min="0" :max="2" :step="0.05"></el-input-number>
<el-input-number v-model="item.config.max_tokens" :min="1" :step="1"></el-input-number>
</div>
</el-card>
</div>
<el-empty v-else description="暂无通用 Backend"></el-empty>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>4. Scene 绑定</h3>
<p>业务场景绑定 dify_app 或 backend插件只配置 scene。</p>
</div>
<el-button size="mini" @click="addScene">新增场景</el-button>
</div>
<div class="section-list" v-if="catalog.scenes.length">
<div class="scene-row" v-for="(item, index) in catalog.scenes" :key="item.uid">
<el-input v-model="item.name" placeholder="scene 名例如chat.main"></el-input>
<el-select v-model="item.target_type" placeholder="目标类型">
<el-option label="dify_app" value="dify_app"></el-option>
<el-option label="backend" value="backend"></el-option>
</el-select>
<el-select v-model="item.target_ref" placeholder="目标引用" filterable>
<el-option v-for="target in getSceneTargetOptions(item.target_type)" :key="target" :label="target" :value="target"></el-option>
</el-select>
<el-button type="text" class="danger-text" @click="removeScene(index)">删除</el-button>
</div>
</div>
<el-empty v-else description="暂无 Scene 绑定"></el-empty>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>插件依赖拓扑</h3>
<p>显示 插件 -> scene -> target_type/target_ref -> 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 size="mini" :type="scope.row.valid_scene ? 'success' : 'danger'">
{% raw %}{{ scope.row.scene || '-' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_type" label="TargetType" min-width="110"></el-table-column>
<el-table-column prop="target_ref" label="TargetRef" min-width="160">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.valid_target ? 'success' : 'danger'">
{% raw %}{{ scope.row.target_ref || '-' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="provider" label="Provider" min-width="120"></el-table-column>
</el-table>
</el-card> </el-card>
</div> </div>
{% endblock %} {% endblock %}
@@ -201,18 +192,27 @@
currentView: '17', currentView: '17',
configPath: '', configPath: '',
topologyRows: [], topologyRows: [],
llmForm: { catalog: {
default_backend: '', default_scene: '',
providers: [],
dify_apps: [],
backends: [], backends: [],
scenes: [] scenes: []
} }
} }
}, },
computed: { computed: {
sceneNameOptions() {
return (this.catalog.scenes || []).map(item => item.name).filter(Boolean);
},
providerNameOptions() {
return (this.catalog.providers || []).map(item => item.name).filter(Boolean);
},
backendNameOptions() { backendNameOptions() {
return (this.llmForm.backends || []) return (this.catalog.backends || []).map(item => item.name).filter(Boolean);
.map(item => item.name) },
.filter(Boolean); difyAppNameOptions() {
return (this.catalog.dify_apps || []).map(item => item.name).filter(Boolean);
} }
}, },
mounted() { mounted() {
@@ -220,129 +220,230 @@
this.loadLlmConfig(); this.loadLlmConfig();
}, },
methods: { methods: {
newBackend() { newUid() {
// 统一后端对象结构,保证新增/编辑时字段完整,避免后端清洗时丢键。 return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
},
// Provider 模板:只放公共字段,避免 Dify 每个应用重复填写。
newProvider() {
return { return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, uid: this.newUid(),
name: '', name: '',
provider: 'dify', provider_type: 'dify',
mode: '', enabled: true,
model: '', config: {
api_base_url: '', provider: 'dify',
api_url: '', api_base_url: '',
endpoint: '', endpoint: 'workflows/run',
api_key: '', mode: 'workflow',
response_mode: '', response_mode: 'blocking',
workflow_output_key: '', request_timeout: 60,
timeout_seconds: 60, max_retries: 3,
request_timeout: 60, retry_delay_seconds: 1.0
temperature: 0.7, }
max_tokens: 1024, };
max_retries: 3, },
retry_delay_seconds: 1.0, // Dify 应用:只保留 app_key 与少量覆盖项,维护成本最低。
stream: false 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() { newScene() {
// Scene 是“业务场景 -> 后端”的路由单元,插件建议只依赖 scene。
return { return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, uid: this.newUid(),
name: '', 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) { normalizeBackend(item) {
return { return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, uid: this.newUid(),
name: item.name || '', name: item.name || '',
provider: item.provider || 'dify', enabled: item.enabled !== false,
mode: item.mode || '', config: { ...(item.config || {}) }
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
}; };
}, },
normalizeScene(item) { normalizeScene(item) {
return { return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, uid: this.newUid(),
name: item.name || '', name: item.name || '',
backend: item.backend || '' target_type: item.target_type || 'dify_app',
target_ref: item.target_ref || '',
enabled: item.enabled !== false
}; };
}, },
addBackend() { addProvider() { this.catalog.providers.push(this.newProvider()); },
this.llmForm.backends.push(this.newBackend()); removeProvider(index) {
}, const removed = this.catalog.providers[index];
addScene() { this.catalog.providers.splice(index, 1);
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) { if (removed && removed.name) {
(this.llmForm.scenes || []).forEach(scene => { (this.catalog.dify_apps || []).forEach(app => {
if (scene.backend === removed.name) { if (app.provider_template === removed.name) {
scene.backend = ''; 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) { 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() { async loadLlmConfig() {
try { try {
const response = await axios.get('/api/system/llm_config'); const response = await axios.get('/api/system/llm_config');
if (response.data.success) { if (!response.data.success) {
const data = response.data.data || {}; this.$message.error(response.data.message || '读取 LLM 目录失败');
this.configPath = data.config_path || ''; return;
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 配置失败');
} }
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) { } catch (error) {
this.$message.error(error.response?.data?.message || '读取全局 LLM 配置失败'); this.$message.error(error.response?.data?.message || '读取 LLM 目录失败');
} }
}, },
async saveLlmConfig() { async saveLlmConfig() {
// 前端先做一次严格校验,避免把空场景名或未绑定后端的记录提交到后端 // 严格校验:防止提交残缺目录导致运行时无法路由
const invalidScene = (this.llmForm.scenes || []).find(item => { const invalidProvider = (this.catalog.providers || []).find(item => !String(item.name || '').trim());
return !String(item.name || '').trim() || !String(item.backend || '').trim(); if (invalidProvider) {
}); this.$message.error('Provider 模板名称不能为空');
if (invalidScene) {
this.$message.error('场景配置不完整:请确保每一行都填写场景名并绑定后端');
return; 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 = { const payload = {
default_backend: this.llmForm.default_backend || '', default_scene: this.catalog.default_scene || '',
backends: (this.llmForm.backends || []).map(item => { providers: (this.catalog.providers || []).map(item => {
const cleaned = { ...item }; const cleaned = { ...item };
delete cleaned.uid; delete cleaned.uid;
return cleaned; 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 }; const cleaned = { ...item };
delete cleaned.uid; delete cleaned.uid;
return cleaned; return cleaned;
}) })
}; };
try { try {
const response = await axios.post('/api/system/llm_config', payload); const response = await axios.post('/api/system/llm_config', payload);
if (response.data.success) { if (response.data.success) {
@@ -359,45 +460,37 @@
}); });
</script> </script>
<style> <style>
.system-page { display: flex; flex-direction: column; gap: 24px; } .system-page { display: flex; flex-direction: column; gap: 20px; }
.page-hero { .page-hero {
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
background: linear-gradient(135deg, rgba(248,250,252,0.96), rgba(226,232,240,0.88)); background: linear-gradient(135deg, rgba(248,250,252,0.96), rgba(226,232,240,0.88));
border: 1px solid rgba(148,163,184,0.16); border: 1px solid rgba(148,163,184,0.16);
border-radius: 24px; padding: 28px 32px; box-shadow: 0 18px 48px rgba(15,23,42,0.06); border-radius: 24px; padding: 28px 32px; box-shadow: 0 18px 48px rgba(15,23,42,0.06);
} }
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #6366f1; font-weight: 700; margin-bottom: 8px; } .page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #0ea5e9; font-weight: 700; margin-bottom: 8px; }
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; } .page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
.page-hero-copy p { color: #64748b; font-size: 14px; } .page-hero-copy p { color: #64748b; font-size: 14px; }
.workspace-card .el-card__body { display: flex; flex-direction: column; gap: 16px; } .workspace-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); }
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; } .workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; } .workspace-header p { font-size: 13px; color: #64748b; }
.config-meta { display: flex; gap: 12px; color: #64748b; font-size: 12px; flex-wrap: wrap; } .config-meta { display: flex; gap: 12px; color: #64748b; font-size: 12px; flex-wrap: wrap; }
.backend-list { display: flex; flex-direction: column; gap: 16px; } .section-list { display: flex; flex-direction: column; gap: 12px; }
.scene-card, .topology-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); } .entry-card { border: 1px solid rgba(148,163,184,0.16); border-radius: 14px; }
.scene-list { display: flex; flex-direction: column; gap: 10px; } .entry-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.entry-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 10px 14px; }
.scene-row { .scene-row {
display: grid; display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto; grid-template-columns: minmax(260px, 1fr) minmax(160px, 220px) minmax(220px, 1fr) auto;
gap: 10px; gap: 10px;
align-items: center; 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 {
display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 8px 16px;
}
.backend-switches { display: flex; align-items: center; gap: 16px; padding: 0 12px 8px; }
.danger-text { color: #dc2626; } .danger-text { color: #dc2626; }
@media (max-width: 960px) { @media (max-width: 960px) {
.page-hero { flex-direction: column; align-items: flex-start; } .page-hero { flex-direction: column; align-items: flex-start; }
.workspace-header { flex-direction: column; align-items: flex-start; } .workspace-header { flex-direction: column; align-items: flex-start; }
.page-hero-actions { flex-wrap: wrap; } .entry-grid { grid-template-columns: 1fr; }
.scene-row { grid-template-columns: 1fr; } .scene-row { grid-template-columns: 1fr; }
.backend-grid { grid-template-columns: 1fr; }
} }
</style> </style>
{% endblock %} {% endblock %}

427
db/llm_catalog_db.py Normal file
View File

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

View File

@@ -18,6 +18,7 @@ from configuration import Config
from db.connection import DBConnectionManager from db.connection import DBConnectionManager
from db.contacts_db import ContactsDBOperator from db.contacts_db import ContactsDBOperator
from db.group_plugin_config_db import GroupPluginConfigDBOperator from db.group_plugin_config_db import GroupPluginConfigDBOperator
from db.llm_catalog_db import LLMCatalogDBOperator
from db.llm_config_db import LLMConfigDBOperator from db.llm_config_db import LLMConfigDBOperator
from db.plugin_schedule_db import PluginScheduleDBOperator from db.plugin_schedule_db import PluginScheduleDBOperator
from db.system_job_db import SystemJobDBOperator from db.system_job_db import SystemJobDBOperator
@@ -72,10 +73,14 @@ class Robot:
self.contacts_db = ContactsDBOperator(self.db_manager) self.contacts_db = ContactsDBOperator(self.db_manager)
self.group_plugin_config_db = GroupPluginConfigDBOperator(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.llm_config_db = LLMConfigDBOperator(self.db_manager)
self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager) self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager)
self.system_job_db = SystemJobDBOperator(self.db_manager) self.system_job_db = SystemJobDBOperator(self.db_manager)
self.group_plugin_config_db.init_tables() 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 # LLM 配置迁移到 MySQL
# 1. 先确保表存在; # 1. 先确保表存在;
# 2. 若库里没有配置,则从 config.yaml 的 llm 节点导入一次; # 2. 若库里没有配置,则从 config.yaml 的 llm 节点导入一次;

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
import yaml import yaml
@@ -11,18 +11,17 @@ from db.connection import DBConnectionManager
class LLMRegistry: class LLMRegistry:
"""集中式 LLM 配置注册器。 """统一 LLM 路由注册器scene -> target -> final_config
读取优先级 当前支持两套数据源
1. 优先读取 MySQLt_llm_config 1. 新目录模型推荐MySQL 的 provider_templates / dify_apps / backends / scenes
2. MySQL 不可用或无数据时,回退读取 config.yaml 的 llm 节点 2. 旧模型兜底config.yaml 的 llm(backends/scenes)
""" """
_cache: Dict[str, Any] = { _cache: Dict[str, Any] = {
# cache_until: 缓存过期时间戳,避免每次调用都打数据库;
# data: 最近一次成功读取并归一化后的 llm 配置对象。
"cache_until": 0.0, "cache_until": 0.0,
"data": {}, "catalog": {},
"legacy_llm": {},
} }
@classmethod @classmethod
@@ -30,40 +29,37 @@ class LLMRegistry:
return Path(__file__).resolve().parents[2] / "config.yaml" return Path(__file__).resolve().parents[2] / "config.yaml"
@classmethod @classmethod
def _load_llm_from_yaml(cls) -> Dict[str, Any]: def _load_yaml_root(cls) -> Dict[str, Any]:
""" YAML 读取 llm 配置(兜底来源)。""" """读取 YAML 根配置(仅作为迁移兜底)。"""
path = cls.get_root_config_path() path = cls.get_root_config_path()
if not path.exists(): if not path.exists():
return {} return {}
with open(path, "r", encoding="utf-8") as fp: with open(path, "r", encoding="utf-8") as fp:
root = yaml.safe_load(fp) or {} return yaml.safe_load(fp) or {}
llm_config = root.get("llm", {}) or {}
if not isinstance(llm_config, dict): @classmethod
return {} def _load_yaml_legacy_llm(cls) -> Dict[str, Any]:
return llm_config """读取旧 llm 结构backends/scenes作为回退。"""
root = cls._load_yaml_root()
llm = root.get("llm", {}) or {}
return llm if isinstance(llm, dict) else {}
@staticmethod @staticmethod
def _loads_json(value: Any) -> Dict[str, Any]: def _loads_json(value: Any, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""数据库 JSON 字段统一解析为 dict。""" """把 JSON 字段安全转为 dict。"""
if isinstance(value, dict): if isinstance(value, dict):
return value return value
if isinstance(value, str): if isinstance(value, str):
try: try:
obj = json.loads(value) obj = json.loads(value)
return obj if isinstance(obj, dict) else {} return obj if isinstance(obj, dict) else (default or {})
except json.JSONDecodeError: except json.JSONDecodeError:
return {} return default or {}
return {} return default or {}
@classmethod @classmethod
def _load_llm_from_mysql(cls) -> Dict[str, Any]: def _load_catalog_from_mysql(cls) -> Dict[str, Any]:
"""从 MySQL 读取 llm 配置。 """从 MySQL 读取新目录模型。"""
注意:
1. 该函数必须“无副作用失败”,即任何异常都返回空 dict交由上层做 YAML 回退;
2. 不依赖 Robot 实例,直接走 DBConnectionManager 单例,便于在插件调用链路中复用。
"""
try: try:
db_manager = DBConnectionManager.get_instance() db_manager = DBConnectionManager.get_instance()
if not db_manager or not db_manager.mysql_pool: if not db_manager or not db_manager.mysql_pool:
@@ -72,68 +68,240 @@ class LLMRegistry:
conn = db_manager.get_mysql_connection() conn = db_manager.get_mysql_connection()
try: try:
with conn.cursor(dictionary=True) as cursor: 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( cursor.execute(
""" """
SELECT default_backend, backends_json, scenes_json SELECT name, provider_template, app_key, workflow_output_key, config_json, enabled
FROM t_llm_config FROM t_llm_dify_apps
WHERE id = 1
LIMIT 1
""" """
) )
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: finally:
conn.close() conn.close()
if not row: providers = {
return {} str(row.get("name") or "").strip(): {
"name": str(row.get("name") or "").strip(),
return { "provider_type": str(row.get("provider_type") or "").strip().lower(),
"default_backend": str(row.get("default_backend") or "").strip(), "enabled": bool(int(row.get("enabled") or 0)),
"backends": cls._loads_json(row.get("backends_json")), "config": cls._loads_json(row.get("config_json"), {}),
"scenes": cls._loads_json(row.get("scenes_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: except Exception:
return {} return {}
@classmethod @classmethod
def _normalize_llm_config(cls, llm_config: Dict[str, Any]) -> Dict[str, Any]: def _load_runtime_snapshot(cls) -> Dict[str, Any]:
"""统一规整 llm 配置结构,避免下游出现类型分支""" """加载运行时快照目录模型优先legacy 兜底)"""
data = llm_config if isinstance(llm_config, dict) else {} now = time.time()
default_backend = str(data.get("default_backend") or "").strip() if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("catalog") is not None:
backends = data.get("backends", {}) or {} return {
scenes = data.get("scenes", {}) or {} "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): if not isinstance(backends, dict):
backends = {} backends = {}
if not isinstance(scenes, dict): if not isinstance(scenes, dict):
scenes = {} scenes = {}
return { backend_name = str(scenes.get(scene_name) or "").strip()
"default_backend": default_backend, if not backend_name:
"backends": backends, backend_name = str(llm.get("default_backend") or "").strip()
"scenes": scenes, 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 @classmethod
def get_llm_config(cls) -> Dict[str, Any]: def get_llm_config(cls) -> Dict[str, Any]:
"""获取运行时 LLM 配置(优先 MySQL失败回退 YAML""" """兼容输出:将目录模型转为 legacy llm(backends/scenes) 视图。
now = time.time()
if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("data"):
return cls._cache["data"]
llm_config = cls._load_llm_from_mysql() 说明:
if not llm_config: 1. 若目录模型可用,则返回“展开后”的 backends/scenes
llm_config = cls._load_llm_from_yaml() 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) backends: Dict[str, Any] = {}
# 轻量缓存 3 秒:兼顾“后台编辑后较快生效”和“降低高频调用的 DB 压力”。 scenes_map: Dict[str, str] = {}
cls._cache = {
"cache_until": now + 3.0, for backend_name, backend in (catalog.get("backends", {}) or {}).items():
"data": normalized, 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 @classmethod
def get_default_backend(cls) -> str: def get_default_backend(cls) -> str:
"""读取全局默认后端名称。"""
llm_config = cls.get_llm_config() llm_config = cls.get_llm_config()
return str(llm_config.get("default_backend", "") or "").strip() return str(llm_config.get("default_backend", "") or "").strip()
@@ -148,24 +316,20 @@ class LLMRegistry:
@classmethod @classmethod
def get_scenes(cls) -> Dict[str, str]: def get_scenes(cls) -> Dict[str, str]:
"""读取 llm.scenes 场景路由配置,返回 scene->backend 的映射。"""
llm_config = cls.get_llm_config() llm_config = cls.get_llm_config()
raw_scenes = llm_config.get("scenes", {}) or {} raw_scenes = llm_config.get("scenes", {}) or {}
if not isinstance(raw_scenes, dict): if not isinstance(raw_scenes, dict):
return {} return {}
normalized: Dict[str, str] = {} normalized: Dict[str, str] = {}
for raw_scene, raw_backend in raw_scenes.items(): for raw_scene, raw_backend in raw_scenes.items():
scene_name = str(raw_scene or "").strip() scene_name = str(raw_scene or "").strip()
backend_name = str(raw_backend or "").strip() backend_name = str(raw_backend or "").strip()
if not scene_name or not backend_name: if scene_name and backend_name:
continue normalized[scene_name] = backend_name
normalized[scene_name] = backend_name
return normalized return normalized
@classmethod @classmethod
def get_scene_backend_name(cls, scene_name: str) -> str: def get_scene_backend_name(cls, scene_name: str) -> str:
"""根据场景名解析后端名;若场景不存在则自动回退 default_backend。"""
name = str(scene_name or "").strip() name = str(scene_name or "").strip()
if not name: if not name:
return cls.get_default_backend() return cls.get_default_backend()
@@ -177,30 +341,37 @@ class LLMRegistry:
@classmethod @classmethod
def resolve_by_scene(cls, scene_name: str) -> Dict[str, Any]: def resolve_by_scene(cls, scene_name: str) -> Dict[str, Any]:
"""场景解析最终后端配置,并附带 scene 字段用于链路追踪""" """ scene 解析最终配置(优先目录模型)"""
backend_name = cls.get_scene_backend_name(scene_name) name = str(scene_name or "").strip()
backend = cls.get_backend(backend_name) if not name:
if backend_name: return {}
backend["backend"] = backend_name snapshot = cls._load_runtime_snapshot()
if scene_name: catalog = snapshot.get("catalog", {}) or {}
backend["scene"] = str(scene_name).strip() if catalog:
return backend 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 @classmethod
def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
local = dict(local_config or {}) local = dict(local_config or {})
# 严格模式说明: scene_name = str(local.get("scene") or "").strip()
# 1. 统一只认 scene 作为路由入口,避免 backend/backend_ref 等多入口并存;
# 2. 若未声明 scene则视为“调用方直接给出完整连接参数”原样返回 local。
scene_name = local.get("scene") or ""
scene_name = str(scene_name).strip()
if scene_name: if scene_name:
merged = cls.resolve_by_scene(scene_name) merged = cls.resolve_by_scene(scene_name)
merged.update(local) merged.update(local)
# 约定:只要声明了 scene就以 scene 路由结果为准 # 以场景路由结果为准,避免调用方覆盖关键路由字段
# 这样后台切换 scene 绑定时,无需改插件配置即可全局生效。 routed = cls.resolve_by_scene(scene_name)
merged["backend"] = cls.get_scene_backend_name(scene_name) if routed:
merged.update(routed)
merged["scene"] = scene_name merged["scene"] = scene_name
return merged return merged
return local 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()))