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

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