变更项: 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 旧结构兜底展示与运行时回退,保证目录表异常时系统仍可运行。
428 lines
20 KiB
Python
428 lines
20 KiB
Python
# -*- 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
|