Refactor backend to support interface-definition driven model resources

- Introduce a new model structure for managing interface definitions and model resources, enhancing the backend's capability to handle various service integrations.
- Update the Makefile to reflect changes in database seeding and resource management commands.
- Remove the deprecated credentials management routes and replace them with a unified model registry API.
- Modify existing routes and schemas to align with the new model structure, ensuring seamless integration with the frontend.
- Enhance database seeding scripts to populate new model resources and their configurations.
- Update README documentation to reflect the new architecture and usage instructions for model resources and interface definitions.
This commit is contained in:
Xin Wang
2026-06-14 19:36:12 +08:00
parent e25dfd4003
commit 90e3e8a0c0
32 changed files with 2577 additions and 1765 deletions

View File

@@ -1,15 +1,16 @@
"""数据表定义(SQLAlchemy 2.0)。
两张表,职责分离(见设计):
- ProviderCredential:模型凭证(key 明文存,同 dograh,靠 DB 访问控制兜底;读时打码)
- Assistant:助手配置,**只存模型/音色的"选项名",不嵌 key**
模型注册表由接口定义驱动:
- InterfaceDefinition:具体接入协议及其动态表单字段
- ModelResource:模型配置与鉴权值
- AssistantModelBinding:助手按能力选择模型资源
助手运行时再用 kind 去 ProviderCredential 取真 key(services/config_resolver.py)。
"""
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, String, func
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -17,22 +18,39 @@ class Base(DeclarativeBase):
pass
class ProviderCredential(Base):
"""模型资源凭证。字段对齐前端 ComponentsModelsPage 的 ModelResource"""
class InterfaceDefinition(Base):
"""具体接入协议,例如 xfyun-tts 与 xfyun-super-tts"""
__tablename__ = "provider_credentials"
__tablename__ = "interface_definitions"
id: Mapped[str] = mapped_column(String(40), primary_key=True) # model_xxx
name: Mapped[str] = mapped_column(String(128), default="") # 资源名称,如 "DeepSeek-V3"
model_id: Mapped[str] = mapped_column(String(128), default="") # 模型ID,如 "deepseek-chat"
type: Mapped[str] = mapped_column(String(16), index=True) # LLM|ASR|TTS|Realtime|Embedding
interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini
api_url: Mapped[str] = mapped_column(String(512), default="")
api_key: Mapped[str] = mapped_column(String(512), default="") # 明文
voice: Mapped[str] = mapped_column(String(128), default="") # TTS 音色
speed: Mapped[float] = mapped_column(Float, default=1.0) # TTS 语速
language: Mapped[str] = mapped_column(String(32), default="") # ASR 语言
# 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选)
interface_type: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(128))
capability: Mapped[str] = mapped_column(String(16), index=True)
field_schema: Mapped[dict] = mapped_column(JSONB, default=dict)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
version: Mapped[int] = mapped_column(default=1)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ModelResource(Base):
"""统一模型资源:接口类型决定能力、鉴权字段和调用参数。"""
__tablename__ = "model_resources"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(128), default="")
capability: Mapped[str] = mapped_column(String(16), index=True)
interface_type: Mapped[str] = mapped_column(
String(64),
ForeignKey("interface_definitions.interface_type", ondelete="RESTRICT"),
index=True,
)
values: Mapped[dict] = mapped_column(JSONB, default=dict)
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
@@ -41,7 +59,7 @@ class ProviderCredential(Base):
class KnowledgeBase(Base):
"""知识库注册表。本身引用一个 Embedding 凭证(用哪个向量模型)
"""知识库注册表。本身引用一个 Embedding 模型资源
文档/分块(pgvector)是 KB 内部实现,这里先不展开;助手侧只认 knowledge_base_id。
"""
@@ -51,10 +69,9 @@ class KnowledgeBase(Base):
id: Mapped[str] = mapped_column(String(40), primary_key=True) # kb_xxx
name: Mapped[str] = mapped_column(String(128))
description: Mapped[str] = mapped_column(String(2048), default="")
# 该 KB 用哪个向量模型;凭证被删则置空
embedding_credential_id: Mapped[str | None] = mapped_column(
embedding_model_resource_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("provider_credentials.id", ondelete="SET NULL"),
ForeignKey("model_resources.id", ondelete="SET NULL"),
nullable=True,
)
status: Mapped[str] = mapped_column(String(16), default="active") # active|archived
@@ -80,19 +97,6 @@ class Assistant(Base):
greeting: Mapped[str] = mapped_column(String(2048), default="")
enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True)
# ---- 引用"注册好的资源":凭证被删 → SET NULL(resolver 有默认/.env 兜底) ----
llm_credential_id: Mapped[str | None] = mapped_column(
String(40), ForeignKey("provider_credentials.id", ondelete="SET NULL"), nullable=True
)
asr_credential_id: Mapped[str | None] = mapped_column(
String(40), ForeignKey("provider_credentials.id", ondelete="SET NULL"), nullable=True
)
tts_credential_id: Mapped[str | None] = mapped_column(
String(40), ForeignKey("provider_credentials.id", ondelete="SET NULL"), nullable=True
)
realtime_credential_id: Mapped[str | None] = mapped_column(
String(40), ForeignKey("provider_credentials.id", ondelete="SET NULL"), nullable=True
)
# KB 引用:被引用时禁止删 KB(RESTRICT),无默认兜底
knowledge_base_id: Mapped[str | None] = mapped_column(
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
@@ -101,7 +105,7 @@ class Assistant(Base):
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵,同凭证)
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
# workflow 专属:图(nodes/edges)。要版本化时再迁出到 assistant_workflow 表
graph: Mapped[dict] = mapped_column(JSON, default=dict)
@@ -110,3 +114,26 @@ class Assistant(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantModelBinding(Base):
"""助手按能力绑定统一模型资源config 可覆盖资源默认 options。"""
__tablename__ = "assistant_model_bindings"
assistant_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("assistants.id", ondelete="CASCADE"),
primary_key=True,
)
capability: Mapped[str] = mapped_column(String(16), primary_key=True)
model_resource_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("model_resources.id", ondelete="RESTRICT"),
index=True,
)
config: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -1,51 +1,42 @@
-- 知识库 + 助手种子数据(依赖 seed_credentials.sql 先灌入 model_xxx 凭证)
--
-- 用法(从仓库根目录):
-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_assistants.sql
-- 或:make db-seed-assistants(凭证已就绪时);make db-seed 会按顺序全灌。
--
-- 说明:
-- * id 固定(kb_001 / asst_001..005)+ ON CONFLICT 幂等,可重复执行。
-- * 引用 seed_credentials 的 model_001(LLM)/003(ASR)/005(TTS)/010(Embedding)。
-- * 宽表 STI:瘦类型用真列(prompt/api_url/api_key/app_id),workflow 用 graph 列。
-- * api_key 在库里明文(读取走 API 才打码),这里填示例占位。
-- 知识库 + 助手种子数据依赖 seed_model_resources.sql
-- 知识库(引用 Embedding 凭证 model_010)
INSERT INTO knowledge_bases (id, name, description, embedding_credential_id, status)
INSERT INTO knowledge_bases
(id, name, description, embedding_model_resource_id, status)
VALUES
('kb_001', '政务政策知识库', '政策解读 / 办事指南示例库', 'model_010', 'active')
('kb_001', '政务政策知识库', '政策解读 / 办事指南示例库', 'model_003', 'active')
ON CONFLICT (id) DO NOTHING;
-- 助手(一种类型一条)
INSERT INTO assistants (
id, name, type, runtime_mode, greeting, enable_interrupt,
llm_credential_id, asr_credential_id, tts_credential_id,
realtime_credential_id, knowledge_base_id,
prompt, api_url, api_key, app_id, graph
knowledge_base_id, prompt, api_url, api_key, app_id, graph
) VALUES
-- 提示词:llm/asr/tts + 知识库,prompt 真列
('asst_001', '政务咨询助手', 'prompt', 'pipeline', '您好,我是政务助手,请问有什么可以帮您?', TRUE,
'model_001', 'model_003', 'model_005', NULL, 'kb_001',
'你是一名专业的政务咨询助手,回答准确、简洁,不编造政策内容。', '', '', '', '{}'),
-- 工作流:asr/tts + graph 列(最小图)
'kb_001', '你是一名专业的政务咨询助手,回答准确、简洁,不编造政策内容。', '', '', '', '{}'),
('asst_002', '热线工单助手', 'workflow', 'pipeline', '', TRUE,
NULL, 'model_003', 'model_005', NULL, NULL,
'', '', '', '',
NULL, '', '', '', '',
'{"nodes":[{"id":"1","type":"startCall","position":{"x":0,"y":0},"data":{"name":"开场","prompt":"你好,请问需要办理什么业务?"}}],"edges":[]}'),
-- Dify:asr/tts + api_url/api_key
('asst_003', 'Dify 客服助手', 'dify', 'pipeline', '', TRUE,
NULL, 'model_003', 'model_005', NULL, NULL,
'', 'https://api.dify.ai/v1', 'app-dify-demo-key', '', '{}'),
-- FastGPT:asr/tts + app_id/api_url/api_key
NULL, '', 'https://api.dify.ai/v1', 'app-dify-demo-key', '', '{}'),
('asst_004', 'FastGPT 售后助手', 'fastgpt', 'pipeline', '', TRUE,
NULL, 'model_003', 'model_005', NULL, NULL,
'', 'https://api.fastgpt.in/api/v1/chat/completions', 'fastgpt-demo-key', 'app-fastgpt-001', '{}'),
-- OpenCode:asr/tts + prompt/api_url/api_key
NULL, '', 'https://api.fastgpt.in/api/v1/chat/completions', 'fastgpt-demo-key', 'app-fastgpt-001', '{}'),
('asst_005', 'OpenCode 代码助手', 'opencode', 'pipeline', '', TRUE,
NULL, 'model_003', 'model_005', NULL, NULL,
'你是一个代码助手的语音界面,用简洁口语回答工程问题。', 'http://localhost:4096', 'opencode-demo-key', '', '{}')
NULL, '你是一个代码助手的语音界面,用简洁口语回答工程问题。', 'http://localhost:4096', 'opencode-demo-key', '', '{}')
ON CONFLICT (id) DO NOTHING;
INSERT INTO assistant_model_bindings
(assistant_id, capability, model_resource_id, config)
VALUES
('asst_001', 'LLM', 'model_001', '{}'),
('asst_001', 'ASR', 'model_002', '{}'),
('asst_001', 'TTS', 'model_004', '{}'),
('asst_002', 'ASR', 'model_002', '{}'),
('asst_002', 'TTS', 'model_004', '{}'),
('asst_003', 'ASR', 'model_002', '{}'),
('asst_003', 'TTS', 'model_004', '{}'),
('asst_004', 'ASR', 'model_002', '{}'),
('asst_004', 'TTS', 'model_004', '{}'),
('asst_005', 'ASR', 'model_002', '{}'),
('asst_005', 'TTS', 'model_004', '{}')
ON CONFLICT (assistant_id, capability) DO UPDATE SET
model_resource_id = EXCLUDED.model_resource_id,
updated_at = now();

View File

@@ -1,38 +0,0 @@
-- 模型凭证种子数据(对应前端原 mockModels 的 12 条)。
--
-- 用法(从仓库根目录):
-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_credentials.sql
--
-- 说明:
-- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等更新。
-- * api_key 在库里是明文(读取走 API 时才打码),这里填的是占位示例 key。
-- * 每种 type 选第一条置为默认(is_default),供后端 config_resolver 解析使用。
-- * TTS 使用 voice/speed;ASR 使用 language;其他类型保持空值/default。
INSERT INTO provider_credentials
(id, name, model_id, type, interface_type, api_url, api_key, voice, speed, language, is_default)
VALUES
('model_001', 'DeepSeek-Chat', 'deepseek-chat', 'LLM', 'openai', 'https://api.deepseek.com/v1', 'sk-230701ff1b6143ecbf322b3170606016', '', 1.0, '', TRUE),
('model_002', 'SiliconFlow-TeleSpeechASR', 'TeleAI/TeleSpeechASR', 'ASR', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', '', 1.0, 'zh', FALSE),
('model_003', 'SiliconFlow-Qwen3-Embedding-4B', 'Qwen/Qwen3-Embedding-4B', 'Embedding', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', '', 1.0, '', TRUE),
('model_004', 'SiliconFlow-CosyVoice2-0.5B', 'FunAudioLLM/CosyVoice2-0.5B', 'TTS', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', 'FunAudioLLM/CosyVoice2-0.5B:anna', 1.0, '', FALSE),
('model_005', 'Qwen-Max', 'qwen-max', 'LLM', 'openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-qwen-4d8e2a6f0c', '', 1.0, '', FALSE),
('model_006', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', '{"appId":"replace-me","apiKey":"replace-me","apiSecret":"replace-me"}', '', 1.0, 'zh', TRUE),
('model_007', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', '', 1.0, 'zh', FALSE),
('model_008', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', '{"appId":"replace-me","apiKey":"replace-me","apiSecret":"replace-me"}', 'xiaoyan', 1.0, '', TRUE),
('model_009', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', 'longxiaochun', 1.0, '', FALSE),
('model_010', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', '', 1.0, '', TRUE),
('model_011', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', '', 1.0, '', FALSE),
('model_012', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', '', 1.0, '', FALSE)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
model_id = EXCLUDED.model_id,
type = EXCLUDED.type,
interface_type = EXCLUDED.interface_type,
api_url = EXCLUDED.api_url,
api_key = EXCLUDED.api_key,
voice = EXCLUDED.voice,
speed = EXCLUDED.speed,
language = EXCLUDED.language,
is_default = EXCLUDED.is_default,
updated_at = now();

View File

@@ -0,0 +1,48 @@
-- 模型资源种子数据。依赖应用启动时写入 interface_definitions。
INSERT INTO model_resources
(id, name, capability, interface_type, values, secrets, enabled, is_default)
VALUES
('model_001', 'DeepSeek-Chat', 'LLM', 'openai-llm',
'{"modelId":"deepseek-chat","apiUrl":"https://api.deepseek.com/v1","temperature":0.7}',
'{"apiKey":"replace-me"}', TRUE, TRUE),
('model_002', 'SiliconFlow-TeleSpeechASR', 'ASR', 'openai-asr',
'{"modelId":"TeleAI/TeleSpeechASR","apiUrl":"https://api.siliconflow.cn/v1","language":"zh"}',
'{"apiKey":"replace-me"}', TRUE, FALSE),
('model_003', 'SiliconFlow-Qwen3-Embedding-4B', 'Embedding', 'openai-embedding',
'{"modelId":"Qwen/Qwen3-Embedding-4B","apiUrl":"https://api.siliconflow.cn/v1"}',
'{"apiKey":"replace-me"}', TRUE, TRUE),
('model_004', 'SiliconFlow-CosyVoice2-0.5B', 'TTS', 'openai-tts',
'{"modelId":"FunAudioLLM/CosyVoice2-0.5B","apiUrl":"https://api.siliconflow.cn/v1","voice":"FunAudioLLM/CosyVoice2-0.5B:anna","speed":1.0,"sourceSampleRate":24000}',
'{"apiKey":"replace-me"}', TRUE, FALSE),
'{"apiKey":"replace-me"}', TRUE, FALSE),
('model_005', '讯飞语音识别', 'ASR', 'xfyun-asr',
'{"apiUrl":"https://iat-api.xfyun.cn/v2/iat","language":"zh_cn","domain":"iat","accent":"mandarin","dynamicCorrection":false,"frameSize":1280}',
'{"appId":"replace-me","apiKey":"replace-me","apiSecret":"replace-me"}', TRUE, TRUE),
('model_006', 'Paraformer 识别', 'ASR', 'dashscope-asr',
'{"modelId":"paraformer-realtime-v2","apiUrl":"https://dashscope.aliyuncs.com/api/v1/services/audio/asr","language":"zh"}',
'{"apiKey":"replace-me"}', TRUE, FALSE),
('model_007', '讯飞语音合成', 'TTS', 'xfyun-tts',
'{"apiUrl":"https://tts-api.xfyun.cn/v2/tts","voice":"xiaoyan","speed":50,"volume":50,"pitch":50,"sourceSampleRate":16000}',
'{"appId":"replace-me","apiKey":"replace-me","apiSecret":"replace-me"}', TRUE, TRUE),
('model_008', 'CosyVoice 合成', 'TTS', 'dashscope-tts',
'{"modelId":"cosyvoice-v1","apiUrl":"https://dashscope.aliyuncs.com/api/v1/services/audio/tts","voice":"longxiaochun"}',
'{"apiKey":"replace-me"}', TRUE, FALSE),
('model_009', 'GPT Realtime', 'Realtime', 'openai-realtime',
'{"modelId":"gpt-4o-realtime-preview","apiUrl":"https://api.openai.com/v1/realtime"}',
'{"apiKey":"replace-me"}', TRUE, TRUE),
('model_010', 'Gemini Live', 'Realtime', 'gemini-realtime',
'{"modelId":"gemini-2.0-flash-live","apiUrl":"https://generativelanguage.googleapis.com/v1beta"}',
'{"apiKey":"replace-me"}', TRUE, FALSE),
('model_011', 'text-embedding-3', 'Embedding', 'openai-embedding',
'{"modelId":"text-embedding-3-small","apiUrl":"https://api.openai.com/v1/embeddings"}',
'{"apiKey":"replace-me"}', TRUE, FALSE)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
capability = EXCLUDED.capability,
interface_type = EXCLUDED.interface_type,
values = EXCLUDED.values,
secrets = EXCLUDED.secrets,
enabled = EXCLUDED.enabled,
is_default = EXCLUDED.is_default,
updated_at = now();

View File

@@ -6,9 +6,11 @@
"""
from collections.abc import AsyncGenerator
import json
import config
from db.models import Base
from services.interface_catalog import INTERFACE_DEFINITIONS
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncSession,
@@ -28,22 +30,26 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# MVP 兼容迁移:create_all 不会给已存在的表补列。
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS voice VARCHAR(128) NOT NULL DEFAULT ''"
"ALTER TABLE interface_definitions "
"ALTER COLUMN field_schema TYPE JSONB USING field_schema::jsonb"
)
)
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS speed DOUBLE PRECISION NOT NULL DEFAULT 1.0"
for definition in INTERFACE_DEFINITIONS:
await conn.execute(
text(
"INSERT INTO interface_definitions "
"(interface_type, name, capability, field_schema, enabled, version) "
"VALUES (:interface_type, :name, :capability, CAST(:field_schema AS jsonb), TRUE, 1) "
"ON CONFLICT (interface_type) DO UPDATE SET "
"name = EXCLUDED.name, capability = EXCLUDED.capability, "
"field_schema = EXCLUDED.field_schema, enabled = TRUE, updated_at = now()"
),
{
"interface_type": definition["interface_type"],
"name": definition["name"],
"capability": definition["capability"],
"field_schema": json.dumps({"fields": definition["fields"]}),
},
)
)
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS language VARCHAR(32) NOT NULL DEFAULT ''"
)
)