Files
abot/db/llm_catalog_db.py
liuwei 061f2b8084 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 旧结构兜底展示与运行时回退,保证目录表异常时系统仍可运行。
2026-04-20 15:09:24 +08:00

428 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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