From 90e3e8a0c021588e14f5bb31b28335b1ca509af6 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 14 Jun 2026 19:36:12 +0800 Subject: [PATCH] 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. --- Makefile | 18 +- backend/README.md | 38 +- backend/app.py | 7 +- backend/config.py | 2 +- backend/db/models.py | 101 +- backend/db/seed_assistants.sql | 65 +- backend/db/seed_credentials.sql | 38 - backend/db/seed_model_resources.sql | 48 + backend/db/session.py | 34 +- backend/models.py | 11 +- backend/routes/assistants.py | 163 +- backend/routes/credentials.py | 190 --- backend/routes/knowledge_bases.py | 18 +- backend/routes/model_registry.py | 249 +++ backend/schemas.py | 78 +- backend/services/config_resolver.py | 108 +- backend/services/interface_catalog.py | 155 ++ backend/services/masking.py | 20 + ...ial_tester.py => model_resource_tester.py} | 114 +- backend/services/pipecat/service_factory.py | 73 +- backend/services/pipecat/xfyun_config.py | 47 +- deploy/README.md | 2 +- deploy/nginx/ai-video.dev.conf | 2 +- .../src/components/pages/AssistantPage.tsx | 617 +++++--- .../components/pages/ComponentsModelsPage.tsx | 1398 ++++++++--------- frontend/src/components/ui/data-list.tsx | 203 +++ frontend/src/components/ui/filter-pills.tsx | 42 + frontend/src/components/ui/list-toolbar.tsx | 26 + frontend/src/components/ui/page-header.tsx | 39 + frontend/src/components/ui/search-input.tsx | 37 + .../src/components/ui/waveform-timeline.tsx | 264 +++- frontend/src/lib/api.ts | 135 +- 32 files changed, 2577 insertions(+), 1765 deletions(-) delete mode 100644 backend/db/seed_credentials.sql create mode 100644 backend/db/seed_model_resources.sql delete mode 100644 backend/routes/credentials.py create mode 100644 backend/routes/model_registry.py create mode 100644 backend/services/interface_catalog.py rename backend/services/{credential_tester.py => model_resource_tester.py} (52%) create mode 100644 frontend/src/components/ui/data-list.tsx create mode 100644 frontend/src/components/ui/filter-pills.tsx create mode 100644 frontend/src/components/ui/list-toolbar.tsx create mode 100644 frontend/src/components/ui/page-header.tsx create mode 100644 frontend/src/components/ui/search-input.tsx diff --git a/Makefile b/Makefile index e051e99..8438d55 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ PSQL = docker compose exec -T postgres psql -U postgres -d postgres .DEFAULT_GOAL := help -.PHONY: help up down restart logs api-logs db db-list db-seed db-seed-credentials db-seed-assistants db-clear db-reset +.PHONY: help up down restart logs api-logs db db-list db-seed db-seed-model-resources db-seed-assistants db-clear db-reset help: ## 列出所有可用目标 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @@ -33,19 +33,19 @@ api-logs: ## 只看后端日志 db: ## 进入交互式 psql docker compose exec postgres psql -U postgres -d postgres -db-list: ## 列出凭证与助手(key 明文,仅本地调试用) - @$(PSQL) -c "SELECT id, name, type, interface_type, is_default FROM provider_credentials ORDER BY id;" +db-list: ## 列出模型资源与助手 + @$(PSQL) -c "SELECT id, name, capability, interface_type, is_default FROM model_resources ORDER BY id;" @$(PSQL) -c "SELECT id, name, type FROM assistants ORDER BY id;" -db-seed-credentials: ## 灌入 12 条模型凭证种子(幂等) - $(PSQL) < backend/db/seed_credentials.sql +db-seed-model-resources: ## 灌入 12 条模型资源种子(幂等) + $(PSQL) < backend/db/seed_model_resources.sql -db-seed-assistants: ## 灌入 知识库 + 助手 种子(幂等;依赖凭证已就绪) +db-seed-assistants: ## 灌入 知识库 + 助手 种子(幂等;依赖模型资源已就绪) $(PSQL) < backend/db/seed_assistants.sql -db-seed: db-seed-credentials db-seed-assistants ## 全量灌种子(凭证→知识库→助手,幂等,可重复执行) +db-seed: db-seed-model-resources db-seed-assistants ## 全量灌种子(模型资源→知识库→助手,幂等,可重复执行) -db-clear: ## 清空 助手/知识库/凭证 三表(按依赖顺序) - $(PSQL) -c "TRUNCATE assistants, knowledge_bases, provider_credentials CASCADE;" +db-clear: ## 清空 助手/知识库/模型资源(按依赖顺序) + $(PSQL) -c "TRUNCATE assistant_model_bindings, assistants, knowledge_bases, model_resources CASCADE;" db-reset: db-clear db-seed ## 清空后重新灌全部种子 diff --git a/backend/README.md b/backend/README.md index dc649a8..b058f02 100644 --- a/backend/README.md +++ b/backend/README.md @@ -25,7 +25,7 @@ pipecat 把"管线"和"输出方式"解耦:同一条 `STT→LLM→TTS` 管线可 ``` ai-video-backend/ ├── app.py # FastAPI 入口,挂路由 + CORS -├── config.py # 读 .env,所有 provider 接入点 +├── config.py # 读 .env,模型接口环境变量兜底 ├── models.py # AssistantConfig(对齐前端 AssistantForm) ├── routes/ # 一个文件一组端点(对齐 dograh routes/) │ ├── health.py @@ -33,7 +33,7 @@ ai-video-backend/ │ └── voice_ws.py # WS 裸音频流 ├── services/ │ └── pipecat/ # 引擎(对齐 dograh services/pipecat/) -│ ├── service_factory.py # 建 STT/LLM/TTS(加 provider 在此) +│ ├── service_factory.py # 建 STT/LLM/TTS(按 interface_type 分发) │ ├── transports.py # transport 工厂(加输出方式在此) │ └── pipeline.py # 管线拼装与运行(transport 无关) ├── Dockerfile @@ -57,15 +57,33 @@ ai-video-backend/ ### 讯飞 ASR / TTS / SuperTTS -讯飞继续复用 `ProviderCredential` 的现有字段,不增加专属列: +讯飞鉴权直接存入对应 `ModelResource.secrets`,接口参数存入 `ModelResource.values`: -- `interface_type`: `xfyun` -- `api_url`: 讯飞 WebSocket URL(`https://` 会自动转为 `wss://`) -- `api_key`: `{"appId":"...","apiKey":"...","apiSecret":"..."}` -- ASR `model_id`: `iat` -- 普通 TTS `model_id`: `tts` -- 超拟人 TTS `model_id`: `supertts`(包含 `/private/` 的 URL 也会自动识别) -- TTS `voice`: 讯飞音色 ID;`speed=1.0` 对应讯飞正常语速 `50` +- 普通语音识别:`interface_type=xfyun-asr` +- 普通语音合成:`interface_type=xfyun-tts` +- 超拟人语音合成:`interface_type=xfyun-super-tts` +- `values.apiUrl` 保存讯飞 WebSocket URL,音色、语速等可选参数也放在 `values` +- `secrets` 分别保存 `appId`、`apiKey`、`apiSecret` + +## 接口定义驱动的模型注册表 + +LLM、ASR、TTS、Embedding、Realtime 使用同一套两层结构: + +```text +assistant_model_bindings -> model_resources -> interface_definitions +``` + +- `interface_definitions`: 定义具体接入协议、能力和动态表单字段。 +- `model_resources`: 每条资源自带 `values/secrets`,不复用供应商账号。 +- `assistant_model_bindings`: 助手按能力选择模型资源。 + +`interface_type` 是具体协议,例如 `xfyun-asr`、`xfyun-tts`、 +`xfyun-super-tts`,后端严格按它选择服务实现,不根据模型 ID 或 URL 猜测。 + +API: + +- `/api/interface-definitions`: 前端读取字段定义并动态生成 Dialog。 +- `/api/model-resources`: 统一模型资源 CRUD,敏感字段逐项打码。 ## 本地运行(用 uv,Python 3.12) diff --git a/backend/app.py b/backend/app.py index b2cda5b..e5cdcdc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5,7 +5,8 @@ 路由分组(对齐 dograh 的 routes/ 结构): /health 健康检查 /api/assistants 助手 CRUD - /api/credentials 模型凭证 CRUD(key 打码) + /api/interface-definitions 接口定义 + /api/model-resources 模型资源 CRUD /ws/voice WebRTC 输出(浏览器) /ws/stream WS 输出(裸音频流) """ @@ -20,9 +21,9 @@ from fastapi.middleware.cors import CORSMiddleware from routes import ( assistants, - credentials, health, knowledge_bases, + model_registry, voice_webrtc, voice_ws, ) @@ -46,8 +47,8 @@ app.add_middleware( app.include_router(health.router) app.include_router(assistants.router) -app.include_router(credentials.router) app.include_router(knowledge_bases.router) +app.include_router(model_registry.router) app.include_router(voice_webrtc.router) app.include_router(voice_ws.router) diff --git a/backend/config.py b/backend/config.py index b31ac68..5fa3473 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,4 +1,4 @@ -"""集中读取环境变量。所有 provider 的接入点都在这里,改栈只改 .env。""" +"""集中读取环境变量。所有模型接口的环境变量兜底都在这里。""" import os diff --git a/backend/db/models.py b/backend/db/models.py index a9a7f57..b983fe9 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -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() + ) diff --git a/backend/db/seed_assistants.sql b/backend/db/seed_assistants.sql index 69008f7..b619dcc 100644 --- a/backend/db/seed_assistants.sql +++ b/backend/db/seed_assistants.sql @@ -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(); diff --git a/backend/db/seed_credentials.sql b/backend/db/seed_credentials.sql deleted file mode 100644 index a1bc545..0000000 --- a/backend/db/seed_credentials.sql +++ /dev/null @@ -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(); diff --git a/backend/db/seed_model_resources.sql b/backend/db/seed_model_resources.sql new file mode 100644 index 0000000..304090d --- /dev/null +++ b/backend/db/seed_model_resources.sql @@ -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(); diff --git a/backend/db/session.py b/backend/db/session.py index 6e7bb20..af8f9d1 100644 --- a/backend/db/session.py +++ b/backend/db/session.py @@ -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 ''" - ) - ) diff --git a/backend/models.py b/backend/models.py index a2733fe..8b92523 100644 --- a/backend/models.py +++ b/backend/models.py @@ -31,8 +31,15 @@ class AssistantConfig(BaseModel): stt_language: str = "" tts_speed: float = 1.0 realtimeModel: str = "" - stt_interface_type: str = "openai" - tts_interface_type: str = "openai" + llm_interface_type: str = "openai-llm" + stt_interface_type: str = "openai-asr" + tts_interface_type: str = "openai-tts" + llm_values: dict = {} + llm_secrets: dict = {} + stt_values: dict = {} + stt_secrets: dict = {} + tts_values: dict = {} + tts_secrets: dict = {} enableInterrupt: bool = True diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index ffc808e..08569e6 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -1,12 +1,8 @@ -"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。 - -模型/KB 以 FK 引用注册表;瘦类型字段直接是真列。外部类型(dify/fastgpt/opencode)的 -api_key 是私有密钥,读时打码、写时哨兵(列级,复用 services/masking,与凭证表一致)。 -""" +"""Assistant CRUD backed by capability-to-model-resource bindings.""" import uuid -from db.models import Assistant +from db.models import Assistant, AssistantModelBinding, ModelResource from db.session import get_session from fastapi import APIRouter, Depends, HTTPException from schemas import AssistantOut, AssistantUpsert @@ -15,27 +11,62 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter(prefix="/api/assistants", tags=["assistants"]) +CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding") -def _to_out(a: Assistant) -> AssistantOut: +async def _sync_bindings( + session: AsyncSession, assistant_id: str, resource_ids: dict[str, str] +) -> None: + for capability in CAPABILITIES: + resource_id = resource_ids.get(capability) + binding = await session.get(AssistantModelBinding, (assistant_id, capability)) + if not resource_id: + if binding: + await session.delete(binding) + continue + resource = await session.get(ModelResource, resource_id) + if not resource or resource.capability != capability: + raise HTTPException(400, f"{capability} 绑定必须引用同能力的模型资源") + if binding: + binding.model_resource_id = resource_id + else: + session.add( + AssistantModelBinding( + assistant_id=assistant_id, + capability=capability, + model_resource_id=resource_id, + config={}, + ) + ) + + +async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, str]: + bindings = ( + await session.execute( + select(AssistantModelBinding).where( + AssistantModelBinding.assistant_id == assistant_id + ) + ) + ).scalars().all() + return {binding.capability: binding.model_resource_id for binding in bindings} + + +async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut: return AssistantOut( - id=a.id, - name=a.name, - type=a.type, # type: ignore[arg-type] - runtime_mode=a.runtime_mode, # type: ignore[arg-type] - greeting=a.greeting, - enable_interrupt=a.enable_interrupt, - llm_credential_id=a.llm_credential_id, - asr_credential_id=a.asr_credential_id, - tts_credential_id=a.tts_credential_id, - realtime_credential_id=a.realtime_credential_id, - knowledge_base_id=a.knowledge_base_id, - prompt=a.prompt, - api_url=a.api_url, - api_key=mask(a.api_key), # 仅外部类型有值;空串 mask 仍是空串 - app_id=a.app_id, - graph=a.graph or {}, - updated_at=a.updated_at.isoformat() if a.updated_at else None, + id=assistant.id, + name=assistant.name, + type=assistant.type, # type: ignore[arg-type] + runtime_mode=assistant.runtime_mode, # type: ignore[arg-type] + greeting=assistant.greeting, + enable_interrupt=assistant.enable_interrupt, + model_resource_ids=await _resource_ids(session, assistant.id), + knowledge_base_id=assistant.knowledge_base_id, + prompt=assistant.prompt, + api_url=assistant.api_url, + api_key=mask(assistant.api_key), + app_id=assistant.app_id, + graph=assistant.graph or {}, + updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None, ) @@ -44,60 +75,61 @@ async def list_assistants(session: AsyncSession = Depends(get_session)): rows = ( await session.execute(select(Assistant).order_by(Assistant.updated_at.desc())) ).scalars().all() - return [_to_out(a) for a in rows] + return [await _to_out(session, assistant) for assistant in rows] @router.post("", response_model=AssistantOut) async def create_assistant( body: AssistantUpsert, session: AsyncSession = Depends(get_session) ): - a = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **body.model_dump()) - session.add(a) + data = body.model_dump() + resource_ids = data.pop("model_resource_ids") + assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data) + session.add(assistant) + await session.flush() + await _sync_bindings(session, assistant.id, resource_ids) await session.commit() - await session.refresh(a) - return _to_out(a) + await session.refresh(assistant) + return await _to_out(session, assistant) @router.get("/{assistant_id}", response_model=AssistantOut) async def get_assistant( assistant_id: str, session: AsyncSession = Depends(get_session) ): - a = await session.get(Assistant, assistant_id) - if not a: + assistant = await session.get(Assistant, assistant_id) + if not assistant: raise HTTPException(404, "助手不存在") - return _to_out(a) + return await _to_out(session, assistant) @router.post("/{assistant_id}/duplicate", response_model=AssistantOut) async def duplicate_assistant( assistant_id: str, session: AsyncSession = Depends(get_session) ): - """服务端整行复制:含真实 api_key,DB→DB,密钥不经过浏览器,副本可直接用。""" - src = await session.get(Assistant, assistant_id) - if not src: + source = await session.get(Assistant, assistant_id) + if not source: raise HTTPException(404, "助手不存在") - a = Assistant( + assistant = Assistant( id=f"asst_{uuid.uuid4().hex[:12]}", - name=f"{src.name} 副本", - type=src.type, - runtime_mode=src.runtime_mode, - greeting=src.greeting, - enable_interrupt=src.enable_interrupt, - llm_credential_id=src.llm_credential_id, - asr_credential_id=src.asr_credential_id, - tts_credential_id=src.tts_credential_id, - realtime_credential_id=src.realtime_credential_id, - knowledge_base_id=src.knowledge_base_id, - prompt=src.prompt, - api_url=src.api_url, - api_key=src.api_key, # 真 key,DB→DB - app_id=src.app_id, - graph=dict(src.graph or {}), # 浅拷贝,避免与源行共享同一 dict + name=f"{source.name} 副本", + type=source.type, + runtime_mode=source.runtime_mode, + greeting=source.greeting, + enable_interrupt=source.enable_interrupt, + knowledge_base_id=source.knowledge_base_id, + prompt=source.prompt, + api_url=source.api_url, + api_key=source.api_key, + app_id=source.app_id, + graph=dict(source.graph or {}), ) - session.add(a) + session.add(assistant) + await session.flush() + await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id)) await session.commit() - await session.refresh(a) - return _to_out(a) + await session.refresh(assistant) + return await _to_out(session, assistant) @router.put("/{assistant_id}", response_model=AssistantOut) @@ -106,26 +138,27 @@ async def update_assistant( body: AssistantUpsert, session: AsyncSession = Depends(get_session), ): - a = await session.get(Assistant, assistant_id) - if not a: + assistant = await session.get(Assistant, assistant_id) + if not assistant: raise HTTPException(404, "助手不存在") data = body.model_dump() - # 写时哨兵(列级):回传打码/空 api_key → 保留旧 key - data["api_key"] = resolve_incoming_key(data["api_key"], a.api_key) - for k, v in data.items(): - setattr(a, k, v) + resource_ids = data.pop("model_resource_ids") + data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key) + for key, value in data.items(): + setattr(assistant, key, value) + await _sync_bindings(session, assistant.id, resource_ids) await session.commit() - await session.refresh(a) - return _to_out(a) + await session.refresh(assistant) + return await _to_out(session, assistant) @router.delete("/{assistant_id}") async def delete_assistant( assistant_id: str, session: AsyncSession = Depends(get_session) ): - a = await session.get(Assistant, assistant_id) - if not a: + assistant = await session.get(Assistant, assistant_id) + if not assistant: raise HTTPException(404, "助手不存在") - await session.delete(a) + await session.delete(assistant) await session.commit() return {"ok": True} diff --git a/backend/routes/credentials.py b/backend/routes/credentials.py deleted file mode 100644 index 26c0b82..0000000 --- a/backend/routes/credentials.py +++ /dev/null @@ -1,190 +0,0 @@ -"""模型资源凭证 CRUD。前端 ComponentsModelsPage 对接这里。 - -字段对齐前端 ModelResource。读:api_key 打码;写:占位符表示不改(写时哨兵)。 -设默认时清掉同 type 其它默认。 -""" - -import uuid - -from db.models import ProviderCredential -from db.session import get_session -from fastapi import APIRouter, Depends, HTTPException -from schemas import ( - CredentialOut, - CredentialTestRequest, - CredentialTestResult, - CredentialUpsert, -) -from services.credential_tester import test_openai_credential, test_xfyun_credential -from services.masking import mask, resolve_incoming_key -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession - -router = APIRouter(prefix="/api/credentials", tags=["credentials"]) - - -def _to_out(c: ProviderCredential) -> CredentialOut: - return CredentialOut( - id=c.id, - name=c.name, - model_id=c.model_id, - type=c.type, - interface_type=c.interface_type, - api_url=c.api_url, - api_key=mask(c.api_key), # 永远打码 - voice=c.voice, - speed=c.speed, - language=c.language, - is_default=c.is_default, - ) - - -async def _clear_other_defaults(session: AsyncSession, type_: str, keep_id: str): - await session.execute( - update(ProviderCredential) - .where(ProviderCredential.type == type_, ProviderCredential.id != keep_id) - .values(is_default=False) - ) - - -@router.get("", response_model=list[CredentialOut]) -async def list_credentials(session: AsyncSession = Depends(get_session)): - rows = ( - await session.execute( - select(ProviderCredential).order_by(ProviderCredential.type) - ) - ).scalars().all() - return [_to_out(c) for c in rows] - - -@router.post("", response_model=CredentialOut) -async def create_credential( - body: CredentialUpsert, session: AsyncSession = Depends(get_session) -): - c = ProviderCredential( - id=f"model_{uuid.uuid4().hex[:12]}", - name=body.name, - model_id=body.model_id, - type=body.type, - interface_type=body.interface_type, - api_url=body.api_url, - api_key=resolve_incoming_key(body.api_key, ""), - voice=body.voice, - speed=body.speed, - language=body.language, - is_default=body.is_default, - ) - session.add(c) - if c.is_default: - await _clear_other_defaults(session, c.type, c.id) - await session.commit() - await session.refresh(c) - return _to_out(c) - - -@router.post("/test", response_model=CredentialTestResult) -async def test_new_credential(body: CredentialTestRequest): - if body.interface_type == "xfyun": - return test_xfyun_credential(body) - if body.interface_type != "openai": - return CredentialTestResult( - ok=False, - message="暂不支持该接口类型", - detail="当前仅支持 OpenAI 兼容接口测试", - ) - if not body.api_key: - return CredentialTestResult( - ok=False, - message="缺少 API Key", - detail="测试新配置时需要输入 API Key", - ) - return await test_openai_credential(body) - - -@router.post("/{cred_id}/test", response_model=CredentialTestResult) -async def test_saved_credential( - cred_id: str, - body: CredentialTestRequest, - session: AsyncSession = Depends(get_session), -): - c = await session.get(ProviderCredential, cred_id) - if not c: - raise HTTPException(404, "凭证不存在") - config = body.model_copy( - update={"api_key": resolve_incoming_key(body.api_key, c.api_key)} - ) - if config.interface_type == "xfyun": - return test_xfyun_credential(config) - if config.interface_type != "openai": - return CredentialTestResult( - ok=False, - message="暂不支持该接口类型", - detail="当前仅支持 OpenAI 兼容接口测试", - ) - return await test_openai_credential(config) - - -@router.post("/{cred_id}/duplicate", response_model=CredentialOut) -async def duplicate_credential( - cred_id: str, session: AsyncSession = Depends(get_session) -): - """服务端整行复制:含真实 api_key,DB→DB,密钥不经浏览器。副本不继承默认标记。""" - src = await session.get(ProviderCredential, cred_id) - if not src: - raise HTTPException(404, "凭证不存在") - c = ProviderCredential( - id=f"model_{uuid.uuid4().hex[:12]}", - name=f"{src.name} 副本", - model_id=src.model_id, - type=src.type, - interface_type=src.interface_type, - api_url=src.api_url, - api_key=src.api_key, # 真 key,DB→DB - voice=src.voice, - speed=src.speed, - language=src.language, - is_default=False, # 副本不继承默认,避免抢走源的默认标记 - ) - session.add(c) - await session.commit() - await session.refresh(c) - return _to_out(c) - - -@router.put("/{cred_id}", response_model=CredentialOut) -async def update_credential( - cred_id: str, - body: CredentialUpsert, - session: AsyncSession = Depends(get_session), -): - c = await session.get(ProviderCredential, cred_id) - if not c: - raise HTTPException(404, "凭证不存在") - c.name = body.name - c.model_id = body.model_id - c.type = body.type - c.interface_type = body.interface_type - c.api_url = body.api_url - c.voice = body.voice - c.speed = body.speed - c.language = body.language - c.is_default = body.is_default - # 写时哨兵:打码占位符 → 保留旧 key - c.api_key = resolve_incoming_key(body.api_key, c.api_key) - if c.is_default: - await _clear_other_defaults(session, c.type, c.id) - await session.commit() - await session.refresh(c) - return _to_out(c) - - -@router.delete("/{cred_id}") -async def delete_credential( - cred_id: str, session: AsyncSession = Depends(get_session) -): - c = await session.get(ProviderCredential, cred_id) - if not c: - raise HTTPException(404, "凭证不存在") - await session.delete(c) - await session.commit() - return {"ok": True} diff --git a/backend/routes/knowledge_bases.py b/backend/routes/knowledge_bases.py index 4356d2a..c433034 100644 --- a/backend/routes/knowledge_bases.py +++ b/backend/routes/knowledge_bases.py @@ -1,12 +1,12 @@ """知识库 CRUD。前端助手编辑页的"知识库"下拉对接这里。 -KB 自身引用一个 Embedding 凭证(embeddingCredentialId)。被助手引用时禁止删除 +KB 自身引用一个 Embedding 模型资源。被助手引用时禁止删除 (DB 层 ON DELETE RESTRICT),这里把外键冲突翻译成 409。 """ import uuid -from db.models import KnowledgeBase +from db.models import KnowledgeBase, ModelResource from db.session import get_session from fastapi import APIRouter, Depends, HTTPException from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert @@ -17,12 +17,22 @@ from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter(prefix="/api/knowledge-bases", tags=["knowledge-bases"]) +async def _validate_embedding_resource( + session: AsyncSession, resource_id: str | None +) -> None: + if not resource_id: + return + resource = await session.get(ModelResource, resource_id) + if not resource or resource.capability != "Embedding": + raise HTTPException(400, "知识库必须引用 Embedding 模型资源") + + def _to_out(kb: KnowledgeBase) -> KnowledgeBaseOut: return KnowledgeBaseOut( id=kb.id, name=kb.name, description=kb.description, - embedding_credential_id=kb.embedding_credential_id, + embedding_model_resource_id=kb.embedding_model_resource_id, status=kb.status, updated_at=kb.updated_at.isoformat() if kb.updated_at else None, ) @@ -40,6 +50,7 @@ async def list_knowledge_bases(session: AsyncSession = Depends(get_session)): async def create_knowledge_base( body: KnowledgeBaseUpsert, session: AsyncSession = Depends(get_session) ): + await _validate_embedding_resource(session, body.embedding_model_resource_id) kb = KnowledgeBase(id=f"kb_{uuid.uuid4().hex[:12]}", **body.model_dump()) session.add(kb) await session.commit() @@ -66,6 +77,7 @@ async def update_knowledge_base( kb = await session.get(KnowledgeBase, kb_id) if not kb: raise HTTPException(404, "知识库不存在") + await _validate_embedding_resource(session, body.embedding_model_resource_id) for k, v in body.model_dump().items(): setattr(kb, k, v) await session.commit() diff --git a/backend/routes/model_registry.py b/backend/routes/model_registry.py new file mode 100644 index 0000000..d04100b --- /dev/null +++ b/backend/routes/model_registry.py @@ -0,0 +1,249 @@ +"""Interface-definition driven model resource registry APIs.""" + +import uuid + +from db.models import ( + AssistantModelBinding, + InterfaceDefinition, + KnowledgeBase, + ModelResource, +) +from db.session import get_session +from fastapi import APIRouter, Depends, HTTPException, Query +from schemas import ( + InterfaceDefinitionOut, + ModelResourceOut, + ModelResourceTestResult, + ModelResourceUpsert, +) +from services.interface_catalog import validate_fields +from services.masking import mask_secrets, merge_secrets +from services.model_resource_tester import test_model_resource +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter(prefix="/api", tags=["model-registry"]) + + +def _definition_dict(row: InterfaceDefinition) -> dict: + return { + "interface_type": row.interface_type, + "name": row.name, + "capability": row.capability, + "fields": (row.field_schema or {}).get("fields", []), + } + + +def _definition_out(row: InterfaceDefinition) -> InterfaceDefinitionOut: + return InterfaceDefinitionOut( + interface_type=row.interface_type, + name=row.name, + capability=row.capability, # type: ignore[arg-type] + field_schema=row.field_schema or {}, + enabled=row.enabled, + version=row.version, + ) + + +def _resource_out(row: ModelResource) -> ModelResourceOut: + return ModelResourceOut( + id=row.id, + name=row.name, + capability=row.capability, # type: ignore[arg-type] + interface_type=row.interface_type, + values=row.values or {}, + secrets=mask_secrets(row.secrets or {}), + enabled=row.enabled, + is_default=row.is_default, + updated_at=row.updated_at.isoformat() if row.updated_at else None, + ) + + +async def _definition( + session: AsyncSession, interface_type: str +) -> InterfaceDefinition: + row = await session.get(InterfaceDefinition, interface_type) + if not row or not row.enabled: + raise HTTPException(400, f"接口类型不可用: {interface_type}") + return row + + +async def _validate( + session: AsyncSession, + body: ModelResourceUpsert, + stored_secrets: dict | None = None, +) -> tuple[InterfaceDefinition, dict]: + definition = await _definition(session, body.interface_type) + secrets = merge_secrets(body.secrets, stored_secrets or {}) + try: + validate_fields(_definition_dict(definition), body.values, secrets) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + return definition, secrets + + +async def _clear_incompatible_references( + session: AsyncSession, resource: ModelResource, capability: str +) -> None: + if capability == resource.capability: + return + await session.execute( + delete(AssistantModelBinding).where( + AssistantModelBinding.model_resource_id == resource.id + ) + ) + await session.execute( + update(KnowledgeBase) + .where(KnowledgeBase.embedding_model_resource_id == resource.id) + .values(embedding_model_resource_id=None) + ) + + +@router.get("/interface-definitions", response_model=list[InterfaceDefinitionOut]) +async def list_interface_definitions( + capability: str | None = Query(default=None), + session: AsyncSession = Depends(get_session), +): + stmt = select(InterfaceDefinition).where(InterfaceDefinition.enabled.is_(True)) + if capability: + stmt = stmt.where(InterfaceDefinition.capability == capability) + rows = (await session.execute(stmt.order_by(InterfaceDefinition.capability))).scalars().all() + return [_definition_out(row) for row in rows] + + +@router.get("/model-resources", response_model=list[ModelResourceOut]) +async def list_model_resources(session: AsyncSession = Depends(get_session)): + rows = ( + await session.execute(select(ModelResource).order_by(ModelResource.capability)) + ).scalars().all() + return [_resource_out(row) for row in rows] + + +@router.post("/model-resources", response_model=ModelResourceOut) +async def create_model_resource( + body: ModelResourceUpsert, session: AsyncSession = Depends(get_session) +): + definition, secrets = await _validate(session, body) + row = ModelResource( + id=f"model_{uuid.uuid4().hex[:12]}", + name=body.name, + capability=definition.capability, + interface_type=definition.interface_type, + values=body.values, + secrets=secrets, + enabled=body.enabled, + is_default=body.is_default, + ) + session.add(row) + if row.is_default: + await session.execute( + update(ModelResource) + .where(ModelResource.capability == row.capability, ModelResource.id != row.id) + .values(is_default=False) + ) + await session.commit() + return _resource_out(row) + + +@router.post("/model-resources/test", response_model=ModelResourceTestResult) +async def test_new_model_resource( + body: ModelResourceUpsert, session: AsyncSession = Depends(get_session) +): + definition, secrets = await _validate(session, body) + return await test_model_resource( + definition.interface_type, + definition.capability, + body.values, + secrets, + ) + + +@router.post( + "/model-resources/{resource_id}/test", response_model=ModelResourceTestResult +) +async def test_saved_model_resource( + resource_id: str, + body: ModelResourceUpsert, + session: AsyncSession = Depends(get_session), +): + row = await session.get(ModelResource, resource_id) + if not row: + raise HTTPException(404, "模型资源不存在") + definition, secrets = await _validate(session, body, row.secrets or {}) + return await test_model_resource( + definition.interface_type, + definition.capability, + body.values, + secrets, + ) + + +@router.post("/model-resources/{resource_id}/duplicate", response_model=ModelResourceOut) +async def duplicate_model_resource( + resource_id: str, session: AsyncSession = Depends(get_session) +): + source = await session.get(ModelResource, resource_id) + if not source: + raise HTTPException(404, "模型资源不存在") + row = ModelResource( + id=f"model_{uuid.uuid4().hex[:12]}", + name=f"{source.name} 副本", + capability=source.capability, + interface_type=source.interface_type, + values=dict(source.values or {}), + secrets=dict(source.secrets or {}), + enabled=source.enabled, + is_default=False, + ) + session.add(row) + await session.commit() + return _resource_out(row) + + +@router.put("/model-resources/{resource_id}", response_model=ModelResourceOut) +async def update_model_resource( + resource_id: str, + body: ModelResourceUpsert, + session: AsyncSession = Depends(get_session), +): + row = await session.get(ModelResource, resource_id) + if not row: + raise HTTPException(404, "模型资源不存在") + definition, secrets = await _validate(session, body, row.secrets or {}) + await _clear_incompatible_references(session, row, definition.capability) + row.name = body.name + row.capability = definition.capability + row.interface_type = definition.interface_type + row.values = body.values + row.secrets = secrets + row.enabled = body.enabled + row.is_default = body.is_default + if row.is_default: + await session.execute( + update(ModelResource) + .where(ModelResource.capability == row.capability, ModelResource.id != row.id) + .values(is_default=False) + ) + await session.commit() + return _resource_out(row) + + +@router.delete("/model-resources/{resource_id}") +async def delete_model_resource( + resource_id: str, session: AsyncSession = Depends(get_session) +): + row = await session.get(ModelResource, resource_id) + if not row: + raise HTTPException(404, "模型资源不存在") + in_use = ( + await session.execute( + select(AssistantModelBinding.assistant_id) + .where(AssistantModelBinding.model_resource_id == resource_id) + .limit(1) + ) + ).scalar_one_or_none() + if in_use: + raise HTTPException(409, "该模型资源仍被助手引用") + await session.delete(row) + await session.commit() + return {"ok": True} diff --git a/backend/schemas.py b/backend/schemas.py index 587294e..4aebe4c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,6 +1,6 @@ """面向前端的请求/响应 DTO。与 DB 模型解耦,**响应里的 key 一律打码**。 -凭证 DTO 字段对齐前端 ComponentsModelsPage 的 ModelResource: +模型资源 DTO 字段对齐前端 ComponentsModelsPage 的 ModelResource: JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_case, 靠 Pydantic alias 自动互转。FastAPI 响应默认 by_alias=True,所以出参也是 camelCase。 """ @@ -9,12 +9,11 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel RuntimeMode = Literal["pipeline", "realtime"] ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding"] -InterfaceType = Literal["openai", "xfyun", "dashscope", "gemini"] AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"] # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 @@ -49,11 +48,7 @@ class AssistantUpsert(CamelModel): greeting: str = "" enable_interrupt: bool = True - # 引用注册资源(FK id;None=未选) - llm_credential_id: str | None = None - asr_credential_id: str | None = None - tts_credential_id: str | None = None - realtime_credential_id: str | None = None + model_resource_ids: dict[ModelType, str] = Field(default_factory=dict) knowledge_base_id: str | None = None # 瘦类型专属(真列);按 type 取用,无关字段写入时清零 @@ -62,7 +57,7 @@ class AssistantUpsert(CamelModel): api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵) app_id: str = "" # workflow 专属:图 - graph: dict[str, Any] = {} + graph: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") def _strip_irrelevant_fields(self): @@ -84,7 +79,7 @@ class AssistantOut(AssistantUpsert): class KnowledgeBaseUpsert(CamelModel): name: str description: str = "" - embedding_credential_id: str | None = None + embedding_model_resource_id: str | None = None class KnowledgeBaseOut(KnowledgeBaseUpsert): @@ -93,55 +88,32 @@ class KnowledgeBaseOut(KnowledgeBaseUpsert): updated_at: str | None = None -# ---------- 模型凭证(对齐前端 ModelResource) ---------- -class CredentialUpsert(CamelModel): - name: str = "" # 资源名称 - model_id: str = "" # 模型ID - type: ModelType # LLM/ASR/TTS/Realtime/Embedding - interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini - api_url: str = "" - api_key: str = "" # 写时:占位符/空表示不改 - voice: str = "" # TTS - speed: float = 1.0 # TTS - language: str = "" # ASR +# ---------- 接口定义驱动的统一模型资源 ---------- +class InterfaceDefinitionOut(CamelModel): + interface_type: str + name: str + capability: ModelType + field_schema: dict[str, Any] + enabled: bool + version: int + + +class ModelResourceUpsert(CamelModel): + name: str + interface_type: str + values: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True is_default: bool = False - @model_validator(mode="after") - def _strip_irrelevant_options(self): - if self.type != "TTS": - self.voice = "" - self.speed = 1.0 - if self.type != "ASR": - self.language = "" - return self - -class CredentialOut(CamelModel): +class ModelResourceOut(ModelResourceUpsert): id: str - name: str - model_id: str - type: str - interface_type: str - api_url: str - api_key: str # 读时:打码后的值 - voice: str - speed: float - language: str - is_default: bool + capability: ModelType + updated_at: str | None = None -class CredentialTestRequest(CamelModel): - model_id: str - type: ModelType - interface_type: InterfaceType = "openai" - api_url: str - api_key: str = "" - voice: str = "" - speed: float = 1.0 - language: str = "" - - -class CredentialTestResult(CamelModel): +class ModelResourceTestResult(CamelModel): ok: bool latency_ms: int | None = None message: str diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index 1fc1306..f951f95 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -1,52 +1,63 @@ """assistant_id → 运行时配置(把真 key 在服务端组装好)。 -浏览器只传 assistant_id;真 key 在这里从 provider_credentials 取出注入。 -助手按 FK(*_credential_id)引用凭证;取不到则回退该 type 默认凭证,再回退 .env。 +浏览器只传 assistant_id;真 key 在这里从 model_resources 取出注入。 +助手按 capability binding 引用资源;取不到则回退该能力默认资源,再回退 .env。 """ import config -from db.models import Assistant, ProviderCredential +from db.models import Assistant, AssistantModelBinding, ModelResource from models import AssistantConfig from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -async def _default_credential( - session: AsyncSession, type_: str -) -> ProviderCredential | None: - """该 type 的默认凭证(is_default 优先,否则按 id 取第一条)。""" - stmt = ( - select(ProviderCredential) - .where(ProviderCredential.type == type_) - .order_by(ProviderCredential.is_default.desc(), ProviderCredential.id.asc()) - .limit(1) - ) - return (await session.execute(stmt)).scalar_one_or_none() +async def _resource_for( + session: AsyncSession, + assistant_id: str, + capability: str, +) -> ModelResource | None: + binding = await session.get(AssistantModelBinding, (assistant_id, capability)) + resource_id = binding.model_resource_id if binding else None + resource = await session.get(ModelResource, resource_id) if resource_id else None + if resource and resource.capability != capability: + resource = None + if resource is None: + resource = ( + await session.execute( + select(ModelResource) + .where(ModelResource.capability == capability, ModelResource.enabled.is_(True)) + .order_by(ModelResource.is_default.desc(), ModelResource.id.asc()) + .limit(1) + ) + ).scalar_one_or_none() + return resource -async def _resolve( - session: AsyncSession, cred_id: str | None, type_: str -) -> ProviderCredential | None: - """按 FK id 取凭证;id 为空或失效 → 回退该 type 默认。""" - if cred_id: - cred = await session.get(ProviderCredential, cred_id) - if cred: - return cred - return await _default_credential(session, type_) +def _value(resource: ModelResource | None, key: str, default): + if not resource: + return default + value = (resource.values or {}).get(key, default) + return default if value is None else value + + +def _secret(resource: ModelResource | None, key: str, default: str) -> str: + if not resource: + return default + return str((resource.secrets or {}).get(key) or default) async def resolve_runtime_config( session: AsyncSession, assistant_id: str ) -> AssistantConfig: - """加载助手 + 解析凭证,产出可直接交给管线的运行时配置(含真 key)。""" + """加载助手 + 解析模型资源,产出可直接交给管线的运行时配置(含真 key)。""" assistant = await session.get(Assistant, assistant_id) if assistant is None: raise ValueError(f"助手不存在: {assistant_id}") - llm = await _resolve(session, assistant.llm_credential_id, "LLM") - stt = await _resolve(session, assistant.asr_credential_id, "ASR") - tts = await _resolve(session, assistant.tts_credential_id, "TTS") - realtime = await _resolve(session, assistant.realtime_credential_id, "Realtime") + llm_resource = await _resource_for(session, assistant.id, "LLM") + stt_resource = await _resource_for(session, assistant.id, "ASR") + tts_resource = await _resource_for(session, assistant.id, "TTS") + realtime_resource = await _resource_for(session, assistant.id, "Realtime") return AssistantConfig( name=assistant.name, @@ -55,21 +66,28 @@ async def resolve_runtime_config( prompt=assistant.prompt or "你是一个有帮助的助手。", runtimeMode=assistant.runtime_mode, # type: ignore[arg-type] enableInterrupt=assistant.enable_interrupt, - # 模型/音色:凭证的模型ID优先 - model=(llm.model_id if llm else ""), - asr=(stt.model_id if stt else ""), - tts_model=(tts.model_id if tts else ""), - voice=(tts.voice if tts else ""), - stt_language=(stt.language if stt else ""), - tts_speed=(tts.speed if tts else 1.0), - stt_interface_type=(stt.interface_type if stt else "openai"), - tts_interface_type=(tts.interface_type if tts else "openai"), - realtimeModel=(realtime.model_id if realtime else ""), - # 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底 - llm_api_key=(llm.api_key if llm else config.LLM_API_KEY), - llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL), - stt_api_key=(stt.api_key if stt else config.STT_API_KEY), - stt_base_url=(stt.api_url if stt else config.STT_BASE_URL), - tts_api_key=(tts.api_key if tts else config.TTS_API_KEY), - tts_base_url=(tts.api_url if tts else config.TTS_BASE_URL), + # 模型/音色:模型资源中的配置优先 + model=str(_value(llm_resource, "modelId", "")), + asr=str(_value(stt_resource, "modelId", "")), + tts_model=str(_value(tts_resource, "modelId", "")), + voice=str(_value(tts_resource, "voice", "")), + stt_language=str(_value(stt_resource, "language", "")), + tts_speed=float(_value(tts_resource, "speed", 1.0)), + llm_interface_type=(llm_resource.interface_type if llm_resource else "openai-llm"), + stt_interface_type=(stt_resource.interface_type if stt_resource else "openai-asr"), + tts_interface_type=(tts_resource.interface_type if tts_resource else "openai-tts"), + realtimeModel=str(_value(realtime_resource, "modelId", "")), + llm_values=(llm_resource.values or {}) if llm_resource else {}, + llm_secrets=(llm_resource.secrets or {}) if llm_resource else {}, + stt_values=(stt_resource.values or {}) if stt_resource else {}, + stt_secrets=(stt_resource.secrets or {}) if stt_resource else {}, + tts_values=(tts_resource.values or {}) if tts_resource else {}, + tts_secrets=(tts_resource.secrets or {}) if tts_resource else {}, + # 运行时连接信息(真 key + url):模型资源优先,否则 .env 兜底 + llm_api_key=_secret(llm_resource, "apiKey", config.LLM_API_KEY), + llm_base_url=str(_value(llm_resource, "apiUrl", config.LLM_BASE_URL)), + stt_api_key=_secret(stt_resource, "apiKey", config.STT_API_KEY), + stt_base_url=str(_value(stt_resource, "apiUrl", config.STT_BASE_URL)), + tts_api_key=_secret(tts_resource, "apiKey", config.TTS_API_KEY), + tts_base_url=str(_value(tts_resource, "apiUrl", config.TTS_BASE_URL)), ) diff --git a/backend/services/interface_catalog.py b/backend/services/interface_catalog.py new file mode 100644 index 0000000..4913eee --- /dev/null +++ b/backend/services/interface_catalog.py @@ -0,0 +1,155 @@ +"""Built-in concrete interface definitions used by backend and dynamic forms.""" + +from __future__ import annotations + +from typing import Any + + +def field( + key: str, + label: str, + *, + group: str = "values", + type_: str = "text", + required: bool = False, + default: Any = None, + options: list[str] | None = None, +) -> dict: + value = { + "key": key, + "label": label, + "group": group, + "type": type_, + "required": required, + } + if default is not None: + value["default"] = default + if options: + value["options"] = options + return value + + +OPENAI_COMMON = [ + field("modelId", "Model ID", required=True), + field("apiUrl", "API URL", type_="url", required=True), + field("apiKey", "API Key", group="secrets", type_="password", required=True), +] +XFYUN_AUTH = [ + field("apiUrl", "WebSocket URL", type_="url", required=True), + field("appId", "App ID", group="secrets", type_="password", required=True), + field("apiKey", "API Key", group="secrets", type_="password", required=True), + field("apiSecret", "API Secret", group="secrets", type_="password", required=True), +] + +INTERFACE_DEFINITIONS: list[dict] = [ + { + "interface_type": "openai-llm", + "name": "OpenAI Compatible LLM", + "capability": "LLM", + "fields": OPENAI_COMMON + + [field("temperature", "Temperature", type_="number", default=0.7)], + }, + { + "interface_type": "openai-asr", + "name": "OpenAI Compatible ASR", + "capability": "ASR", + "fields": OPENAI_COMMON + [field("language", "Language", default="zh")], + }, + { + "interface_type": "openai-tts", + "name": "OpenAI Compatible TTS", + "capability": "TTS", + "fields": OPENAI_COMMON + + [ + field("voice", "Voice"), + field("speed", "Speed", type_="number", default=1.0), + field("sourceSampleRate", "Source Sample Rate", type_="number", default=24000), + ], + }, + { + "interface_type": "openai-embedding", + "name": "OpenAI Compatible Embedding", + "capability": "Embedding", + "fields": OPENAI_COMMON + [field("dimensions", "Dimensions", type_="number")], + }, + { + "interface_type": "openai-realtime", + "name": "OpenAI Realtime", + "capability": "Realtime", + "fields": OPENAI_COMMON + [field("voice", "Voice")], + }, + { + "interface_type": "xfyun-asr", + "name": "Xfyun Streaming ASR", + "capability": "ASR", + "fields": XFYUN_AUTH + + [ + field("language", "Language", default="zh_cn"), + field("domain", "Domain", default="iat"), + field("accent", "Accent", default="mandarin"), + field("dynamicCorrection", "Dynamic Correction", type_="boolean", default=False), + field("frameSize", "Frame Size", type_="number", default=1280), + ], + }, + { + "interface_type": "xfyun-tts", + "name": "Xfyun TTS", + "capability": "TTS", + "fields": XFYUN_AUTH + + [ + field("voice", "Voice"), + field("speed", "Speed", type_="number", default=50), + field("volume", "Volume", type_="number", default=50), + field("pitch", "Pitch", type_="number", default=50), + field("sourceSampleRate", "Source Sample Rate", type_="number", default=16000), + ], + }, + { + "interface_type": "xfyun-super-tts", + "name": "Xfyun Super TTS", + "capability": "TTS", + "fields": XFYUN_AUTH + + [ + field("voice", "Voice"), + field("speed", "Speed", type_="number", default=50), + field("volume", "Volume", type_="number", default=50), + field("pitch", "Pitch", type_="number", default=50), + field("oralLevel", "Oral Level", default="mid"), + field("sourceSampleRate", "Source Sample Rate", type_="number", default=24000), + field("textAggregationMode", "Text Aggregation Mode", default="token"), + ], + }, + { + "interface_type": "dashscope-llm", + "name": "DashScope LLM", + "capability": "LLM", + "fields": OPENAI_COMMON + + [field("temperature", "Temperature", type_="number", default=0.7)], + }, + { + "interface_type": "dashscope-asr", + "name": "DashScope ASR", + "capability": "ASR", + "fields": OPENAI_COMMON + [field("language", "Language", default="zh")], + }, + { + "interface_type": "dashscope-tts", + "name": "DashScope TTS", + "capability": "TTS", + "fields": OPENAI_COMMON + [field("voice", "Voice")], + }, + { + "interface_type": "gemini-realtime", + "name": "Gemini Realtime", + "capability": "Realtime", + "fields": OPENAI_COMMON, + }, +] + + +def validate_fields(definition: dict, values: dict, secrets: dict) -> None: + for item in definition["fields"]: + source = secrets if item["group"] == "secrets" else values + value = source.get(item["key"]) + if item.get("required") and (value is None or value == ""): + raise ValueError(f"{item['label']} is required") diff --git a/backend/services/masking.py b/backend/services/masking.py index 35480b5..1ebe5f3 100644 --- a/backend/services/masking.py +++ b/backend/services/masking.py @@ -25,3 +25,23 @@ def resolve_incoming_key(incoming: str | None, stored: str) -> str: if incoming is None or incoming == "" or is_masked(incoming): return stored return incoming + + +def mask_secrets(value): + """Recursively mask every scalar in a model resource secrets object.""" + if isinstance(value, dict): + return {key: mask_secrets(item) for key, item in value.items()} + if isinstance(value, list): + return [mask_secrets(item) for item in value] + return mask(str(value)) if value is not None else "" + + +def merge_secrets(incoming: dict, stored: dict) -> dict: + """Merge secret fields while treating masked/empty values as keep-existing.""" + result = dict(stored or {}) + for key, value in (incoming or {}).items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = merge_secrets(value, result[key]) + elif value not in (None, "") and not is_masked(str(value)): + result[key] = value + return result diff --git a/backend/services/credential_tester.py b/backend/services/model_resource_tester.py similarity index 52% rename from backend/services/credential_tester.py rename to backend/services/model_resource_tester.py index c261691..cd3216a 100644 --- a/backend/services/credential_tester.py +++ b/backend/services/model_resource_tester.py @@ -1,4 +1,4 @@ -"""OpenAI 兼容模型凭证的最小连通测试。""" +"""Connectivity checks for interface-definition driven model resources.""" from __future__ import annotations @@ -8,8 +8,8 @@ import wave import httpx -from schemas import CredentialTestRequest, CredentialTestResult -from services.pipecat.xfyun_config import parse_xfyun_credential +import config +from schemas import ModelResourceTestResult TEST_TIMEOUT_SECONDS = 10.0 @@ -28,7 +28,7 @@ def _silent_wav() -> bytes: return buffer.getvalue() -def _error_detail(response: httpx.Response, api_key: str) -> str: +def _error_detail(response: httpx.Response, secrets: dict) -> str: try: body = response.json() detail = ( @@ -39,110 +39,114 @@ def _error_detail(response: httpx.Response, api_key: str) -> str: except ValueError: detail = None text = str(detail or response.text or response.reason_phrase).strip() - return text.replace(api_key, "***")[:300] + for secret in secrets.values(): + if secret: + text = text.replace(str(secret), "***") + return text[:300] -async def test_openai_credential( - config: CredentialTestRequest, -) -> CredentialTestResult: +async def test_model_resource( + interface_type: str, + capability: str, + values: dict, + secrets: dict, +) -> ModelResourceTestResult: + if interface_type.startswith("xfyun-"): + return ModelResourceTestResult( + ok=True, + message="讯飞连接参数有效", + detail="鉴权字段和连接参数完整,请在语音测试页验证签名及音频链路", + ) + if capability == "Realtime": + return ModelResourceTestResult( + ok=False, + message="暂不支持 Realtime 连接测试", + detail="请在助手语音测试页验证实时连接", + ) + + api_url = str(values.get("apiUrl") or "") + model_id = str(values.get("modelId") or "") + api_key = str(secrets.get("apiKey") or "") + headers = {"Authorization": f"Bearer {api_key}"} started = time.perf_counter() - headers = {"Authorization": f"Bearer {config.api_key}"} try: async with httpx.AsyncClient(timeout=TEST_TIMEOUT_SECONDS) as client: - if config.type == "LLM": + if capability == "LLM": response = await client.post( - _endpoint(config.api_url, "chat/completions"), + _endpoint(api_url, "chat/completions"), headers=headers, json={ - "model": config.model_id, + "model": model_id, "messages": [{"role": "user", "content": "Reply with OK."}], "max_tokens": 1, "stream": False, }, ) - elif config.type == "Embedding": + elif capability == "Embedding": response = await client.post( - _endpoint(config.api_url, "embeddings"), + _endpoint(api_url, "embeddings"), headers=headers, - json={"model": config.model_id, "input": "ping"}, + json={"model": model_id, "input": "ping"}, ) - elif config.type == "ASR": + elif capability == "ASR": response = await client.post( - _endpoint(config.api_url, "audio/transcriptions"), + _endpoint(api_url, "audio/transcriptions"), headers=headers, data={ - "model": config.model_id, - **({"language": config.language} if config.language else {}), + "model": model_id, + **( + {"language": str(values["language"])} + if values.get("language") + else {} + ), }, files={"file": ("test.wav", _silent_wav(), "audio/wav")}, ) - elif config.type == "TTS": + elif capability == "TTS": response = await client.post( - _endpoint(config.api_url, "audio/speech"), + _endpoint(api_url, "audio/speech"), headers=headers, json={ - "model": config.model_id, + "model": model_id, "input": "测试", - "voice": config.voice, + "voice": str(values.get("voice") or config.TTS_VOICE), "response_format": "pcm", - "speed": config.speed, + "speed": float(values.get("speed") or 1), }, ) else: - return CredentialTestResult( + return ModelResourceTestResult( ok=False, - message="暂不支持该资源类型的连通测试", - detail=f"当前仅支持 LLM、Embedding、ASR、TTS,收到 {config.type}", + message="暂不支持该能力的连接测试", + detail=f"收到能力类型 {capability}", ) latency_ms = round((time.perf_counter() - started) * 1000) if response.is_success: - return CredentialTestResult( + return ModelResourceTestResult( ok=True, latency_ms=latency_ms, message="连接成功", - detail=f"OpenAI 兼容接口响应正常(HTTP {response.status_code})", + detail=f"接口响应正常(HTTP {response.status_code})", ) - return CredentialTestResult( + return ModelResourceTestResult( ok=False, latency_ms=latency_ms, message=f"连接失败(HTTP {response.status_code})", - detail=_error_detail(response, config.api_key), + detail=_error_detail(response, secrets), ) except httpx.TimeoutException: - return CredentialTestResult( + return ModelResourceTestResult( ok=False, latency_ms=round((time.perf_counter() - started) * 1000), message="连接超时", detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应", ) except httpx.RequestError as exc: - return CredentialTestResult( + return ModelResourceTestResult( ok=False, latency_ms=round((time.perf_counter() - started) * 1000), message="无法连接到模型服务", detail=str(exc)[:300], ) - - -def test_xfyun_credential(config: CredentialTestRequest) -> CredentialTestResult: - """Validate the Xfyun credential packed into the existing api_key field. - - Actual signed-WebSocket synthesis/recognition is exercised by the voice - pipeline; this check deliberately avoids consuming provider quota. - """ - try: - parse_xfyun_credential(config.api_key) - except ValueError as exc: - return CredentialTestResult( - ok=False, - message="讯飞凭证格式无效", - detail=str(exc), - ) - - return CredentialTestResult( - ok=True, - message="讯飞凭证格式有效", - detail="请在语音测试页验证签名、识别和合成链路", - ) diff --git a/backend/services/pipecat/service_factory.py b/backend/services/pipecat/service_factory.py index 2d48333..448c9f1 100644 --- a/backend/services/pipecat/service_factory.py +++ b/backend/services/pipecat/service_factory.py @@ -1,7 +1,7 @@ """创建 STT / LLM / TTS 服务。 对应 dograh 的 service_factory.py,但只留一套国产栈(OpenAI 兼容), -按 provider 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。 +按 interface_type 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。 """ import config @@ -14,13 +14,7 @@ from pipecat.services.openai.tts import VALID_VOICES, OpenAITTSService from pipecat.transcriptions.language import Language from services.pipecat.xfyun_asr import DEFAULT_XFYUN_ASR_URL, XfyunASRService -from services.pipecat.xfyun_config import ( - is_super_tts, - parse_xfyun_credential, - websocket_url, - xfyun_language, - xfyun_speed, -) +from services.pipecat.xfyun_config import websocket_url, xfyun_language, xfyun_speed from services.pipecat.xfyun_super_tts import ( DEFAULT_XFYUN_SUPER_TTS_URL, XfyunSuperTTSService, @@ -43,16 +37,21 @@ def create_stt(cfg: AssistantConfig): 连接信息优先用 cfg(由 config_resolver 从 DB 注入),为空回退 .env 默认。 """ - if cfg.stt_interface_type == "xfyun": - credential = parse_xfyun_credential(cfg.stt_api_key) + if cfg.stt_interface_type == "xfyun-asr": return XfyunASRService( - app_id=credential.app_id, - api_key=credential.api_key, - api_secret=credential.api_secret, + app_id=str(cfg.stt_secrets.get("appId") or ""), + api_key=str(cfg.stt_secrets.get("apiKey") or ""), + api_secret=str(cfg.stt_secrets.get("apiSecret") or ""), url=websocket_url(cfg.stt_base_url, DEFAULT_XFYUN_ASR_URL), language=xfyun_language(cfg.stt_language), sample_rate=16000, + domain=str(cfg.stt_values.get("domain") or "iat"), + accent=str(cfg.stt_values.get("accent") or "mandarin"), + frame_size=int(cfg.stt_values.get("frameSize") or 1280), + dynamic_correction=bool(cfg.stt_values.get("dynamicCorrection", False)), ) + if cfg.stt_interface_type not in {"openai-asr", "dashscope-asr"}: + raise ValueError(f"不支持的 ASR 接口类型: {cfg.stt_interface_type}") return OpenAISTTService( api_key=cfg.stt_api_key or config.STT_API_KEY, @@ -66,6 +65,8 @@ def create_stt(cfg: AssistantConfig): def create_llm(cfg: AssistantConfig): """DeepSeek 等,走 OpenAI 兼容的 /v1/chat/completions。""" + if cfg.llm_interface_type not in {"openai-llm", "dashscope-llm"}: + raise ValueError(f"不支持的 LLM 接口类型: {cfg.llm_interface_type}") return OpenAILLMService( api_key=cfg.llm_api_key or config.LLM_API_KEY, base_url=cfg.llm_base_url or config.LLM_BASE_URL, @@ -76,31 +77,39 @@ def create_llm(cfg: AssistantConfig): def create_tts(cfg: AssistantConfig): """CosyVoice 等,走 OpenAI 兼容的 /v1/audio/speech。""" voice = cfg.voice or config.TTS_VOICE - if cfg.tts_interface_type == "xfyun": - credential = parse_xfyun_credential(cfg.tts_api_key) - speed = xfyun_speed(cfg.tts_speed) - if is_super_tts(cfg.tts_model, cfg.tts_base_url): - return XfyunSuperTTSService( - app_id=credential.app_id, - api_key=credential.api_key, - api_secret=credential.api_secret, - voice=voice, - url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_SUPER_TTS_URL), - sample_rate=16000, - source_sample_rate=24000, - speed=speed, - ) + if cfg.tts_interface_type == "xfyun-super-tts": + return XfyunSuperTTSService( + app_id=str(cfg.tts_secrets.get("appId") or ""), + api_key=str(cfg.tts_secrets.get("apiKey") or ""), + api_secret=str(cfg.tts_secrets.get("apiSecret") or ""), + voice=voice, + url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_SUPER_TTS_URL), + sample_rate=16000, + source_sample_rate=int(cfg.tts_values.get("sourceSampleRate") or 24000), + speed=xfyun_speed(cfg.tts_speed), + volume=int(cfg.tts_values.get("volume") or 50), + pitch=int(cfg.tts_values.get("pitch") or 50), + oral_level=str(cfg.tts_values.get("oralLevel") or "mid"), + text_aggregation_mode=str( + cfg.tts_values.get("textAggregationMode") or "token" + ), + ) + if cfg.tts_interface_type == "xfyun-tts": return XfyunTTSService( - app_id=credential.app_id, - api_key=credential.api_key, - api_secret=credential.api_secret, + app_id=str(cfg.tts_secrets.get("appId") or ""), + api_key=str(cfg.tts_secrets.get("apiKey") or ""), + api_secret=str(cfg.tts_secrets.get("apiSecret") or ""), voice=voice, url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_TTS_URL), sample_rate=16000, - source_sample_rate=16000, - speed=speed, + source_sample_rate=int(cfg.tts_values.get("sourceSampleRate") or 16000), + speed=xfyun_speed(cfg.tts_speed), + volume=int(cfg.tts_values.get("volume") or 50), + pitch=int(cfg.tts_values.get("pitch") or 50), push_stop_frames=True, ) + if cfg.tts_interface_type not in {"openai-tts", "dashscope-tts"}: + raise ValueError(f"不支持的 TTS 接口类型: {cfg.tts_interface_type}") # Pipecat 默认只接受 OpenAI 官方音色。OpenAI 兼容服务常使用自定义 voice id, # 注册为原样映射后仍由 OpenAI SDK 按字符串透传给供应商。 diff --git a/backend/services/pipecat/xfyun_config.py b/backend/services/pipecat/xfyun_config.py index 6be6f16..a965a49 100644 --- a/backend/services/pipecat/xfyun_config.py +++ b/backend/services/pipecat/xfyun_config.py @@ -1,46 +1,7 @@ -"""Parse Xfyun's three-part credential from ProviderCredential.api_key.""" +"""Shared Xfyun service value normalization.""" from __future__ import annotations -import json -from dataclasses import dataclass - - -@dataclass(frozen=True) -class XfyunCredential: - app_id: str - api_key: str - api_secret: str - - -def parse_xfyun_credential(value: str) -> XfyunCredential: - """Accept JSON in the existing api_key column. - - Example: - {"appId":"...","apiKey":"...","apiSecret":"..."} - """ - try: - payload = json.loads(value) - except json.JSONDecodeError as exc: - raise ValueError( - 'Xfyun API Key must be JSON: {"appId":"...","apiKey":"...","apiSecret":"..."}' - ) from exc - - if not isinstance(payload, dict): - raise ValueError("Xfyun API Key JSON must be an object") - - credential = XfyunCredential( - app_id=str(payload.get("appId") or payload.get("app_id") or "").strip(), - api_key=str(payload.get("apiKey") or payload.get("api_key") or "").strip(), - api_secret=str( - payload.get("apiSecret") or payload.get("api_secret") or "" - ).strip(), - ) - if not credential.app_id or not credential.api_key or not credential.api_secret: - raise ValueError("Xfyun API Key JSON requires appId, apiKey, and apiSecret") - return credential - - def websocket_url(value: str, default: str) -> str: url = (value or default).strip() if url.startswith("https://"): @@ -49,12 +10,6 @@ def websocket_url(value: str, default: str) -> str: return f"ws://{url.removeprefix('http://')}" return url - -def is_super_tts(model_id: str, api_url: str) -> bool: - model = model_id.lower().replace("-", "_") - return "super" in model or "/private/" in api_url.lower() - - def xfyun_language(value: str) -> str: normalized = (value or "zh_cn").lower().replace("-", "_") return {"zh": "zh_cn", "en": "en_us"}.get(normalized, normalized) diff --git a/deploy/README.md b/deploy/README.md index 960d38a..1e813e5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -9,7 +9,7 @@ ``` 浏览器 ──https/wss──> nginx :443 (唯一 TLS 入口, mkcert 证书) ├── /ws/ → 后端 :8000 (/ws/voice 信令、/ws/stream 裸流) - ├── /api/ → 后端 :8000 (assistants/credentials/...) + ├── /api/ → 后端 :8000 (assistants/model-resources/...) ├── /health → 后端 :8000 └── / → 前端 :3000 (Next dev + HMR) ``` diff --git a/deploy/nginx/ai-video.dev.conf b/deploy/nginx/ai-video.dev.conf index 758eeaa..67778a7 100644 --- a/deploy/nginx/ai-video.dev.conf +++ b/deploy/nginx/ai-video.dev.conf @@ -62,7 +62,7 @@ http { proxy_buffering off; # 流式音频不能攒着 } - # ---- 后端 HTTP 接口:/api/*(assistants/credentials/knowledge-bases)+ /health ---- + # ---- 后端 HTTP 接口:/api/*(assistants/model-resources/knowledge-bases)+ /health ---- location /api/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index a474015..49a9eca 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -14,12 +14,12 @@ import { Pencil, Plus, Rocket, - Search, Sparkles, Trash2, Workflow, ChevronLeft, - ChevronRight, + ChevronUp, + ChevronDown, Save, Mic, Send, @@ -61,6 +61,11 @@ import { NebulaVisualizer } from "@/components/ui/nebula-visualizer"; import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer"; import { WaveVisualizer } from "@/components/ui/wave-visualizer"; import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline"; +import { DataList } from "@/components/ui/data-list"; +import { PageHeader } from "@/components/ui/page-header"; +import { FilterPills } from "@/components/ui/filter-pills"; +import { SearchInput } from "@/components/ui/search-input"; +import { ListToolbar } from "@/components/ui/list-toolbar"; import { Card, CardContent, @@ -71,15 +76,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { assistantsApi, - credentialsApi, knowledgeBasesApi, + modelResourcesApi, type Assistant, type AssistantType as ApiAssistantType, type AssistantUpsert, - type Credential, type KnowledgeBase, + type ModelResource, } from "@/lib/api"; -import { useVoicePreview, type ChatMessage } from "@/hooks/use-voice-preview"; +import { + useVoicePreview, + type ChatMessage, + type VoicePreviewStatus, +} from "@/hooks/use-voice-preview"; type RuntimeMode = "pipeline" | "realtime"; @@ -283,12 +292,17 @@ type AssistantListItem = { name: string; type: AssistantType; updatedAt: string; + /** 原始 ISO 时间戳,用于按时间排序(updatedAt 为展示用整形字符串) */ + updatedAtRaw: string | null | undefined; }; type TypeFilter = "全部" | AssistantType; const typeFilters: TypeFilter[] = ["全部", ...assistantTypes]; +// 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前 +type SortOrder = "newest" | "oldest"; + export function AssistantPage(props: AssistantPageProps) { const router = useRouter(); // 编辑中的助手 id(来自路由) @@ -311,8 +325,8 @@ export function AssistantPage(props: AssistantPageProps) { const [loadError, setLoadError] = useState(null); // 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥") const [storedApiKeyMask, setStoredApiKeyMask] = useState(""); - // 下拉数据源:模型凭证 + 知识库 - const [credentials, setCredentials] = useState([]); + // 下拉数据源:模型资源 + 知识库 + const [modelResources, setModelResources] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); // 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换 const [view, setView] = useState(() => { @@ -322,6 +336,7 @@ export function AssistantPage(props: AssistantPageProps) { }); const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("全部"); + const [sortOrder, setSortOrder] = useState("newest"); const [currentPage, setCurrentPage] = useState(1); // choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页 // (工作流占位页也用它展示名称与类型) @@ -352,14 +367,14 @@ export function AssistantPage(props: AssistantPageProps) { void loadAssistants(); }, [props.mode, loadAssistants]); - // 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库) + // 进入创建/编辑前加载下拉数据源(模型资源 + 知识库) const loadResources = useCallback(async () => { try { const [creds, kbs] = await Promise.all([ - credentialsApi.list(), + modelResourcesApi.list(), knowledgeBasesApi.list(), ]); - setCredentials(creds); + setModelResources(creds); setKnowledgeBases(kbs); } catch { // 拉取失败时下拉为空,不阻塞表单 @@ -374,9 +389,9 @@ export function AssistantPage(props: AssistantPageProps) { }, [props.mode, loadResources]); // 按资源类型生成 {value:id, label:name} 选项 - const credOptions = (type: Credential["type"]) => - credentials - .filter((c) => c.type === type) + const credOptions = (type: ModelResource["capability"]) => + modelResources + .filter((c) => c.capability === type) .map((c) => ({ value: c.id, label: c.name })); const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name })); @@ -384,7 +399,7 @@ export function AssistantPage(props: AssistantPageProps) { router.push("/assistants/new"); } - // 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id) + // 把后端 Assistant 回填进提示词表单(model/asr/voice 等存模型资源 id) // 返回回填后的表单,供调用方记录"已保存基线" function fillPromptForm(a: Assistant): AssistantForm { const next: AssistantForm = { @@ -392,10 +407,10 @@ export function AssistantPage(props: AssistantPageProps) { greeting: a.greeting, prompt: a.prompt, runtimeMode: a.runtimeMode, - realtimeModel: a.realtimeCredentialId ?? "", - model: a.llmCredentialId ?? "", - asr: a.asrCredentialId ?? "", - voice: a.ttsCredentialId ?? "", + realtimeModel: a.modelResourceIds.Realtime ?? "", + model: a.modelResourceIds.LLM ?? "", + asr: a.modelResourceIds.ASR ?? "", + voice: a.modelResourceIds.TTS ?? "", knowledgeBase: a.knowledgeBaseId ?? "", enableInterrupt: a.enableInterrupt, }; @@ -458,10 +473,7 @@ export function AssistantPage(props: AssistantPageProps) { runtimeMode: "pipeline", greeting: "", enableInterrupt: true, - llmCredentialId: null, - asrCredentialId: null, - ttsCredentialId: null, - realtimeCredentialId: null, + modelResourceIds: {}, knowledgeBaseId: null, prompt: "", apiUrl: "", @@ -511,10 +523,12 @@ export function AssistantPage(props: AssistantPageProps) { runtimeMode: form.runtimeMode, greeting: form.greeting, enableInterrupt: form.enableInterrupt, - llmCredentialId: form.model || null, - asrCredentialId: form.asr || null, - ttsCredentialId: form.voice || null, - realtimeCredentialId: form.realtimeModel || null, + modelResourceIds: { + ...(form.model ? { LLM: form.model } : {}), + ...(form.asr ? { ASR: form.asr } : {}), + ...(form.voice ? { TTS: form.voice } : {}), + ...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}), + }, knowledgeBaseId: form.knowledgeBase || null, prompt: form.prompt, }), @@ -528,8 +542,8 @@ export function AssistantPage(props: AssistantPageProps) { apiUrl: a.apiUrl, // 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key apiKey: "", - asr: a.asrCredentialId ?? "", - voice: a.ttsCredentialId ?? "", + asr: a.modelResourceIds.ASR ?? "", + voice: a.modelResourceIds.TTS ?? "", enableInterrupt: a.enableInterrupt, }; setDifyForm(next); @@ -542,8 +556,10 @@ export function AssistantPage(props: AssistantPageProps) { name: difyForm.name.trim(), type: "dify", enableInterrupt: difyForm.enableInterrupt, - asrCredentialId: difyForm.asr || null, - ttsCredentialId: difyForm.voice || null, + modelResourceIds: { + ...(difyForm.asr ? { ASR: difyForm.asr } : {}), + ...(difyForm.voice ? { TTS: difyForm.voice } : {}), + }, apiUrl: difyForm.apiUrl, apiKey: difyForm.apiKey, }), @@ -558,8 +574,8 @@ export function AssistantPage(props: AssistantPageProps) { apiUrl: a.apiUrl, // 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key apiKey: "", - asr: a.asrCredentialId ?? "", - voice: a.ttsCredentialId ?? "", + asr: a.modelResourceIds.ASR ?? "", + voice: a.modelResourceIds.TTS ?? "", enableInterrupt: a.enableInterrupt, }; setFastGptForm(next); @@ -572,8 +588,10 @@ export function AssistantPage(props: AssistantPageProps) { name: fastGptForm.name.trim(), type: "fastgpt", enableInterrupt: fastGptForm.enableInterrupt, - asrCredentialId: fastGptForm.asr || null, - ttsCredentialId: fastGptForm.voice || null, + modelResourceIds: { + ...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}), + ...(fastGptForm.voice ? { TTS: fastGptForm.voice } : {}), + }, appId: fastGptForm.appId, apiUrl: fastGptForm.apiUrl, apiKey: fastGptForm.apiKey, @@ -589,9 +607,9 @@ export function AssistantPage(props: AssistantPageProps) { apiUrl: a.apiUrl, // 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key apiKey: "", - model: a.llmCredentialId ?? "", - asr: a.asrCredentialId ?? "", - voice: a.ttsCredentialId ?? "", + model: a.modelResourceIds.LLM ?? "", + asr: a.modelResourceIds.ASR ?? "", + voice: a.modelResourceIds.TTS ?? "", enableInterrupt: a.enableInterrupt, }; setOpenCodeForm(next); @@ -642,9 +660,11 @@ export function AssistantPage(props: AssistantPageProps) { name: openCodeForm.name.trim(), type: "opencode", enableInterrupt: openCodeForm.enableInterrupt, - llmCredentialId: openCodeForm.model || null, - asrCredentialId: openCodeForm.asr || null, - ttsCredentialId: openCodeForm.voice || null, + modelResourceIds: { + ...(openCodeForm.model ? { LLM: openCodeForm.model } : {}), + ...(openCodeForm.asr ? { ASR: openCodeForm.asr } : {}), + ...(openCodeForm.voice ? { TTS: openCodeForm.voice } : {}), + }, prompt: openCodeForm.prompt, apiUrl: openCodeForm.apiUrl, apiKey: openCodeForm.apiKey, @@ -673,6 +693,7 @@ export function AssistantPage(props: AssistantPageProps) { name: a.name, type: typeToLabel[a.type], updatedAt: formatTimestamp(a.updatedAt), + updatedAtRaw: a.updatedAt, })); const filteredAssistants = listItems.filter((assistant) => { if (typeFilter !== "全部" && assistant.type !== typeFilter) { @@ -691,12 +712,23 @@ export function AssistantPage(props: AssistantPageProps) { .includes(keyword); }); + // 按更新时间排序(以原始 ISO 时间戳为准);缺失时间戳视为最早,id 作为稳定的次级排序键 + const timeValue = (iso: string | null | undefined) => { + const t = iso ? new Date(iso).getTime() : NaN; + return Number.isNaN(t) ? 0 : t; + }; + const sortedAssistants = [...filteredAssistants].sort((a, b) => { + const diff = timeValue(b.updatedAtRaw) - timeValue(a.updatedAtRaw); + if (diff !== 0) return sortOrder === "newest" ? diff : -diff; + return a.id.localeCompare(b.id); + }); + const pageSize = 5; - const totalPages = Math.max(1, Math.ceil(filteredAssistants.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(sortedAssistants.length / pageSize)); const safeCurrentPage = Math.min(currentPage, totalPages); const pageStart = (safeCurrentPage - 1) * pageSize; const pageEnd = pageStart + pageSize; - const paginatedAssistants = filteredAssistants.slice(pageStart, pageEnd); + const paginatedAssistants = sortedAssistants.slice(pageStart, pageEnd); function handleSearchChange(value: string) { setSearchQuery(value); @@ -708,6 +740,15 @@ export function AssistantPage(props: AssistantPageProps) { setCurrentPage(1); } + function handleSortChange(order: SortOrder) { + setSortOrder(order); + setCurrentPage(1); + } + + function toggleSortOrder() { + handleSortChange(sortOrder === "newest" ? "oldest" : "newest"); + } + function updateForm( key: K, value: AssistantForm[K], @@ -778,103 +819,125 @@ export function AssistantPage(props: AssistantPageProps) { if (view === "list") { return (
-
-
-

助手列表

-

- 管理已有的视频助手,支持提示词、工作流、Dify 和 FastGPT 类型。 -

-
- - -
+ + + 创建助手 + + } + />
-
-
- {typeFilters.map((filter) => ( - - ))} -
- -
- - handleSearchChange(event.target.value)} - className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft" + onChange={handleSearchChange} placeholder="搜索助手名称、类型或 ID..." + className="lg:w-[320px]" /> -
-
+ } + /> -
-
-
- 助手名称 -
-
- 助手类型 -
-
- 更新时间 -
-
- 操作 -
-
- -
- {paginatedAssistants.map((assistant) => ( -
-
+ + rows={paginatedAssistants} + rowKey={(assistant) => assistant.id} + loading={listLoading} + loadingText="正在加载助手列表…" + error={listError} + onRetry={() => void loadAssistants()} + empty={{ + title: listItems.length === 0 ? "暂无助手" : "未找到匹配的助手", + description: + listItems.length === 0 + ? "点击右上角「创建助手」开始。" + : "请调整关键词或筛选条件后再试。", + }} + pagination={{ + page: safeCurrentPage, + totalPages, + onPageChange: setCurrentPage, + summary: + filteredAssistants.length === 0 + ? "没有数据" + : `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`, + }} + columns={[ + { + key: "name", + header: "助手名称", + width: "md:w-[360px]", + cell: (assistant) => ( + <>
{assistant.name}
{assistant.id}
-
- -
- - {assistant.type} - -
- -
- {assistant.updatedAt} -
- -
+ + ), + }, + { + key: "type", + header: "助手类型", + width: "md:w-[128px]", + cell: (assistant) => ( + + {assistant.type} + + ), + }, + { + key: "updatedAt", + width: "md:w-[176px]", + header: ( + + ), + cellClassName: + "whitespace-nowrap tabular-nums text-muted-foreground", + cell: (assistant) => assistant.updatedAt, + }, + { + key: "actions", + header: "操作", + align: "right", + cellClassName: "flex justify-end gap-2", + cell: (assistant) => ( + <>
-
- ))} - - {listLoading && ( -
- - 正在加载助手列表… -
- )} - - {!listLoading && listError && ( -
-
加载失败
-
- {listError} -
- -
- )} - - {!listLoading && !listError && filteredAssistants.length === 0 && ( -
-
- {listItems.length === 0 - ? "暂无助手" - : "未找到匹配的助手"} -
-
- {listItems.length === 0 - ? "点击右上角「创建助手」开始。" - : "请调整关键词或筛选条件后再试。"} -
-
- )} -
-
- -
-
- {filteredAssistants.length === 0 - ? "没有数据" - : `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`} -
- -
- - - {Array.from({ length: totalPages }, (_, index) => index + 1).map( - (page) => ( - + ), - )} - - -
-
+ }, + ]} + />
); @@ -1744,6 +1715,10 @@ const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] = { style: "wave", label: "波形", icon: }, ]; +// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为 +// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。 +const SHOW_VOICE_VIZ = false; + function SegmentedIconGroup({ children, label, @@ -1800,38 +1775,40 @@ function DebugDrawer({ assistantId }: { assistantId: string | null }) {