# -*- 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