Refactor backend to support interface-definition driven model resources
- Introduce a new model structure for managing interface definitions and model resources, enhancing the backend's capability to handle various service integrations. - Update the Makefile to reflect changes in database seeding and resource management commands. - Remove the deprecated credentials management routes and replace them with a unified model registry API. - Modify existing routes and schemas to align with the new model structure, ensuring seamless integration with the frontend. - Enhance database seeding scripts to populate new model resources and their configurations. - Update README documentation to reflect the new architecture and usage instructions for model resources and interface definitions.
This commit is contained in:
18
Makefile
18
Makefile
@@ -7,7 +7,7 @@
|
|||||||
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
|
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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: ## 列出所有可用目标
|
help: ## 列出所有可用目标
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
@@ -33,19 +33,19 @@ api-logs: ## 只看后端日志
|
|||||||
db: ## 进入交互式 psql
|
db: ## 进入交互式 psql
|
||||||
docker compose exec postgres psql -U postgres -d postgres
|
docker compose exec postgres psql -U postgres -d postgres
|
||||||
|
|
||||||
db-list: ## 列出凭证与助手(key 明文,仅本地调试用)
|
db-list: ## 列出模型资源与助手
|
||||||
@$(PSQL) -c "SELECT id, name, type, interface_type, is_default FROM provider_credentials ORDER BY id;"
|
@$(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;"
|
@$(PSQL) -c "SELECT id, name, type FROM assistants ORDER BY id;"
|
||||||
|
|
||||||
db-seed-credentials: ## 灌入 12 条模型凭证种子(幂等)
|
db-seed-model-resources: ## 灌入 12 条模型资源种子(幂等)
|
||||||
$(PSQL) < backend/db/seed_credentials.sql
|
$(PSQL) < backend/db/seed_model_resources.sql
|
||||||
|
|
||||||
db-seed-assistants: ## 灌入 知识库 + 助手 种子(幂等;依赖凭证已就绪)
|
db-seed-assistants: ## 灌入 知识库 + 助手 种子(幂等;依赖模型资源已就绪)
|
||||||
$(PSQL) < backend/db/seed_assistants.sql
|
$(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: ## 清空 助手/知识库/凭证 三表(按依赖顺序)
|
db-clear: ## 清空 助手/知识库/模型资源(按依赖顺序)
|
||||||
$(PSQL) -c "TRUNCATE assistants, knowledge_bases, provider_credentials CASCADE;"
|
$(PSQL) -c "TRUNCATE assistant_model_bindings, assistants, knowledge_bases, model_resources CASCADE;"
|
||||||
|
|
||||||
db-reset: db-clear db-seed ## 清空后重新灌全部种子
|
db-reset: db-clear db-seed ## 清空后重新灌全部种子
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pipecat 把"管线"和"输出方式"解耦:同一条 `STT→LLM→TTS` 管线可
|
|||||||
```
|
```
|
||||||
ai-video-backend/
|
ai-video-backend/
|
||||||
├── app.py # FastAPI 入口,挂路由 + CORS
|
├── app.py # FastAPI 入口,挂路由 + CORS
|
||||||
├── config.py # 读 .env,所有 provider 接入点
|
├── config.py # 读 .env,模型接口环境变量兜底
|
||||||
├── models.py # AssistantConfig(对齐前端 AssistantForm)
|
├── models.py # AssistantConfig(对齐前端 AssistantForm)
|
||||||
├── routes/ # 一个文件一组端点(对齐 dograh routes/)
|
├── routes/ # 一个文件一组端点(对齐 dograh routes/)
|
||||||
│ ├── health.py
|
│ ├── health.py
|
||||||
@@ -33,7 +33,7 @@ ai-video-backend/
|
|||||||
│ └── voice_ws.py # WS 裸音频流
|
│ └── voice_ws.py # WS 裸音频流
|
||||||
├── services/
|
├── services/
|
||||||
│ └── pipecat/ # 引擎(对齐 dograh services/pipecat/)
|
│ └── pipecat/ # 引擎(对齐 dograh services/pipecat/)
|
||||||
│ ├── service_factory.py # 建 STT/LLM/TTS(加 provider 在此)
|
│ ├── service_factory.py # 建 STT/LLM/TTS(按 interface_type 分发)
|
||||||
│ ├── transports.py # transport 工厂(加输出方式在此)
|
│ ├── transports.py # transport 工厂(加输出方式在此)
|
||||||
│ └── pipeline.py # 管线拼装与运行(transport 无关)
|
│ └── pipeline.py # 管线拼装与运行(transport 无关)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
@@ -57,15 +57,33 @@ ai-video-backend/
|
|||||||
|
|
||||||
### 讯飞 ASR / TTS / SuperTTS
|
### 讯飞 ASR / TTS / SuperTTS
|
||||||
|
|
||||||
讯飞继续复用 `ProviderCredential` 的现有字段,不增加专属列:
|
讯飞鉴权直接存入对应 `ModelResource.secrets`,接口参数存入 `ModelResource.values`:
|
||||||
|
|
||||||
- `interface_type`: `xfyun`
|
- 普通语音识别:`interface_type=xfyun-asr`
|
||||||
- `api_url`: 讯飞 WebSocket URL(`https://` 会自动转为 `wss://`)
|
- 普通语音合成:`interface_type=xfyun-tts`
|
||||||
- `api_key`: `{"appId":"...","apiKey":"...","apiSecret":"..."}`
|
- 超拟人语音合成:`interface_type=xfyun-super-tts`
|
||||||
- ASR `model_id`: `iat`
|
- `values.apiUrl` 保存讯飞 WebSocket URL,音色、语速等可选参数也放在 `values`
|
||||||
- 普通 TTS `model_id`: `tts`
|
- `secrets` 分别保存 `appId`、`apiKey`、`apiSecret`
|
||||||
- 超拟人 TTS `model_id`: `supertts`(包含 `/private/` 的 URL 也会自动识别)
|
|
||||||
- TTS `voice`: 讯飞音色 ID;`speed=1.0` 对应讯飞正常语速 `50`
|
## 接口定义驱动的模型注册表
|
||||||
|
|
||||||
|
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)
|
## 本地运行(用 uv,Python 3.12)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
路由分组(对齐 dograh 的 routes/ 结构):
|
路由分组(对齐 dograh 的 routes/ 结构):
|
||||||
/health 健康检查
|
/health 健康检查
|
||||||
/api/assistants 助手 CRUD
|
/api/assistants 助手 CRUD
|
||||||
/api/credentials 模型凭证 CRUD(key 打码)
|
/api/interface-definitions 接口定义
|
||||||
|
/api/model-resources 模型资源 CRUD
|
||||||
/ws/voice WebRTC 输出(浏览器)
|
/ws/voice WebRTC 输出(浏览器)
|
||||||
/ws/stream WS 输出(裸音频流)
|
/ws/stream WS 输出(裸音频流)
|
||||||
"""
|
"""
|
||||||
@@ -20,9 +21,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from routes import (
|
from routes import (
|
||||||
assistants,
|
assistants,
|
||||||
credentials,
|
|
||||||
health,
|
health,
|
||||||
knowledge_bases,
|
knowledge_bases,
|
||||||
|
model_registry,
|
||||||
voice_webrtc,
|
voice_webrtc,
|
||||||
voice_ws,
|
voice_ws,
|
||||||
)
|
)
|
||||||
@@ -46,8 +47,8 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(assistants.router)
|
app.include_router(assistants.router)
|
||||||
app.include_router(credentials.router)
|
|
||||||
app.include_router(knowledge_bases.router)
|
app.include_router(knowledge_bases.router)
|
||||||
|
app.include_router(model_registry.router)
|
||||||
app.include_router(voice_webrtc.router)
|
app.include_router(voice_webrtc.router)
|
||||||
app.include_router(voice_ws.router)
|
app.include_router(voice_ws.router)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""集中读取环境变量。所有 provider 的接入点都在这里,改栈只改 .env。"""
|
"""集中读取环境变量。所有模型接口的环境变量兜底都在这里。"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"""数据表定义(SQLAlchemy 2.0)。
|
"""数据表定义(SQLAlchemy 2.0)。
|
||||||
|
|
||||||
两张表,职责分离(见设计):
|
模型注册表由接口定义驱动:
|
||||||
- ProviderCredential:模型凭证(key 明文存,同 dograh,靠 DB 访问控制兜底;读时打码)
|
- InterfaceDefinition:具体接入协议及其动态表单字段
|
||||||
- Assistant:助手配置,**只存模型/音色的"选项名",不嵌 key**
|
- ModelResource:模型配置与鉴权值
|
||||||
|
- AssistantModelBinding:助手按能力选择模型资源
|
||||||
|
|
||||||
助手运行时再用 kind 去 ProviderCredential 取真 key(services/config_resolver.py)。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
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
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
@@ -17,22 +18,39 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ProviderCredential(Base):
|
class InterfaceDefinition(Base):
|
||||||
"""模型资源凭证。字段对齐前端 ComponentsModelsPage 的 ModelResource。"""
|
"""具体接入协议,例如 xfyun-tts 与 xfyun-super-tts。"""
|
||||||
|
|
||||||
__tablename__ = "provider_credentials"
|
__tablename__ = "interface_definitions"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True) # model_xxx
|
interface_type: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128), default="") # 资源名称,如 "DeepSeek-V3"
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
model_id: Mapped[str] = mapped_column(String(128), default="") # 模型ID,如 "deepseek-chat"
|
capability: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
type: Mapped[str] = mapped_column(String(16), index=True) # LLM|ASR|TTS|Realtime|Embedding
|
field_schema: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
api_url: Mapped[str] = mapped_column(String(512), default="")
|
version: Mapped[int] = mapped_column(default=1)
|
||||||
api_key: Mapped[str] = mapped_column(String(512), default="") # 明文
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
voice: Mapped[str] = mapped_column(String(128), default="") # TTS 音色
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
speed: Mapped[float] = mapped_column(Float, default=1.0) # TTS 语速
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
language: Mapped[str] = mapped_column(String(32), default="") # ASR 语言
|
)
|
||||||
# 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选)
|
|
||||||
|
|
||||||
|
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)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -41,7 +59,7 @@ class ProviderCredential(Base):
|
|||||||
|
|
||||||
|
|
||||||
class KnowledgeBase(Base):
|
class KnowledgeBase(Base):
|
||||||
"""知识库注册表。本身引用一个 Embedding 凭证(用哪个向量模型)。
|
"""知识库注册表。本身引用一个 Embedding 模型资源。
|
||||||
|
|
||||||
文档/分块(pgvector)是 KB 内部实现,这里先不展开;助手侧只认 knowledge_base_id。
|
文档/分块(pgvector)是 KB 内部实现,这里先不展开;助手侧只认 knowledge_base_id。
|
||||||
"""
|
"""
|
||||||
@@ -51,10 +69,9 @@ class KnowledgeBase(Base):
|
|||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True) # kb_xxx
|
id: Mapped[str] = mapped_column(String(40), primary_key=True) # kb_xxx
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
description: Mapped[str] = mapped_column(String(2048), default="")
|
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
# 该 KB 用哪个向量模型;凭证被删则置空
|
embedding_model_resource_id: Mapped[str | None] = mapped_column(
|
||||||
embedding_credential_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(40),
|
String(40),
|
||||||
ForeignKey("provider_credentials.id", ondelete="SET NULL"),
|
ForeignKey("model_resources.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(String(16), default="active") # active|archived
|
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="")
|
greeting: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True)
|
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),无默认兜底
|
# KB 引用:被引用时禁止删 KB(RESTRICT),无默认兜底
|
||||||
knowledge_base_id: Mapped[str | None] = mapped_column(
|
knowledge_base_id: Mapped[str | None] = mapped_column(
|
||||||
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
|
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
|
||||||
@@ -101,7 +105,7 @@ class Assistant(Base):
|
|||||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||||
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / 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
|
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
|
||||||
# workflow 专属:图(nodes/edges)。要版本化时再迁出到 assistant_workflow 表
|
# workflow 专属:图(nodes/edges)。要版本化时再迁出到 assistant_workflow 表
|
||||||
graph: Mapped[dict] = mapped_column(JSON, default=dict)
|
graph: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
@@ -110,3 +114,26 @@ class Assistant(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,51 +1,42 @@
|
|||||||
-- 知识库 + 助手种子数据(依赖 seed_credentials.sql 先灌入 model_xxx 凭证)。
|
-- 知识库 + 助手种子数据。依赖 seed_model_resources.sql。
|
||||||
--
|
|
||||||
-- 用法(从仓库根目录):
|
|
||||||
-- 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 才打码),这里填示例占位。
|
|
||||||
|
|
||||||
-- 知识库(引用 Embedding 凭证 model_010)
|
INSERT INTO knowledge_bases
|
||||||
INSERT INTO knowledge_bases (id, name, description, embedding_credential_id, status)
|
(id, name, description, embedding_model_resource_id, status)
|
||||||
VALUES
|
VALUES
|
||||||
('kb_001', '政务政策知识库', '政策解读 / 办事指南示例库', 'model_010', 'active')
|
('kb_001', '政务政策知识库', '政策解读 / 办事指南示例库', 'model_003', 'active')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- 助手(一种类型一条)
|
|
||||||
INSERT INTO assistants (
|
INSERT INTO assistants (
|
||||||
id, name, type, runtime_mode, greeting, enable_interrupt,
|
id, name, type, runtime_mode, greeting, enable_interrupt,
|
||||||
llm_credential_id, asr_credential_id, tts_credential_id,
|
knowledge_base_id, prompt, api_url, api_key, app_id, graph
|
||||||
realtime_credential_id, knowledge_base_id,
|
|
||||||
prompt, api_url, api_key, app_id, graph
|
|
||||||
) VALUES
|
) VALUES
|
||||||
-- 提示词:llm/asr/tts + 知识库,prompt 真列
|
|
||||||
('asst_001', '政务咨询助手', 'prompt', 'pipeline', '您好,我是政务助手,请问有什么可以帮您?', TRUE,
|
('asst_001', '政务咨询助手', 'prompt', 'pipeline', '您好,我是政务助手,请问有什么可以帮您?', TRUE,
|
||||||
'model_001', 'model_003', 'model_005', NULL, 'kb_001',
|
'kb_001', '你是一名专业的政务咨询助手,回答准确、简洁,不编造政策内容。', '', '', '', '{}'),
|
||||||
'你是一名专业的政务咨询助手,回答准确、简洁,不编造政策内容。', '', '', '', '{}'),
|
|
||||||
|
|
||||||
-- 工作流:asr/tts + graph 列(最小图)
|
|
||||||
('asst_002', '热线工单助手', 'workflow', 'pipeline', '', TRUE,
|
('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":[]}'),
|
'{"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,
|
('asst_003', 'Dify 客服助手', 'dify', 'pipeline', '', TRUE,
|
||||||
NULL, 'model_003', 'model_005', NULL, NULL,
|
NULL, '', 'https://api.dify.ai/v1', 'app-dify-demo-key', '', '{}'),
|
||||||
'', 'https://api.dify.ai/v1', 'app-dify-demo-key', '', '{}'),
|
|
||||||
|
|
||||||
-- FastGPT:asr/tts + app_id/api_url/api_key
|
|
||||||
('asst_004', 'FastGPT 售后助手', 'fastgpt', 'pipeline', '', TRUE,
|
('asst_004', 'FastGPT 售后助手', 'fastgpt', 'pipeline', '', TRUE,
|
||||||
NULL, 'model_003', 'model_005', NULL, NULL,
|
NULL, '', 'https://api.fastgpt.in/api/v1/chat/completions', 'fastgpt-demo-key', 'app-fastgpt-001', '{}'),
|
||||||
'', 'https://api.fastgpt.in/api/v1/chat/completions', 'fastgpt-demo-key', 'app-fastgpt-001', '{}'),
|
|
||||||
|
|
||||||
-- OpenCode:asr/tts + prompt/api_url/api_key
|
|
||||||
('asst_005', 'OpenCode 代码助手', 'opencode', 'pipeline', '', TRUE,
|
('asst_005', 'OpenCode 代码助手', 'opencode', 'pipeline', '', TRUE,
|
||||||
NULL, 'model_003', 'model_005', NULL, NULL,
|
NULL, '你是一个代码助手的语音界面,用简洁口语回答工程问题。', 'http://localhost:4096', 'opencode-demo-key', '', '{}')
|
||||||
'你是一个代码助手的语音界面,用简洁口语回答工程问题。', 'http://localhost:4096', 'opencode-demo-key', '', '{}')
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
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();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
48
backend/db/seed_model_resources.sql
Normal file
48
backend/db/seed_model_resources.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- 模型资源种子数据。依赖应用启动时写入 interface_definitions。
|
||||||
|
|
||||||
|
INSERT INTO model_resources
|
||||||
|
(id, name, capability, interface_type, values, secrets, enabled, is_default)
|
||||||
|
VALUES
|
||||||
|
('model_001', 'DeepSeek-Chat', 'LLM', 'openai-llm',
|
||||||
|
'{"modelId":"deepseek-chat","apiUrl":"https://api.deepseek.com/v1","temperature":0.7}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, TRUE),
|
||||||
|
('model_002', 'SiliconFlow-TeleSpeechASR', 'ASR', 'openai-asr',
|
||||||
|
'{"modelId":"TeleAI/TeleSpeechASR","apiUrl":"https://api.siliconflow.cn/v1","language":"zh"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE),
|
||||||
|
('model_003', 'SiliconFlow-Qwen3-Embedding-4B', 'Embedding', 'openai-embedding',
|
||||||
|
'{"modelId":"Qwen/Qwen3-Embedding-4B","apiUrl":"https://api.siliconflow.cn/v1"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, TRUE),
|
||||||
|
('model_004', 'SiliconFlow-CosyVoice2-0.5B', 'TTS', 'openai-tts',
|
||||||
|
'{"modelId":"FunAudioLLM/CosyVoice2-0.5B","apiUrl":"https://api.siliconflow.cn/v1","voice":"FunAudioLLM/CosyVoice2-0.5B:anna","speed":1.0,"sourceSampleRate":24000}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE),
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE),
|
||||||
|
('model_005', '讯飞语音识别', 'ASR', 'xfyun-asr',
|
||||||
|
'{"apiUrl":"https://iat-api.xfyun.cn/v2/iat","language":"zh_cn","domain":"iat","accent":"mandarin","dynamicCorrection":false,"frameSize":1280}',
|
||||||
|
'{"appId":"replace-me","apiKey":"replace-me","apiSecret":"replace-me"}', TRUE, TRUE),
|
||||||
|
('model_006', 'Paraformer 识别', 'ASR', 'dashscope-asr',
|
||||||
|
'{"modelId":"paraformer-realtime-v2","apiUrl":"https://dashscope.aliyuncs.com/api/v1/services/audio/asr","language":"zh"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE),
|
||||||
|
('model_007', '讯飞语音合成', 'TTS', 'xfyun-tts',
|
||||||
|
'{"apiUrl":"https://tts-api.xfyun.cn/v2/tts","voice":"xiaoyan","speed":50,"volume":50,"pitch":50,"sourceSampleRate":16000}',
|
||||||
|
'{"appId":"replace-me","apiKey":"replace-me","apiSecret":"replace-me"}', TRUE, TRUE),
|
||||||
|
('model_008', 'CosyVoice 合成', 'TTS', 'dashscope-tts',
|
||||||
|
'{"modelId":"cosyvoice-v1","apiUrl":"https://dashscope.aliyuncs.com/api/v1/services/audio/tts","voice":"longxiaochun"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE),
|
||||||
|
('model_009', 'GPT Realtime', 'Realtime', 'openai-realtime',
|
||||||
|
'{"modelId":"gpt-4o-realtime-preview","apiUrl":"https://api.openai.com/v1/realtime"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, TRUE),
|
||||||
|
('model_010', 'Gemini Live', 'Realtime', 'gemini-realtime',
|
||||||
|
'{"modelId":"gemini-2.0-flash-live","apiUrl":"https://generativelanguage.googleapis.com/v1beta"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE),
|
||||||
|
('model_011', 'text-embedding-3', 'Embedding', 'openai-embedding',
|
||||||
|
'{"modelId":"text-embedding-3-small","apiUrl":"https://api.openai.com/v1/embeddings"}',
|
||||||
|
'{"apiKey":"replace-me"}', TRUE, FALSE)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
capability = EXCLUDED.capability,
|
||||||
|
interface_type = EXCLUDED.interface_type,
|
||||||
|
values = EXCLUDED.values,
|
||||||
|
secrets = EXCLUDED.secrets,
|
||||||
|
enabled = EXCLUDED.enabled,
|
||||||
|
is_default = EXCLUDED.is_default,
|
||||||
|
updated_at = now();
|
||||||
@@ -6,9 +6,11 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
import json
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from db.models import Base
|
from db.models import Base
|
||||||
|
from services.interface_catalog import INTERFACE_DEFINITIONS
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
@@ -28,22 +30,26 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# MVP 兼容迁移:create_all 不会给已存在的表补列。
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"ALTER TABLE provider_credentials "
|
"ALTER TABLE interface_definitions "
|
||||||
"ADD COLUMN IF NOT EXISTS voice VARCHAR(128) NOT NULL DEFAULT ''"
|
"ALTER COLUMN field_schema TYPE JSONB USING field_schema::jsonb"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await conn.execute(
|
for definition in INTERFACE_DEFINITIONS:
|
||||||
text(
|
await conn.execute(
|
||||||
"ALTER TABLE provider_credentials "
|
text(
|
||||||
"ADD COLUMN IF NOT EXISTS speed DOUBLE PRECISION NOT NULL DEFAULT 1.0"
|
"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 ''"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ class AssistantConfig(BaseModel):
|
|||||||
stt_language: str = ""
|
stt_language: str = ""
|
||||||
tts_speed: float = 1.0
|
tts_speed: float = 1.0
|
||||||
realtimeModel: str = ""
|
realtimeModel: str = ""
|
||||||
stt_interface_type: str = "openai"
|
llm_interface_type: str = "openai-llm"
|
||||||
tts_interface_type: str = "openai"
|
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
|
enableInterrupt: bool = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。
|
"""Assistant CRUD backed by capability-to-model-resource bindings."""
|
||||||
|
|
||||||
模型/KB 以 FK 引用注册表;瘦类型字段直接是真列。外部类型(dify/fastgpt/opencode)的
|
|
||||||
api_key 是私有密钥,读时打码、写时哨兵(列级,复用 services/masking,与凭证表一致)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from db.models import Assistant
|
from db.models import Assistant, AssistantModelBinding, ModelResource
|
||||||
from db.session import get_session
|
from db.session import get_session
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from schemas import AssistantOut, AssistantUpsert
|
from schemas import AssistantOut, AssistantUpsert
|
||||||
@@ -15,27 +11,62 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
|
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(
|
return AssistantOut(
|
||||||
id=a.id,
|
id=assistant.id,
|
||||||
name=a.name,
|
name=assistant.name,
|
||||||
type=a.type, # type: ignore[arg-type]
|
type=assistant.type, # type: ignore[arg-type]
|
||||||
runtime_mode=a.runtime_mode, # type: ignore[arg-type]
|
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||||
greeting=a.greeting,
|
greeting=assistant.greeting,
|
||||||
enable_interrupt=a.enable_interrupt,
|
enable_interrupt=assistant.enable_interrupt,
|
||||||
llm_credential_id=a.llm_credential_id,
|
model_resource_ids=await _resource_ids(session, assistant.id),
|
||||||
asr_credential_id=a.asr_credential_id,
|
knowledge_base_id=assistant.knowledge_base_id,
|
||||||
tts_credential_id=a.tts_credential_id,
|
prompt=assistant.prompt,
|
||||||
realtime_credential_id=a.realtime_credential_id,
|
api_url=assistant.api_url,
|
||||||
knowledge_base_id=a.knowledge_base_id,
|
api_key=mask(assistant.api_key),
|
||||||
prompt=a.prompt,
|
app_id=assistant.app_id,
|
||||||
api_url=a.api_url,
|
graph=assistant.graph or {},
|
||||||
api_key=mask(a.api_key), # 仅外部类型有值;空串 mask 仍是空串
|
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
|
||||||
app_id=a.app_id,
|
|
||||||
graph=a.graph or {},
|
|
||||||
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,60 +75,61 @@ async def list_assistants(session: AsyncSession = Depends(get_session)):
|
|||||||
rows = (
|
rows = (
|
||||||
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
|
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
|
||||||
).scalars().all()
|
).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)
|
@router.post("", response_model=AssistantOut)
|
||||||
async def create_assistant(
|
async def create_assistant(
|
||||||
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
a = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **body.model_dump())
|
data = body.model_dump()
|
||||||
session.add(a)
|
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.commit()
|
||||||
await session.refresh(a)
|
await session.refresh(assistant)
|
||||||
return _to_out(a)
|
return await _to_out(session, assistant)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{assistant_id}", response_model=AssistantOut)
|
@router.get("/{assistant_id}", response_model=AssistantOut)
|
||||||
async def get_assistant(
|
async def get_assistant(
|
||||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
a = await session.get(Assistant, assistant_id)
|
assistant = await session.get(Assistant, assistant_id)
|
||||||
if not a:
|
if not assistant:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
return _to_out(a)
|
return await _to_out(session, assistant)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
|
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
|
||||||
async def duplicate_assistant(
|
async def duplicate_assistant(
|
||||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
"""服务端整行复制:含真实 api_key,DB→DB,密钥不经过浏览器,副本可直接用。"""
|
source = await session.get(Assistant, assistant_id)
|
||||||
src = await session.get(Assistant, assistant_id)
|
if not source:
|
||||||
if not src:
|
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
a = Assistant(
|
assistant = Assistant(
|
||||||
id=f"asst_{uuid.uuid4().hex[:12]}",
|
id=f"asst_{uuid.uuid4().hex[:12]}",
|
||||||
name=f"{src.name} 副本",
|
name=f"{source.name} 副本",
|
||||||
type=src.type,
|
type=source.type,
|
||||||
runtime_mode=src.runtime_mode,
|
runtime_mode=source.runtime_mode,
|
||||||
greeting=src.greeting,
|
greeting=source.greeting,
|
||||||
enable_interrupt=src.enable_interrupt,
|
enable_interrupt=source.enable_interrupt,
|
||||||
llm_credential_id=src.llm_credential_id,
|
knowledge_base_id=source.knowledge_base_id,
|
||||||
asr_credential_id=src.asr_credential_id,
|
prompt=source.prompt,
|
||||||
tts_credential_id=src.tts_credential_id,
|
api_url=source.api_url,
|
||||||
realtime_credential_id=src.realtime_credential_id,
|
api_key=source.api_key,
|
||||||
knowledge_base_id=src.knowledge_base_id,
|
app_id=source.app_id,
|
||||||
prompt=src.prompt,
|
graph=dict(source.graph or {}),
|
||||||
api_url=src.api_url,
|
|
||||||
api_key=src.api_key, # 真 key,DB→DB
|
|
||||||
app_id=src.app_id,
|
|
||||||
graph=dict(src.graph or {}), # 浅拷贝,避免与源行共享同一 dict
|
|
||||||
)
|
)
|
||||||
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.commit()
|
||||||
await session.refresh(a)
|
await session.refresh(assistant)
|
||||||
return _to_out(a)
|
return await _to_out(session, assistant)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{assistant_id}", response_model=AssistantOut)
|
@router.put("/{assistant_id}", response_model=AssistantOut)
|
||||||
@@ -106,26 +138,27 @@ async def update_assistant(
|
|||||||
body: AssistantUpsert,
|
body: AssistantUpsert,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
a = await session.get(Assistant, assistant_id)
|
assistant = await session.get(Assistant, assistant_id)
|
||||||
if not a:
|
if not assistant:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
# 写时哨兵(列级):回传打码/空 api_key → 保留旧 key
|
resource_ids = data.pop("model_resource_ids")
|
||||||
data["api_key"] = resolve_incoming_key(data["api_key"], a.api_key)
|
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
|
||||||
for k, v in data.items():
|
for key, value in data.items():
|
||||||
setattr(a, k, v)
|
setattr(assistant, key, value)
|
||||||
|
await _sync_bindings(session, assistant.id, resource_ids)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(a)
|
await session.refresh(assistant)
|
||||||
return _to_out(a)
|
return await _to_out(session, assistant)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{assistant_id}")
|
@router.delete("/{assistant_id}")
|
||||||
async def delete_assistant(
|
async def delete_assistant(
|
||||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
a = await session.get(Assistant, assistant_id)
|
assistant = await session.get(Assistant, assistant_id)
|
||||||
if not a:
|
if not assistant:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
await session.delete(a)
|
await session.delete(assistant)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"""知识库 CRUD。前端助手编辑页的"知识库"下拉对接这里。
|
"""知识库 CRUD。前端助手编辑页的"知识库"下拉对接这里。
|
||||||
|
|
||||||
KB 自身引用一个 Embedding 凭证(embeddingCredentialId)。被助手引用时禁止删除
|
KB 自身引用一个 Embedding 模型资源。被助手引用时禁止删除
|
||||||
(DB 层 ON DELETE RESTRICT),这里把外键冲突翻译成 409。
|
(DB 层 ON DELETE RESTRICT),这里把外键冲突翻译成 409。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from db.models import KnowledgeBase
|
from db.models import KnowledgeBase, ModelResource
|
||||||
from db.session import get_session
|
from db.session import get_session
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
|
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
|
||||||
@@ -17,12 +17,22 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
router = APIRouter(prefix="/api/knowledge-bases", tags=["knowledge-bases"])
|
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:
|
def _to_out(kb: KnowledgeBase) -> KnowledgeBaseOut:
|
||||||
return KnowledgeBaseOut(
|
return KnowledgeBaseOut(
|
||||||
id=kb.id,
|
id=kb.id,
|
||||||
name=kb.name,
|
name=kb.name,
|
||||||
description=kb.description,
|
description=kb.description,
|
||||||
embedding_credential_id=kb.embedding_credential_id,
|
embedding_model_resource_id=kb.embedding_model_resource_id,
|
||||||
status=kb.status,
|
status=kb.status,
|
||||||
updated_at=kb.updated_at.isoformat() if kb.updated_at else None,
|
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(
|
async def create_knowledge_base(
|
||||||
body: KnowledgeBaseUpsert, session: AsyncSession = Depends(get_session)
|
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())
|
kb = KnowledgeBase(id=f"kb_{uuid.uuid4().hex[:12]}", **body.model_dump())
|
||||||
session.add(kb)
|
session.add(kb)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -66,6 +77,7 @@ async def update_knowledge_base(
|
|||||||
kb = await session.get(KnowledgeBase, kb_id)
|
kb = await session.get(KnowledgeBase, kb_id)
|
||||||
if not kb:
|
if not kb:
|
||||||
raise HTTPException(404, "知识库不存在")
|
raise HTTPException(404, "知识库不存在")
|
||||||
|
await _validate_embedding_resource(session, body.embedding_model_resource_id)
|
||||||
for k, v in body.model_dump().items():
|
for k, v in body.model_dump().items():
|
||||||
setattr(kb, k, v)
|
setattr(kb, k, v)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
249
backend/routes/model_registry.py
Normal file
249
backend/routes/model_registry.py
Normal file
@@ -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}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""面向前端的请求/响应 DTO。与 DB 模型解耦,**响应里的 key 一律打码**。
|
"""面向前端的请求/响应 DTO。与 DB 模型解耦,**响应里的 key 一律打码**。
|
||||||
|
|
||||||
凭证 DTO 字段对齐前端 ComponentsModelsPage 的 ModelResource:
|
模型资源 DTO 字段对齐前端 ComponentsModelsPage 的 ModelResource:
|
||||||
JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_case,
|
JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_case,
|
||||||
靠 Pydantic alias 自动互转。FastAPI 响应默认 by_alias=True,所以出参也是 camelCase。
|
靠 Pydantic alias 自动互转。FastAPI 响应默认 by_alias=True,所以出参也是 camelCase。
|
||||||
"""
|
"""
|
||||||
@@ -9,12 +9,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Literal
|
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
|
from pydantic.alias_generators import to_camel
|
||||||
|
|
||||||
RuntimeMode = Literal["pipeline", "realtime"]
|
RuntimeMode = Literal["pipeline", "realtime"]
|
||||||
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding"]
|
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding"]
|
||||||
InterfaceType = Literal["openai", "xfyun", "dashscope", "gemini"]
|
|
||||||
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
||||||
|
|
||||||
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
|
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
|
||||||
@@ -49,11 +48,7 @@ class AssistantUpsert(CamelModel):
|
|||||||
greeting: str = ""
|
greeting: str = ""
|
||||||
enable_interrupt: bool = True
|
enable_interrupt: bool = True
|
||||||
|
|
||||||
# 引用注册资源(FK id;None=未选)
|
model_resource_ids: dict[ModelType, str] = Field(default_factory=dict)
|
||||||
llm_credential_id: str | None = None
|
|
||||||
asr_credential_id: str | None = None
|
|
||||||
tts_credential_id: str | None = None
|
|
||||||
realtime_credential_id: str | None = None
|
|
||||||
knowledge_base_id: str | None = None
|
knowledge_base_id: str | None = None
|
||||||
|
|
||||||
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
||||||
@@ -62,7 +57,7 @@ class AssistantUpsert(CamelModel):
|
|||||||
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
||||||
app_id: str = ""
|
app_id: str = ""
|
||||||
# workflow 专属:图
|
# workflow 专属:图
|
||||||
graph: dict[str, Any] = {}
|
graph: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _strip_irrelevant_fields(self):
|
def _strip_irrelevant_fields(self):
|
||||||
@@ -84,7 +79,7 @@ class AssistantOut(AssistantUpsert):
|
|||||||
class KnowledgeBaseUpsert(CamelModel):
|
class KnowledgeBaseUpsert(CamelModel):
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
embedding_credential_id: str | None = None
|
embedding_model_resource_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeBaseOut(KnowledgeBaseUpsert):
|
class KnowledgeBaseOut(KnowledgeBaseUpsert):
|
||||||
@@ -93,55 +88,32 @@ class KnowledgeBaseOut(KnowledgeBaseUpsert):
|
|||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---------- 模型凭证(对齐前端 ModelResource) ----------
|
# ---------- 接口定义驱动的统一模型资源 ----------
|
||||||
class CredentialUpsert(CamelModel):
|
class InterfaceDefinitionOut(CamelModel):
|
||||||
name: str = "" # 资源名称
|
interface_type: str
|
||||||
model_id: str = "" # 模型ID
|
name: str
|
||||||
type: ModelType # LLM/ASR/TTS/Realtime/Embedding
|
capability: ModelType
|
||||||
interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini
|
field_schema: dict[str, Any]
|
||||||
api_url: str = ""
|
enabled: bool
|
||||||
api_key: str = "" # 写时:占位符/空表示不改
|
version: int
|
||||||
voice: str = "" # TTS
|
|
||||||
speed: float = 1.0 # TTS
|
|
||||||
language: str = "" # ASR
|
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
|
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 ModelResourceOut(ModelResourceUpsert):
|
||||||
class CredentialOut(CamelModel):
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
capability: ModelType
|
||||||
model_id: str
|
updated_at: str | None = None
|
||||||
type: str
|
|
||||||
interface_type: str
|
|
||||||
api_url: str
|
|
||||||
api_key: str # 读时:打码后的值
|
|
||||||
voice: str
|
|
||||||
speed: float
|
|
||||||
language: str
|
|
||||||
is_default: bool
|
|
||||||
|
|
||||||
|
|
||||||
class CredentialTestRequest(CamelModel):
|
class ModelResourceTestResult(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):
|
|
||||||
ok: bool
|
ok: bool
|
||||||
latency_ms: int | None = None
|
latency_ms: int | None = None
|
||||||
message: str
|
message: str
|
||||||
|
|||||||
@@ -1,52 +1,63 @@
|
|||||||
"""assistant_id → 运行时配置(把真 key 在服务端组装好)。
|
"""assistant_id → 运行时配置(把真 key 在服务端组装好)。
|
||||||
|
|
||||||
浏览器只传 assistant_id;真 key 在这里从 provider_credentials 取出注入。
|
浏览器只传 assistant_id;真 key 在这里从 model_resources 取出注入。
|
||||||
助手按 FK(*_credential_id)引用凭证;取不到则回退该 type 默认凭证,再回退 .env。
|
助手按 capability binding 引用资源;取不到则回退该能力默认资源,再回退 .env。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from db.models import Assistant, ProviderCredential
|
from db.models import Assistant, AssistantModelBinding, ModelResource
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
async def _default_credential(
|
async def _resource_for(
|
||||||
session: AsyncSession, type_: str
|
session: AsyncSession,
|
||||||
) -> ProviderCredential | None:
|
assistant_id: str,
|
||||||
"""该 type 的默认凭证(is_default 优先,否则按 id 取第一条)。"""
|
capability: str,
|
||||||
stmt = (
|
) -> ModelResource | None:
|
||||||
select(ProviderCredential)
|
binding = await session.get(AssistantModelBinding, (assistant_id, capability))
|
||||||
.where(ProviderCredential.type == type_)
|
resource_id = binding.model_resource_id if binding else None
|
||||||
.order_by(ProviderCredential.is_default.desc(), ProviderCredential.id.asc())
|
resource = await session.get(ModelResource, resource_id) if resource_id else None
|
||||||
.limit(1)
|
if resource and resource.capability != capability:
|
||||||
)
|
resource = None
|
||||||
return (await session.execute(stmt)).scalar_one_or_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(
|
def _value(resource: ModelResource | None, key: str, default):
|
||||||
session: AsyncSession, cred_id: str | None, type_: str
|
if not resource:
|
||||||
) -> ProviderCredential | None:
|
return default
|
||||||
"""按 FK id 取凭证;id 为空或失效 → 回退该 type 默认。"""
|
value = (resource.values or {}).get(key, default)
|
||||||
if cred_id:
|
return default if value is None else value
|
||||||
cred = await session.get(ProviderCredential, cred_id)
|
|
||||||
if cred:
|
|
||||||
return cred
|
def _secret(resource: ModelResource | None, key: str, default: str) -> str:
|
||||||
return await _default_credential(session, type_)
|
if not resource:
|
||||||
|
return default
|
||||||
|
return str((resource.secrets or {}).get(key) or default)
|
||||||
|
|
||||||
|
|
||||||
async def resolve_runtime_config(
|
async def resolve_runtime_config(
|
||||||
session: AsyncSession, assistant_id: str
|
session: AsyncSession, assistant_id: str
|
||||||
) -> AssistantConfig:
|
) -> AssistantConfig:
|
||||||
"""加载助手 + 解析凭证,产出可直接交给管线的运行时配置(含真 key)。"""
|
"""加载助手 + 解析模型资源,产出可直接交给管线的运行时配置(含真 key)。"""
|
||||||
assistant = await session.get(Assistant, assistant_id)
|
assistant = await session.get(Assistant, assistant_id)
|
||||||
if assistant is None:
|
if assistant is None:
|
||||||
raise ValueError(f"助手不存在: {assistant_id}")
|
raise ValueError(f"助手不存在: {assistant_id}")
|
||||||
|
|
||||||
llm = await _resolve(session, assistant.llm_credential_id, "LLM")
|
llm_resource = await _resource_for(session, assistant.id, "LLM")
|
||||||
stt = await _resolve(session, assistant.asr_credential_id, "ASR")
|
stt_resource = await _resource_for(session, assistant.id, "ASR")
|
||||||
tts = await _resolve(session, assistant.tts_credential_id, "TTS")
|
tts_resource = await _resource_for(session, assistant.id, "TTS")
|
||||||
realtime = await _resolve(session, assistant.realtime_credential_id, "Realtime")
|
realtime_resource = await _resource_for(session, assistant.id, "Realtime")
|
||||||
|
|
||||||
return AssistantConfig(
|
return AssistantConfig(
|
||||||
name=assistant.name,
|
name=assistant.name,
|
||||||
@@ -55,21 +66,28 @@ async def resolve_runtime_config(
|
|||||||
prompt=assistant.prompt or "你是一个有帮助的助手。",
|
prompt=assistant.prompt or "你是一个有帮助的助手。",
|
||||||
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||||
enableInterrupt=assistant.enable_interrupt,
|
enableInterrupt=assistant.enable_interrupt,
|
||||||
# 模型/音色:凭证的模型ID优先
|
# 模型/音色:模型资源中的配置优先
|
||||||
model=(llm.model_id if llm else ""),
|
model=str(_value(llm_resource, "modelId", "")),
|
||||||
asr=(stt.model_id if stt else ""),
|
asr=str(_value(stt_resource, "modelId", "")),
|
||||||
tts_model=(tts.model_id if tts else ""),
|
tts_model=str(_value(tts_resource, "modelId", "")),
|
||||||
voice=(tts.voice if tts else ""),
|
voice=str(_value(tts_resource, "voice", "")),
|
||||||
stt_language=(stt.language if stt else ""),
|
stt_language=str(_value(stt_resource, "language", "")),
|
||||||
tts_speed=(tts.speed if tts else 1.0),
|
tts_speed=float(_value(tts_resource, "speed", 1.0)),
|
||||||
stt_interface_type=(stt.interface_type if stt else "openai"),
|
llm_interface_type=(llm_resource.interface_type if llm_resource else "openai-llm"),
|
||||||
tts_interface_type=(tts.interface_type if tts else "openai"),
|
stt_interface_type=(stt_resource.interface_type if stt_resource else "openai-asr"),
|
||||||
realtimeModel=(realtime.model_id if realtime else ""),
|
tts_interface_type=(tts_resource.interface_type if tts_resource else "openai-tts"),
|
||||||
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
|
realtimeModel=str(_value(realtime_resource, "modelId", "")),
|
||||||
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),
|
llm_values=(llm_resource.values or {}) if llm_resource else {},
|
||||||
llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL),
|
llm_secrets=(llm_resource.secrets or {}) if llm_resource else {},
|
||||||
stt_api_key=(stt.api_key if stt else config.STT_API_KEY),
|
stt_values=(stt_resource.values or {}) if stt_resource else {},
|
||||||
stt_base_url=(stt.api_url if stt else config.STT_BASE_URL),
|
stt_secrets=(stt_resource.secrets or {}) if stt_resource else {},
|
||||||
tts_api_key=(tts.api_key if tts else config.TTS_API_KEY),
|
tts_values=(tts_resource.values or {}) if tts_resource else {},
|
||||||
tts_base_url=(tts.api_url if tts else config.TTS_BASE_URL),
|
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)),
|
||||||
)
|
)
|
||||||
|
|||||||
155
backend/services/interface_catalog.py
Normal file
155
backend/services/interface_catalog.py
Normal file
@@ -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")
|
||||||
@@ -25,3 +25,23 @@ def resolve_incoming_key(incoming: str | None, stored: str) -> str:
|
|||||||
if incoming is None or incoming == "" or is_masked(incoming):
|
if incoming is None or incoming == "" or is_masked(incoming):
|
||||||
return stored
|
return stored
|
||||||
return incoming
|
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""OpenAI 兼容模型凭证的最小连通测试。"""
|
"""Connectivity checks for interface-definition driven model resources."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import wave
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from schemas import CredentialTestRequest, CredentialTestResult
|
import config
|
||||||
from services.pipecat.xfyun_config import parse_xfyun_credential
|
from schemas import ModelResourceTestResult
|
||||||
|
|
||||||
TEST_TIMEOUT_SECONDS = 10.0
|
TEST_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ def _silent_wav() -> bytes:
|
|||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _error_detail(response: httpx.Response, api_key: str) -> str:
|
def _error_detail(response: httpx.Response, secrets: dict) -> str:
|
||||||
try:
|
try:
|
||||||
body = response.json()
|
body = response.json()
|
||||||
detail = (
|
detail = (
|
||||||
@@ -39,110 +39,114 @@ def _error_detail(response: httpx.Response, api_key: str) -> str:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
detail = None
|
detail = None
|
||||||
text = str(detail or response.text or response.reason_phrase).strip()
|
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(
|
async def test_model_resource(
|
||||||
config: CredentialTestRequest,
|
interface_type: str,
|
||||||
) -> CredentialTestResult:
|
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()
|
started = time.perf_counter()
|
||||||
headers = {"Authorization": f"Bearer {config.api_key}"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=TEST_TIMEOUT_SECONDS) as client:
|
async with httpx.AsyncClient(timeout=TEST_TIMEOUT_SECONDS) as client:
|
||||||
if config.type == "LLM":
|
if capability == "LLM":
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
_endpoint(config.api_url, "chat/completions"),
|
_endpoint(api_url, "chat/completions"),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"model": config.model_id,
|
"model": model_id,
|
||||||
"messages": [{"role": "user", "content": "Reply with OK."}],
|
"messages": [{"role": "user", "content": "Reply with OK."}],
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif config.type == "Embedding":
|
elif capability == "Embedding":
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
_endpoint(config.api_url, "embeddings"),
|
_endpoint(api_url, "embeddings"),
|
||||||
headers=headers,
|
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(
|
response = await client.post(
|
||||||
_endpoint(config.api_url, "audio/transcriptions"),
|
_endpoint(api_url, "audio/transcriptions"),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data={
|
data={
|
||||||
"model": config.model_id,
|
"model": model_id,
|
||||||
**({"language": config.language} if config.language else {}),
|
**(
|
||||||
|
{"language": str(values["language"])}
|
||||||
|
if values.get("language")
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
files={"file": ("test.wav", _silent_wav(), "audio/wav")},
|
files={"file": ("test.wav", _silent_wav(), "audio/wav")},
|
||||||
)
|
)
|
||||||
elif config.type == "TTS":
|
elif capability == "TTS":
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
_endpoint(config.api_url, "audio/speech"),
|
_endpoint(api_url, "audio/speech"),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"model": config.model_id,
|
"model": model_id,
|
||||||
"input": "测试",
|
"input": "测试",
|
||||||
"voice": config.voice,
|
"voice": str(values.get("voice") or config.TTS_VOICE),
|
||||||
"response_format": "pcm",
|
"response_format": "pcm",
|
||||||
"speed": config.speed,
|
"speed": float(values.get("speed") or 1),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return CredentialTestResult(
|
return ModelResourceTestResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
message="暂不支持该资源类型的连通测试",
|
message="暂不支持该能力的连接测试",
|
||||||
detail=f"当前仅支持 LLM、Embedding、ASR、TTS,收到 {config.type}",
|
detail=f"收到能力类型 {capability}",
|
||||||
)
|
)
|
||||||
|
|
||||||
latency_ms = round((time.perf_counter() - started) * 1000)
|
latency_ms = round((time.perf_counter() - started) * 1000)
|
||||||
if response.is_success:
|
if response.is_success:
|
||||||
return CredentialTestResult(
|
return ModelResourceTestResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
message="连接成功",
|
message="连接成功",
|
||||||
detail=f"OpenAI 兼容接口响应正常(HTTP {response.status_code})",
|
detail=f"接口响应正常(HTTP {response.status_code})",
|
||||||
)
|
)
|
||||||
return CredentialTestResult(
|
return ModelResourceTestResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
message=f"连接失败(HTTP {response.status_code})",
|
message=f"连接失败(HTTP {response.status_code})",
|
||||||
detail=_error_detail(response, config.api_key),
|
detail=_error_detail(response, secrets),
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return CredentialTestResult(
|
return ModelResourceTestResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
latency_ms=round((time.perf_counter() - started) * 1000),
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
||||||
message="连接超时",
|
message="连接超时",
|
||||||
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应",
|
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应",
|
||||||
)
|
)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
return CredentialTestResult(
|
return ModelResourceTestResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
latency_ms=round((time.perf_counter() - started) * 1000),
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
||||||
message="无法连接到模型服务",
|
message="无法连接到模型服务",
|
||||||
detail=str(exc)[:300],
|
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="请在语音测试页验证签名、识别和合成链路",
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""创建 STT / LLM / TTS 服务。
|
"""创建 STT / LLM / TTS 服务。
|
||||||
|
|
||||||
对应 dograh 的 service_factory.py,但只留一套国产栈(OpenAI 兼容),
|
对应 dograh 的 service_factory.py,但只留一套国产栈(OpenAI 兼容),
|
||||||
按 provider 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。
|
按 interface_type 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import config
|
import config
|
||||||
@@ -14,13 +14,7 @@ from pipecat.services.openai.tts import VALID_VOICES, OpenAITTSService
|
|||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
|
|
||||||
from services.pipecat.xfyun_asr import DEFAULT_XFYUN_ASR_URL, XfyunASRService
|
from services.pipecat.xfyun_asr import DEFAULT_XFYUN_ASR_URL, XfyunASRService
|
||||||
from services.pipecat.xfyun_config import (
|
from services.pipecat.xfyun_config import websocket_url, xfyun_language, xfyun_speed
|
||||||
is_super_tts,
|
|
||||||
parse_xfyun_credential,
|
|
||||||
websocket_url,
|
|
||||||
xfyun_language,
|
|
||||||
xfyun_speed,
|
|
||||||
)
|
|
||||||
from services.pipecat.xfyun_super_tts import (
|
from services.pipecat.xfyun_super_tts import (
|
||||||
DEFAULT_XFYUN_SUPER_TTS_URL,
|
DEFAULT_XFYUN_SUPER_TTS_URL,
|
||||||
XfyunSuperTTSService,
|
XfyunSuperTTSService,
|
||||||
@@ -43,16 +37,21 @@ def create_stt(cfg: AssistantConfig):
|
|||||||
|
|
||||||
连接信息优先用 cfg(由 config_resolver 从 DB 注入),为空回退 .env 默认。
|
连接信息优先用 cfg(由 config_resolver 从 DB 注入),为空回退 .env 默认。
|
||||||
"""
|
"""
|
||||||
if cfg.stt_interface_type == "xfyun":
|
if cfg.stt_interface_type == "xfyun-asr":
|
||||||
credential = parse_xfyun_credential(cfg.stt_api_key)
|
|
||||||
return XfyunASRService(
|
return XfyunASRService(
|
||||||
app_id=credential.app_id,
|
app_id=str(cfg.stt_secrets.get("appId") or ""),
|
||||||
api_key=credential.api_key,
|
api_key=str(cfg.stt_secrets.get("apiKey") or ""),
|
||||||
api_secret=credential.api_secret,
|
api_secret=str(cfg.stt_secrets.get("apiSecret") or ""),
|
||||||
url=websocket_url(cfg.stt_base_url, DEFAULT_XFYUN_ASR_URL),
|
url=websocket_url(cfg.stt_base_url, DEFAULT_XFYUN_ASR_URL),
|
||||||
language=xfyun_language(cfg.stt_language),
|
language=xfyun_language(cfg.stt_language),
|
||||||
sample_rate=16000,
|
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(
|
return OpenAISTTService(
|
||||||
api_key=cfg.stt_api_key or config.STT_API_KEY,
|
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):
|
def create_llm(cfg: AssistantConfig):
|
||||||
"""DeepSeek 等,走 OpenAI 兼容的 /v1/chat/completions。"""
|
"""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(
|
return OpenAILLMService(
|
||||||
api_key=cfg.llm_api_key or config.LLM_API_KEY,
|
api_key=cfg.llm_api_key or config.LLM_API_KEY,
|
||||||
base_url=cfg.llm_base_url or config.LLM_BASE_URL,
|
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):
|
def create_tts(cfg: AssistantConfig):
|
||||||
"""CosyVoice 等,走 OpenAI 兼容的 /v1/audio/speech。"""
|
"""CosyVoice 等,走 OpenAI 兼容的 /v1/audio/speech。"""
|
||||||
voice = cfg.voice or config.TTS_VOICE
|
voice = cfg.voice or config.TTS_VOICE
|
||||||
if cfg.tts_interface_type == "xfyun":
|
if cfg.tts_interface_type == "xfyun-super-tts":
|
||||||
credential = parse_xfyun_credential(cfg.tts_api_key)
|
return XfyunSuperTTSService(
|
||||||
speed = xfyun_speed(cfg.tts_speed)
|
app_id=str(cfg.tts_secrets.get("appId") or ""),
|
||||||
if is_super_tts(cfg.tts_model, cfg.tts_base_url):
|
api_key=str(cfg.tts_secrets.get("apiKey") or ""),
|
||||||
return XfyunSuperTTSService(
|
api_secret=str(cfg.tts_secrets.get("apiSecret") or ""),
|
||||||
app_id=credential.app_id,
|
voice=voice,
|
||||||
api_key=credential.api_key,
|
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_SUPER_TTS_URL),
|
||||||
api_secret=credential.api_secret,
|
sample_rate=16000,
|
||||||
voice=voice,
|
source_sample_rate=int(cfg.tts_values.get("sourceSampleRate") or 24000),
|
||||||
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_SUPER_TTS_URL),
|
speed=xfyun_speed(cfg.tts_speed),
|
||||||
sample_rate=16000,
|
volume=int(cfg.tts_values.get("volume") or 50),
|
||||||
source_sample_rate=24000,
|
pitch=int(cfg.tts_values.get("pitch") or 50),
|
||||||
speed=speed,
|
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(
|
return XfyunTTSService(
|
||||||
app_id=credential.app_id,
|
app_id=str(cfg.tts_secrets.get("appId") or ""),
|
||||||
api_key=credential.api_key,
|
api_key=str(cfg.tts_secrets.get("apiKey") or ""),
|
||||||
api_secret=credential.api_secret,
|
api_secret=str(cfg.tts_secrets.get("apiSecret") or ""),
|
||||||
voice=voice,
|
voice=voice,
|
||||||
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_TTS_URL),
|
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_TTS_URL),
|
||||||
sample_rate=16000,
|
sample_rate=16000,
|
||||||
source_sample_rate=16000,
|
source_sample_rate=int(cfg.tts_values.get("sourceSampleRate") or 16000),
|
||||||
speed=speed,
|
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,
|
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,
|
# Pipecat 默认只接受 OpenAI 官方音色。OpenAI 兼容服务常使用自定义 voice id,
|
||||||
# 注册为原样映射后仍由 OpenAI SDK 按字符串透传给供应商。
|
# 注册为原样映射后仍由 OpenAI SDK 按字符串透传给供应商。
|
||||||
|
|||||||
@@ -1,46 +1,7 @@
|
|||||||
"""Parse Xfyun's three-part credential from ProviderCredential.api_key."""
|
"""Shared Xfyun service value normalization."""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
def websocket_url(value: str, default: str) -> str:
|
||||||
url = (value or default).strip()
|
url = (value or default).strip()
|
||||||
if url.startswith("https://"):
|
if url.startswith("https://"):
|
||||||
@@ -49,12 +10,6 @@ def websocket_url(value: str, default: str) -> str:
|
|||||||
return f"ws://{url.removeprefix('http://')}"
|
return f"ws://{url.removeprefix('http://')}"
|
||||||
return url
|
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:
|
def xfyun_language(value: str) -> str:
|
||||||
normalized = (value or "zh_cn").lower().replace("-", "_")
|
normalized = (value or "zh_cn").lower().replace("-", "_")
|
||||||
return {"zh": "zh_cn", "en": "en_us"}.get(normalized, normalized)
|
return {"zh": "zh_cn", "en": "en_us"}.get(normalized, normalized)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
```
|
```
|
||||||
浏览器 ──https/wss──> nginx :443 (唯一 TLS 入口, mkcert 证书)
|
浏览器 ──https/wss──> nginx :443 (唯一 TLS 入口, mkcert 证书)
|
||||||
├── /ws/ → 后端 :8000 (/ws/voice 信令、/ws/stream 裸流)
|
├── /ws/ → 后端 :8000 (/ws/voice 信令、/ws/stream 裸流)
|
||||||
├── /api/ → 后端 :8000 (assistants/credentials/...)
|
├── /api/ → 后端 :8000 (assistants/model-resources/...)
|
||||||
├── /health → 后端 :8000
|
├── /health → 后端 :8000
|
||||||
└── / → 前端 :3000 (Next dev + HMR)
|
└── / → 前端 :3000 (Next dev + HMR)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ http {
|
|||||||
proxy_buffering off; # 流式音频不能攒着
|
proxy_buffering off; # 流式音频不能攒着
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- 后端 HTTP 接口:/api/*(assistants/credentials/knowledge-bases)+ /health ----
|
# ---- 后端 HTTP 接口:/api/*(assistants/model-resources/knowledge-bases)+ /health ----
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Rocket,
|
Rocket,
|
||||||
Search,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
Workflow,
|
Workflow,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
Save,
|
Save,
|
||||||
Mic,
|
Mic,
|
||||||
Send,
|
Send,
|
||||||
@@ -61,6 +61,11 @@ import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
|
|||||||
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
|
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
|
||||||
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
|
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
|
||||||
import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -71,15 +76,19 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
assistantsApi,
|
assistantsApi,
|
||||||
credentialsApi,
|
|
||||||
knowledgeBasesApi,
|
knowledgeBasesApi,
|
||||||
|
modelResourcesApi,
|
||||||
type Assistant,
|
type Assistant,
|
||||||
type AssistantType as ApiAssistantType,
|
type AssistantType as ApiAssistantType,
|
||||||
type AssistantUpsert,
|
type AssistantUpsert,
|
||||||
type Credential,
|
|
||||||
type KnowledgeBase,
|
type KnowledgeBase,
|
||||||
|
type ModelResource,
|
||||||
} from "@/lib/api";
|
} 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";
|
type RuntimeMode = "pipeline" | "realtime";
|
||||||
|
|
||||||
@@ -283,12 +292,17 @@ type AssistantListItem = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: AssistantType;
|
type: AssistantType;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** 原始 ISO 时间戳,用于按时间排序(updatedAt 为展示用整形字符串) */
|
||||||
|
updatedAtRaw: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TypeFilter = "全部" | AssistantType;
|
type TypeFilter = "全部" | AssistantType;
|
||||||
|
|
||||||
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
|
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
|
||||||
|
|
||||||
|
// 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前
|
||||||
|
type SortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
export function AssistantPage(props: AssistantPageProps) {
|
export function AssistantPage(props: AssistantPageProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// 编辑中的助手 id(来自路由)
|
// 编辑中的助手 id(来自路由)
|
||||||
@@ -311,8 +325,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
|
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
|
||||||
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
|
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
|
||||||
// 下拉数据源:模型凭证 + 知识库
|
// 下拉数据源:模型资源 + 知识库
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
||||||
const [view, setView] = useState<View>(() => {
|
const [view, setView] = useState<View>(() => {
|
||||||
@@ -322,6 +336,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
});
|
});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
|
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
|
||||||
// (工作流占位页也用它展示名称与类型)
|
// (工作流占位页也用它展示名称与类型)
|
||||||
@@ -352,14 +367,14 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
void loadAssistants();
|
void loadAssistants();
|
||||||
}, [props.mode, loadAssistants]);
|
}, [props.mode, loadAssistants]);
|
||||||
|
|
||||||
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
|
// 进入创建/编辑前加载下拉数据源(模型资源 + 知识库)
|
||||||
const loadResources = useCallback(async () => {
|
const loadResources = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [creds, kbs] = await Promise.all([
|
const [creds, kbs] = await Promise.all([
|
||||||
credentialsApi.list(),
|
modelResourcesApi.list(),
|
||||||
knowledgeBasesApi.list(),
|
knowledgeBasesApi.list(),
|
||||||
]);
|
]);
|
||||||
setCredentials(creds);
|
setModelResources(creds);
|
||||||
setKnowledgeBases(kbs);
|
setKnowledgeBases(kbs);
|
||||||
} catch {
|
} catch {
|
||||||
// 拉取失败时下拉为空,不阻塞表单
|
// 拉取失败时下拉为空,不阻塞表单
|
||||||
@@ -374,9 +389,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}, [props.mode, loadResources]);
|
}, [props.mode, loadResources]);
|
||||||
|
|
||||||
// 按资源类型生成 {value:id, label:name} 选项
|
// 按资源类型生成 {value:id, label:name} 选项
|
||||||
const credOptions = (type: Credential["type"]) =>
|
const credOptions = (type: ModelResource["capability"]) =>
|
||||||
credentials
|
modelResources
|
||||||
.filter((c) => c.type === type)
|
.filter((c) => c.capability === type)
|
||||||
.map((c) => ({ value: c.id, label: c.name }));
|
.map((c) => ({ value: c.id, label: c.name }));
|
||||||
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.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");
|
router.push("/assistants/new");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
|
// 把后端 Assistant 回填进提示词表单(model/asr/voice 等存模型资源 id)
|
||||||
// 返回回填后的表单,供调用方记录"已保存基线"
|
// 返回回填后的表单,供调用方记录"已保存基线"
|
||||||
function fillPromptForm(a: Assistant): AssistantForm {
|
function fillPromptForm(a: Assistant): AssistantForm {
|
||||||
const next: AssistantForm = {
|
const next: AssistantForm = {
|
||||||
@@ -392,10 +407,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
greeting: a.greeting,
|
greeting: a.greeting,
|
||||||
prompt: a.prompt,
|
prompt: a.prompt,
|
||||||
runtimeMode: a.runtimeMode,
|
runtimeMode: a.runtimeMode,
|
||||||
realtimeModel: a.realtimeCredentialId ?? "",
|
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
||||||
model: a.llmCredentialId ?? "",
|
model: a.modelResourceIds.LLM ?? "",
|
||||||
asr: a.asrCredentialId ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.ttsCredentialId ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
knowledgeBase: a.knowledgeBaseId ?? "",
|
knowledgeBase: a.knowledgeBaseId ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
};
|
};
|
||||||
@@ -458,10 +473,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
runtimeMode: "pipeline",
|
runtimeMode: "pipeline",
|
||||||
greeting: "",
|
greeting: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
llmCredentialId: null,
|
modelResourceIds: {},
|
||||||
asrCredentialId: null,
|
|
||||||
ttsCredentialId: null,
|
|
||||||
realtimeCredentialId: null,
|
|
||||||
knowledgeBaseId: null,
|
knowledgeBaseId: null,
|
||||||
prompt: "",
|
prompt: "",
|
||||||
apiUrl: "",
|
apiUrl: "",
|
||||||
@@ -511,10 +523,12 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
runtimeMode: form.runtimeMode,
|
runtimeMode: form.runtimeMode,
|
||||||
greeting: form.greeting,
|
greeting: form.greeting,
|
||||||
enableInterrupt: form.enableInterrupt,
|
enableInterrupt: form.enableInterrupt,
|
||||||
llmCredentialId: form.model || null,
|
modelResourceIds: {
|
||||||
asrCredentialId: form.asr || null,
|
...(form.model ? { LLM: form.model } : {}),
|
||||||
ttsCredentialId: form.voice || null,
|
...(form.asr ? { ASR: form.asr } : {}),
|
||||||
realtimeCredentialId: form.realtimeModel || null,
|
...(form.voice ? { TTS: form.voice } : {}),
|
||||||
|
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
|
||||||
|
},
|
||||||
knowledgeBaseId: form.knowledgeBase || null,
|
knowledgeBaseId: form.knowledgeBase || null,
|
||||||
prompt: form.prompt,
|
prompt: form.prompt,
|
||||||
}),
|
}),
|
||||||
@@ -528,8 +542,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
apiUrl: a.apiUrl,
|
apiUrl: a.apiUrl,
|
||||||
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
asr: a.asrCredentialId ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.ttsCredentialId ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
};
|
};
|
||||||
setDifyForm(next);
|
setDifyForm(next);
|
||||||
@@ -542,8 +556,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: difyForm.name.trim(),
|
name: difyForm.name.trim(),
|
||||||
type: "dify",
|
type: "dify",
|
||||||
enableInterrupt: difyForm.enableInterrupt,
|
enableInterrupt: difyForm.enableInterrupt,
|
||||||
asrCredentialId: difyForm.asr || null,
|
modelResourceIds: {
|
||||||
ttsCredentialId: difyForm.voice || null,
|
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
|
||||||
|
...(difyForm.voice ? { TTS: difyForm.voice } : {}),
|
||||||
|
},
|
||||||
apiUrl: difyForm.apiUrl,
|
apiUrl: difyForm.apiUrl,
|
||||||
apiKey: difyForm.apiKey,
|
apiKey: difyForm.apiKey,
|
||||||
}),
|
}),
|
||||||
@@ -558,8 +574,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
apiUrl: a.apiUrl,
|
apiUrl: a.apiUrl,
|
||||||
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
asr: a.asrCredentialId ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.ttsCredentialId ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
};
|
};
|
||||||
setFastGptForm(next);
|
setFastGptForm(next);
|
||||||
@@ -572,8 +588,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: fastGptForm.name.trim(),
|
name: fastGptForm.name.trim(),
|
||||||
type: "fastgpt",
|
type: "fastgpt",
|
||||||
enableInterrupt: fastGptForm.enableInterrupt,
|
enableInterrupt: fastGptForm.enableInterrupt,
|
||||||
asrCredentialId: fastGptForm.asr || null,
|
modelResourceIds: {
|
||||||
ttsCredentialId: fastGptForm.voice || null,
|
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
|
||||||
|
...(fastGptForm.voice ? { TTS: fastGptForm.voice } : {}),
|
||||||
|
},
|
||||||
appId: fastGptForm.appId,
|
appId: fastGptForm.appId,
|
||||||
apiUrl: fastGptForm.apiUrl,
|
apiUrl: fastGptForm.apiUrl,
|
||||||
apiKey: fastGptForm.apiKey,
|
apiKey: fastGptForm.apiKey,
|
||||||
@@ -589,9 +607,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
apiUrl: a.apiUrl,
|
apiUrl: a.apiUrl,
|
||||||
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
model: a.llmCredentialId ?? "",
|
model: a.modelResourceIds.LLM ?? "",
|
||||||
asr: a.asrCredentialId ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.ttsCredentialId ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
};
|
};
|
||||||
setOpenCodeForm(next);
|
setOpenCodeForm(next);
|
||||||
@@ -642,9 +660,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: openCodeForm.name.trim(),
|
name: openCodeForm.name.trim(),
|
||||||
type: "opencode",
|
type: "opencode",
|
||||||
enableInterrupt: openCodeForm.enableInterrupt,
|
enableInterrupt: openCodeForm.enableInterrupt,
|
||||||
llmCredentialId: openCodeForm.model || null,
|
modelResourceIds: {
|
||||||
asrCredentialId: openCodeForm.asr || null,
|
...(openCodeForm.model ? { LLM: openCodeForm.model } : {}),
|
||||||
ttsCredentialId: openCodeForm.voice || null,
|
...(openCodeForm.asr ? { ASR: openCodeForm.asr } : {}),
|
||||||
|
...(openCodeForm.voice ? { TTS: openCodeForm.voice } : {}),
|
||||||
|
},
|
||||||
prompt: openCodeForm.prompt,
|
prompt: openCodeForm.prompt,
|
||||||
apiUrl: openCodeForm.apiUrl,
|
apiUrl: openCodeForm.apiUrl,
|
||||||
apiKey: openCodeForm.apiKey,
|
apiKey: openCodeForm.apiKey,
|
||||||
@@ -673,6 +693,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: a.name,
|
name: a.name,
|
||||||
type: typeToLabel[a.type],
|
type: typeToLabel[a.type],
|
||||||
updatedAt: formatTimestamp(a.updatedAt),
|
updatedAt: formatTimestamp(a.updatedAt),
|
||||||
|
updatedAtRaw: a.updatedAt,
|
||||||
}));
|
}));
|
||||||
const filteredAssistants = listItems.filter((assistant) => {
|
const filteredAssistants = listItems.filter((assistant) => {
|
||||||
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
|
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
|
||||||
@@ -691,12 +712,23 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
.includes(keyword);
|
.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 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 safeCurrentPage = Math.min(currentPage, totalPages);
|
||||||
const pageStart = (safeCurrentPage - 1) * pageSize;
|
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||||||
const pageEnd = pageStart + pageSize;
|
const pageEnd = pageStart + pageSize;
|
||||||
const paginatedAssistants = filteredAssistants.slice(pageStart, pageEnd);
|
const paginatedAssistants = sortedAssistants.slice(pageStart, pageEnd);
|
||||||
|
|
||||||
function handleSearchChange(value: string) {
|
function handleSearchChange(value: string) {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
@@ -708,6 +740,15 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSortChange(order: SortOrder) {
|
||||||
|
setSortOrder(order);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortOrder() {
|
||||||
|
handleSortChange(sortOrder === "newest" ? "oldest" : "newest");
|
||||||
|
}
|
||||||
|
|
||||||
function updateForm<K extends keyof AssistantForm>(
|
function updateForm<K extends keyof AssistantForm>(
|
||||||
key: K,
|
key: K,
|
||||||
value: AssistantForm[K],
|
value: AssistantForm[K],
|
||||||
@@ -778,103 +819,125 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
if (view === "list") {
|
if (view === "list") {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||||||
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6">
|
<PageHeader
|
||||||
<div>
|
title="助手列表"
|
||||||
<h1 className="font-display display-lg text-ink">助手列表</h1>
|
description="管理已有的视频助手,支持提示词、工作流、Dify 和 FastGPT 类型。"
|
||||||
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
action={
|
||||||
管理已有的视频助手,支持提示词、工作流、Dify 和 FastGPT 类型。
|
<Button
|
||||||
</p>
|
size="lg"
|
||||||
</div>
|
className="w-full gap-2 sm:w-auto"
|
||||||
|
onClick={startCreate}
|
||||||
<Button
|
>
|
||||||
size="lg"
|
<Plus size={16} />
|
||||||
className="w-full shrink-0 gap-2 sm:w-auto"
|
创建助手
|
||||||
onClick={startCreate}
|
</Button>
|
||||||
>
|
}
|
||||||
<Plus size={16} />
|
/>
|
||||||
创建助手
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<ListToolbar
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
filters={
|
||||||
{typeFilters.map((filter) => (
|
<FilterPills
|
||||||
<Button
|
options={typeFilters}
|
||||||
key={filter}
|
value={typeFilter}
|
||||||
variant={filter === typeFilter ? "default" : "outline"}
|
onChange={handleFilterChange}
|
||||||
size="sm"
|
|
||||||
className={
|
|
||||||
filter === typeFilter
|
|
||||||
? "rounded-full"
|
|
||||||
: "rounded-full border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
||||||
}
|
|
||||||
onClick={() => handleFilterChange(filter)}
|
|
||||||
>
|
|
||||||
{filter}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full lg:w-[320px]">
|
|
||||||
<Search
|
|
||||||
size={15}
|
|
||||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
}
|
||||||
|
search={
|
||||||
|
<SearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => handleSearchChange(event.target.value)}
|
onChange={handleSearchChange}
|
||||||
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
|
|
||||||
placeholder="搜索助手名称、类型或 ID..."
|
placeholder="搜索助手名称、类型或 ID..."
|
||||||
|
className="lg:w-[320px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-xl border border-hairline">
|
<DataList<AssistantListItem>
|
||||||
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
|
rows={paginatedAssistants}
|
||||||
<div className="caption-label flex-1 text-muted-soft">
|
rowKey={(assistant) => assistant.id}
|
||||||
助手名称
|
loading={listLoading}
|
||||||
</div>
|
loadingText="正在加载助手列表…"
|
||||||
<div className="caption-label w-[110px] text-muted-soft">
|
error={listError}
|
||||||
助手类型
|
onRetry={() => void loadAssistants()}
|
||||||
</div>
|
empty={{
|
||||||
<div className="caption-label w-[150px] text-muted-soft">
|
title: listItems.length === 0 ? "暂无助手" : "未找到匹配的助手",
|
||||||
更新时间
|
description:
|
||||||
</div>
|
listItems.length === 0
|
||||||
<div className="caption-label w-[116px] text-right text-muted-soft">
|
? "点击右上角「创建助手」开始。"
|
||||||
操作
|
: "请调整关键词或筛选条件后再试。",
|
||||||
</div>
|
}}
|
||||||
</div>
|
pagination={{
|
||||||
|
page: safeCurrentPage,
|
||||||
<div className="divide-y divide-hairline">
|
totalPages,
|
||||||
{paginatedAssistants.map((assistant) => (
|
onPageChange: setCurrentPage,
|
||||||
<div
|
summary:
|
||||||
key={assistant.id}
|
filteredAssistants.length === 0
|
||||||
className="flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4"
|
? "没有数据"
|
||||||
>
|
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`,
|
||||||
<div className="min-w-0 flex-1">
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "助手名称",
|
||||||
|
width: "md:w-[360px]",
|
||||||
|
cell: (assistant) => (
|
||||||
|
<>
|
||||||
<div className="truncate font-medium text-foreground">
|
<div className="truncate font-medium text-foreground">
|
||||||
{assistant.name}
|
{assistant.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted-soft">
|
<div className="mt-1 text-xs text-muted-soft">
|
||||||
{assistant.id}
|
{assistant.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
),
|
||||||
<div className="md:w-[110px]">
|
},
|
||||||
<Badge
|
{
|
||||||
variant="secondary"
|
key: "type",
|
||||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
header: "助手类型",
|
||||||
>
|
width: "md:w-[128px]",
|
||||||
{assistant.type}
|
cell: (assistant) => (
|
||||||
</Badge>
|
<Badge
|
||||||
</div>
|
variant="secondary"
|
||||||
|
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||||
<div className="text-muted-foreground md:w-[150px]">
|
>
|
||||||
{assistant.updatedAt}
|
{assistant.type}
|
||||||
</div>
|
</Badge>
|
||||||
|
),
|
||||||
<div className="flex justify-end gap-2 md:w-[116px]">
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
width: "md:w-[176px]",
|
||||||
|
header: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleSortOrder}
|
||||||
|
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
|
aria-label={
|
||||||
|
sortOrder === "newest"
|
||||||
|
? "当前按最近更新排序,点击切换为最早更新"
|
||||||
|
: "当前按最早更新排序,点击切换为最近更新"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
更新时间
|
||||||
|
{sortOrder === "newest" ? (
|
||||||
|
<ChevronDown size={13} />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={13} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
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) => (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -920,103 +983,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{listLoading && (
|
|
||||||
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
正在加载助手列表…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!listLoading && listError && (
|
|
||||||
<div className="px-5 py-12 text-center">
|
|
||||||
<div className="font-medium text-destructive">加载失败</div>
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{listError}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => void loadAssistants()}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!listLoading && !listError && filteredAssistants.length === 0 && (
|
|
||||||
<div className="px-5 py-12 text-center">
|
|
||||||
<div className="font-medium text-foreground">
|
|
||||||
{listItems.length === 0
|
|
||||||
? "暂无助手"
|
|
||||||
: "未找到匹配的助手"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{listItems.length === 0
|
|
||||||
? "点击右上角「创建助手」开始。"
|
|
||||||
: "请调整关键词或筛选条件后再试。"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
{filteredAssistants.length === 0
|
|
||||||
? "没有数据"
|
|
||||||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={safeCurrentPage <= 1}
|
|
||||||
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
|
||||||
aria-label="上一页"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={15} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
|
||||||
(page) => (
|
|
||||||
<Button
|
|
||||||
key={page}
|
|
||||||
variant={page === safeCurrentPage ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={[
|
|
||||||
"h-8 min-w-8 px-2",
|
|
||||||
page === safeCurrentPage
|
|
||||||
? ""
|
|
||||||
: "border-hairline-strong text-muted-foreground hover:text-foreground",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => setCurrentPage(page)}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</Button>
|
|
||||||
),
|
),
|
||||||
)}
|
},
|
||||||
|
]}
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={safeCurrentPage >= totalPages}
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((page) => Math.min(totalPages, page + 1))
|
|
||||||
}
|
|
||||||
aria-label="下一页"
|
|
||||||
>
|
|
||||||
<ChevronRight size={15} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1744,6 +1715,10 @@ const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
|
|||||||
{ style: "wave", label: "波形", icon: <Waves size={14} /> },
|
{ style: "wave", label: "波形", icon: <Waves size={14} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为
|
||||||
|
// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。
|
||||||
|
const SHOW_VOICE_VIZ = false;
|
||||||
|
|
||||||
function SegmentedIconGroup({
|
function SegmentedIconGroup({
|
||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
@@ -1800,38 +1775,40 @@ function DebugDrawer({ assistantId }: { assistantId: string | null }) {
|
|||||||
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
|
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
||||||
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
||||||
<div className="flex items-center gap-2">
|
{SHOW_VOICE_VIZ && (
|
||||||
{!showTranscript && (
|
<div className="flex items-center gap-2">
|
||||||
<SegmentedIconGroup label="可视化样式">
|
{!showTranscript && (
|
||||||
{VIZ_OPTIONS.map((option) => (
|
<SegmentedIconGroup label="可视化样式">
|
||||||
<SegmentedIconButton
|
{VIZ_OPTIONS.map((option) => (
|
||||||
key={option.style}
|
<SegmentedIconButton
|
||||||
selected={vizStyle === option.style}
|
key={option.style}
|
||||||
label={`可视化样式:${option.label}`}
|
selected={vizStyle === option.style}
|
||||||
onClick={() => setVizStyle(option.style)}
|
label={`可视化样式:${option.label}`}
|
||||||
>
|
onClick={() => setVizStyle(option.style)}
|
||||||
{option.icon}
|
>
|
||||||
</SegmentedIconButton>
|
{option.icon}
|
||||||
))}
|
</SegmentedIconButton>
|
||||||
|
))}
|
||||||
|
</SegmentedIconGroup>
|
||||||
|
)}
|
||||||
|
<SegmentedIconGroup label="预览视图">
|
||||||
|
<SegmentedIconButton
|
||||||
|
selected={!showTranscript}
|
||||||
|
label="语音可视化视图"
|
||||||
|
onClick={() => setShowTranscript(false)}
|
||||||
|
>
|
||||||
|
<Mic size={14} />
|
||||||
|
</SegmentedIconButton>
|
||||||
|
<SegmentedIconButton
|
||||||
|
selected={showTranscript}
|
||||||
|
label="文字聊天记录视图"
|
||||||
|
onClick={() => setShowTranscript(true)}
|
||||||
|
>
|
||||||
|
<MessageSquareText size={14} />
|
||||||
|
</SegmentedIconButton>
|
||||||
</SegmentedIconGroup>
|
</SegmentedIconGroup>
|
||||||
)}
|
</div>
|
||||||
<SegmentedIconGroup label="预览视图">
|
)}
|
||||||
<SegmentedIconButton
|
|
||||||
selected={!showTranscript}
|
|
||||||
label="语音可视化视图"
|
|
||||||
onClick={() => setShowTranscript(false)}
|
|
||||||
>
|
|
||||||
<Mic size={14} />
|
|
||||||
</SegmentedIconButton>
|
|
||||||
<SegmentedIconButton
|
|
||||||
selected={showTranscript}
|
|
||||||
label="文字聊天记录视图"
|
|
||||||
onClick={() => setShowTranscript(true)}
|
|
||||||
>
|
|
||||||
<MessageSquareText size={14} />
|
|
||||||
</SegmentedIconButton>
|
|
||||||
</SegmentedIconGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DebugVoicePanel
|
<DebugVoicePanel
|
||||||
@@ -1881,8 +1858,21 @@ function DebugVoicePanel({
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
||||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||||
{showTranscript ? (
|
{!SHOW_VOICE_VIZ || showTranscript ? (
|
||||||
<DebugTranscriptPanel messages={messages} recording={recording} />
|
<>
|
||||||
|
<DebugTranscriptPanel messages={messages} recording={recording} />
|
||||||
|
<VoiceSessionControls
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
micWarning={micWarning}
|
||||||
|
assistantId={assistantId}
|
||||||
|
audioInputs={audioInputs}
|
||||||
|
selectedDeviceId={selectedDeviceId}
|
||||||
|
setSelectedDeviceId={setSelectedDeviceId}
|
||||||
|
connect={connect}
|
||||||
|
disconnect={disconnect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
|
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
|
||||||
<div
|
<div
|
||||||
@@ -2045,6 +2035,123 @@ function DebugVoicePanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话控制条:状态 + 麦克风选择 + 开始/结束按钮。
|
||||||
|
// 原本这些控件在中央可视化视图里,可视化隐藏后(SHOW_VOICE_VIZ=false)集中到这一条。
|
||||||
|
function VoiceSessionControls({
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
micWarning,
|
||||||
|
assistantId,
|
||||||
|
audioInputs,
|
||||||
|
selectedDeviceId,
|
||||||
|
setSelectedDeviceId,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
}: {
|
||||||
|
status: VoicePreviewStatus;
|
||||||
|
error: string | null;
|
||||||
|
micWarning: string | null;
|
||||||
|
assistantId: string | null;
|
||||||
|
audioInputs: MediaDeviceInfo[];
|
||||||
|
selectedDeviceId: string;
|
||||||
|
setSelectedDeviceId: (deviceId: string) => void;
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
}) {
|
||||||
|
const recording = status === "connecting" || status === "connected";
|
||||||
|
const hint =
|
||||||
|
status === "failed"
|
||||||
|
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
|
||||||
|
: !assistantId
|
||||||
|
? "请先保存助手,再开始语音预览。"
|
||||||
|
: micWarning
|
||||||
|
? `${micWarning} 可接收助手播报,但无法发送语音。`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-t border-hairline px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||||
|
recording
|
||||||
|
? "animate-pulse bg-success"
|
||||||
|
: status === "failed"
|
||||||
|
? "bg-destructive"
|
||||||
|
: "bg-muted-soft",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{status === "connecting"
|
||||||
|
? "连接中…"
|
||||||
|
: status === "connected"
|
||||||
|
? micWarning
|
||||||
|
? "仅收听"
|
||||||
|
: "进行中"
|
||||||
|
: status === "failed"
|
||||||
|
? "连接失败"
|
||||||
|
: "准备开始"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedDeviceId || "default"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSelectedDeviceId(value === "default" ? "" : value)
|
||||||
|
}
|
||||||
|
disabled={recording}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0 flex-1 gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
|
||||||
|
aria-label="选择麦克风"
|
||||||
|
>
|
||||||
|
<Mic size={13} className="shrink-0 text-muted-soft" />
|
||||||
|
<SelectValue placeholder="默认麦克风" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">默认麦克风</SelectItem>
|
||||||
|
{audioInputs.map((device, index) => (
|
||||||
|
<SelectItem key={device.deviceId} value={device.deviceId}>
|
||||||
|
{device.label || `麦克风 ${index + 1}`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!assistantId || status === "connecting"}
|
||||||
|
onClick={() => {
|
||||||
|
if (recording) {
|
||||||
|
disconnect();
|
||||||
|
} else {
|
||||||
|
void connect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"shrink-0 gap-1.5 rounded-full",
|
||||||
|
recording ? "bg-destructive text-white hover:bg-destructive/90" : "",
|
||||||
|
].join(" ")}
|
||||||
|
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
||||||
|
>
|
||||||
|
{status === "connecting" ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : recording ? (
|
||||||
|
<PhoneOff size={14} />
|
||||||
|
) : (
|
||||||
|
<Mic size={14} />
|
||||||
|
)}
|
||||||
|
{recording ? "结束对话" : "开始对话"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
|
// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
|
||||||
function formatMessageTime(iso: string): string {
|
function formatMessageTime(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
203
frontend/src/components/ui/data-list.tsx
Normal file
203
frontend/src/components/ui/data-list.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type DataListColumn<T> = {
|
||||||
|
/** 唯一键,用于 React key */
|
||||||
|
key: string;
|
||||||
|
/** 表头:字符串会自动套用 caption-label 样式;传节点则原样渲染(如可排序按钮) */
|
||||||
|
header: ReactNode;
|
||||||
|
/**
|
||||||
|
* 列宽 Tailwind 类,必须含响应式前缀以仅在桌面端生效(如 "md:w-[176px]"),
|
||||||
|
* 这样移动端为堆叠卡片、桌面端为定宽列。注意:必须是字面量字符串,
|
||||||
|
* Tailwind 才能在编译期抽取该 class。留空表示主列,占据剩余空间。
|
||||||
|
*/
|
||||||
|
width?: string;
|
||||||
|
/** 右对齐(用于"操作"等列) */
|
||||||
|
align?: "left" | "right";
|
||||||
|
/** 单元格内容 */
|
||||||
|
cell: (row: T) => ReactNode;
|
||||||
|
/** 单元格额外类名(如操作列的 "flex justify-end gap-2") */
|
||||||
|
cellClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataListPagination = {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
/** 左侧统计文案,如「显示 1-5 / 共 20 个」 */
|
||||||
|
summary?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataListProps<T> = {
|
||||||
|
columns: DataListColumn<T>[];
|
||||||
|
rows: T[];
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
/** 加载中:替换表格主体显示加载态 */
|
||||||
|
loading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
/** 错误信息:非空时替换主体显示错误态(优先级高于空态) */
|
||||||
|
error?: string | null;
|
||||||
|
errorTitle?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** 空态文案(rows 为空且无 loading/error 时显示) */
|
||||||
|
empty?: { title: string; description?: string };
|
||||||
|
/** 行点击(可选);提供时整行可点击 */
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
pagination?: DataListPagination;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DataList<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
loading = false,
|
||||||
|
loadingText = "正在加载",
|
||||||
|
error,
|
||||||
|
errorTitle = "加载失败",
|
||||||
|
onRetry,
|
||||||
|
empty,
|
||||||
|
onRowClick,
|
||||||
|
pagination,
|
||||||
|
}: DataListProps<T>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-hairline">
|
||||||
|
{/* 表头(移动端隐藏,桌面端为一行) */}
|
||||||
|
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.key}
|
||||||
|
className={cn(
|
||||||
|
column.width ?? "flex-1",
|
||||||
|
column.align === "right" && "text-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{typeof column.header === "string" ? (
|
||||||
|
<span className="caption-label text-muted-soft">
|
||||||
|
{column.header}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
column.header
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{loadingText}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-5 py-12 text-center">
|
||||||
|
<div className="font-medium text-destructive">{errorTitle}</div>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">{error}</div>
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="px-5 py-12 text-center">
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{empty?.title ?? "暂无数据"}
|
||||||
|
</div>
|
||||||
|
{empty?.description && (
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{empty.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-hairline">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={rowKey(row)}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4",
|
||||||
|
onRowClick && "cursor-pointer",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.key}
|
||||||
|
className={cn(
|
||||||
|
column.width ?? "min-w-0 flex-1",
|
||||||
|
column.cellClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.cell(row)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && (
|
||||||
|
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>{pagination.summary}</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => pagination.onPageChange(Math.max(1, pagination.page - 1))}
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{Array.from({ length: pagination.totalPages }, (_, index) => index + 1).map(
|
||||||
|
(page) => (
|
||||||
|
<Button
|
||||||
|
key={page}
|
||||||
|
variant={page === pagination.page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 min-w-8 px-2",
|
||||||
|
page !== pagination.page &&
|
||||||
|
"border-hairline-strong text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => pagination.onPageChange(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
pagination.onPageChange(
|
||||||
|
Math.min(pagination.totalPages, pagination.page + 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/ui/filter-pills.tsx
Normal file
42
frontend/src/components/ui/filter-pills.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type FilterPillsProps<T extends string> = {
|
||||||
|
options: readonly T[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 一行 pill 形筛选项,统一 active/inactive 样式 */
|
||||||
|
export function FilterPills<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: FilterPillsProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
variant={active ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full",
|
||||||
|
!active &&
|
||||||
|
"border-hairline-strong text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/list-toolbar.tsx
Normal file
26
frontend/src/components/ui/list-toolbar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ListToolbarProps = {
|
||||||
|
/** 左侧筛选区(通常是 FilterPills) */
|
||||||
|
filters?: ReactNode;
|
||||||
|
/** 右侧搜索区(通常是 SearchInput) */
|
||||||
|
search?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 列表页工具栏布局:左筛选 / 右搜索,移动端纵向堆叠 */
|
||||||
|
export function ListToolbar({ filters, search, className }: ListToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filters}
|
||||||
|
{search}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/components/ui/page-header.tsx
Normal file
39
frontend/src/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type PageHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
/** 右侧主操作(如「创建助手」按钮) */
|
||||||
|
action?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 列表/资源页统一页头:标题 + 说明 + 右侧操作,移动端纵向堆叠 */
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display display-lg text-ink">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action && <div className="w-full shrink-0 sm:w-auto">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/ui/search-input.tsx
Normal file
37
frontend/src/components/ui/search-input.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type SearchInputProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** 作用于外层容器,通常用于设定宽度(如 "lg:w-[320px]") */
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 带放大镜图标的搜索框,样式与列表页统一 */
|
||||||
|
export function SearchInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative w-full", className)}>
|
||||||
|
<Search
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Activity, ChevronDown, ChevronUp } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronsRight,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||||
@@ -12,18 +19,22 @@ import {
|
|||||||
rgba,
|
rgba,
|
||||||
} from "@/lib/visualizer-palette";
|
} from "@/lib/visualizer-palette";
|
||||||
|
|
||||||
/** 每格条形代表的音频时长(ms),决定时间轴滚动节奏 */
|
/** 每条样本代表的音频时长(ms) */
|
||||||
const SAMPLE_MS = 50;
|
const SAMPLE_MS = 50;
|
||||||
/** 条形宽度/间距(px):滚动速度 = (BAR_WIDTH+BAR_GAP) * 1000/SAMPLE_MS px/s */
|
/** 每列条形宽度/间距(px) */
|
||||||
const BAR_WIDTH = 2;
|
const COL_WIDTH = 2;
|
||||||
const BAR_GAP = 1;
|
const COL_GAP = 1;
|
||||||
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
const COL_STEP = COL_WIDTH + COL_GAP;
|
||||||
/** 历史保留上限:2 分钟,超出后丢最旧的样本 */
|
/** 缩放档位:每列聚合的时长(ms)。50 = 原始精度,越大看到的时间范围越长 */
|
||||||
const MAX_SAMPLES = (2 * 60 * 1000) / SAMPLE_MS;
|
const ZOOM_LEVELS_MS_PER_COL = [50, 100, 200, 400, 800, 1600, 3200];
|
||||||
/** 时间刻度间隔(ms) */
|
/** 时间刻度候选间隔(ms),按缩放挑选不至于过密的一档 */
|
||||||
const TICK_MS = 5_000;
|
const TICK_STEPS_MS = [1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000];
|
||||||
|
/** 历史保留上限:10 分钟,超出后丢最旧的样本 */
|
||||||
|
const MAX_SAMPLES = (10 * 60 * 1000) / SAMPLE_MS;
|
||||||
/** 顶部时间轴高度(px) */
|
/** 顶部时间轴高度(px) */
|
||||||
const AXIS_HEIGHT = 16;
|
const AXIS_HEIGHT = 16;
|
||||||
|
/** 左侧轨道标签栏宽度(px),波形不会画进这里 */
|
||||||
|
const LABEL_GUTTER = 40;
|
||||||
|
|
||||||
type History = {
|
type History = {
|
||||||
/** 每 SAMPLE_MS 一条的 RMS 强度(0~1),user/agent 等长同步推进 */
|
/** 每 SAMPLE_MS 一条的 RMS 强度(0~1),user/agent 等长同步推进 */
|
||||||
@@ -40,7 +51,10 @@ function makeHistory(): History {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 当前时域 RMS 强度(0~1);放大系数与 WaveVisualizer 一致,让小音量也可见 */
|
/** 当前时域 RMS 强度(0~1);放大系数与 WaveVisualizer 一致,让小音量也可见 */
|
||||||
function rmsLevel(node: AnalyserNode | null, buf: Uint8Array<ArrayBuffer>): number {
|
function rmsLevel(
|
||||||
|
node: AnalyserNode | null,
|
||||||
|
buf: Uint8Array<ArrayBuffer>,
|
||||||
|
): number {
|
||||||
if (!node) return 0;
|
if (!node) return 0;
|
||||||
node.getByteTimeDomainData(buf);
|
node.getByteTimeDomainData(buf);
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
@@ -70,9 +84,10 @@ export type WaveformTimelineProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 双轨波形时间轴:上轨「我」(麦克风)、下轨「助手」(远端音频),
|
* 双轨波形时间轴:上轨「我」(麦克风)、下轨「助手」(远端音频)。
|
||||||
* 按固定节拍采样 RMS 音量,最新样本贴右缘向左滚动,顶部带 m:ss 时间刻度。
|
* 按固定节拍采样 RMS 音量;跟随模式下最新样本贴右缘滚动。
|
||||||
* 配色取自设计 token(--gradient-*),自动跟随明暗主题。
|
* 交互:拖拽 / 滚轮平移回看历史,Ctrl(⌘)+滚轮或右上按钮缩放,
|
||||||
|
* 回看时出现「回到最新」按钮恢复跟随。配色取自设计 token,自动跟随主题。
|
||||||
*/
|
*/
|
||||||
export function WaveformTimeline({
|
export function WaveformTimeline({
|
||||||
userStream,
|
userStream,
|
||||||
@@ -84,6 +99,18 @@ export function WaveformTimeline({
|
|||||||
const historyRef = React.useRef<History>(makeHistory());
|
const historyRef = React.useRef<History>(makeHistory());
|
||||||
const activeRef = React.useRef(active);
|
const activeRef = React.useRef(active);
|
||||||
|
|
||||||
|
// 视窗状态:缩放档位用 state 驱动按钮 UI,平移量/跟随标志放 ref 供绘制帧读取
|
||||||
|
const [zoomIdx, setZoomIdx] = React.useState(0);
|
||||||
|
const [following, setFollowing] = React.useState(true);
|
||||||
|
const zoomIdxRef = React.useRef(zoomIdx);
|
||||||
|
const followingRef = React.useRef(following);
|
||||||
|
/** 距「最新样本」回看了多少毫秒,0 = 跟随直播边缘 */
|
||||||
|
const offsetMsRef = React.useRef(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
zoomIdxRef.current = zoomIdx;
|
||||||
|
}, [zoomIdx]);
|
||||||
|
|
||||||
// active 传 stream 是否存在,避免 useAudioAnalyser 在缺流时去申请麦克风
|
// active 传 stream 是否存在,避免 useAudioAnalyser 在缺流时去申请麦克风
|
||||||
const userAnalyserRef = useAudioAnalyser({
|
const userAnalyserRef = useAudioAnalyser({
|
||||||
active: active && Boolean(userStream),
|
active: active && Boolean(userStream),
|
||||||
@@ -96,13 +123,74 @@ export function WaveformTimeline({
|
|||||||
smoothingTimeConstant: 0.5,
|
smoothingTimeConstant: 0.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 新会话开始时清空上一轮历史
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
activeRef.current = active;
|
activeRef.current = active;
|
||||||
if (active) {
|
|
||||||
historyRef.current = makeHistory();
|
|
||||||
}
|
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
// 上一帧的 active,绘制循环里用它检测「新会话开始」并清空历史
|
||||||
|
const wasActiveRef = React.useRef(false);
|
||||||
|
|
||||||
|
/** 平移 deltaMs(正 = 回看更早);移动后按是否贴回右缘更新跟随态 */
|
||||||
|
const panBy = React.useCallback((deltaMs: number) => {
|
||||||
|
const next = Math.max(0, offsetMsRef.current + deltaMs);
|
||||||
|
offsetMsRef.current = next;
|
||||||
|
const follow = next <= 0;
|
||||||
|
followingRef.current = follow;
|
||||||
|
setFollowing(follow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const backToLive = React.useCallback(() => {
|
||||||
|
offsetMsRef.current = 0;
|
||||||
|
followingRef.current = true;
|
||||||
|
setFollowing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const zoomBy = React.useCallback((delta: number) => {
|
||||||
|
setZoomIdx((idx) =>
|
||||||
|
Math.min(ZOOM_LEVELS_MS_PER_COL.length - 1, Math.max(0, idx + delta)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 滚轮:平移;Ctrl/⌘+滚轮:缩放。需要 preventDefault,所以手动挂非 passive 监听
|
||||||
|
React.useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
zoomBy(e.deltaY > 0 ? 1 : -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msPerPx = ZOOM_LEVELS_MS_PER_COL[zoomIdxRef.current] / COL_STEP;
|
||||||
|
panBy(-(e.deltaX || e.deltaY) * msPerPx);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
return () => canvas.removeEventListener("wheel", onWheel);
|
||||||
|
}, [panBy, zoomBy]);
|
||||||
|
|
||||||
|
// 拖拽平移
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
let lastX = e.clientX;
|
||||||
|
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const dx = ev.clientX - lastX;
|
||||||
|
lastX = ev.clientX;
|
||||||
|
const msPerPx = ZOOM_LEVELS_MS_PER_COL[zoomIdxRef.current] / COL_STEP;
|
||||||
|
panBy(dx * msPerPx);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
canvas.removeEventListener("pointermove", onMove);
|
||||||
|
canvas.removeEventListener("pointerup", onUp);
|
||||||
|
canvas.removeEventListener("pointercancel", onUp);
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointermove", onMove);
|
||||||
|
canvas.addEventListener("pointerup", onUp);
|
||||||
|
canvas.addEventListener("pointercancel", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -130,6 +218,15 @@ export function WaveformTimeline({
|
|||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// 新会话开始:清空上一轮历史并恢复跟随
|
||||||
|
if (activeRef.current && !wasActiveRef.current) {
|
||||||
|
historyRef.current = makeHistory();
|
||||||
|
offsetMsRef.current = 0;
|
||||||
|
followingRef.current = true;
|
||||||
|
setFollowing(true);
|
||||||
|
}
|
||||||
|
wasActiveRef.current = activeRef.current;
|
||||||
|
|
||||||
// 采样:按固定节拍推入历史,帧率波动时补齐;长时间空窗(面板折叠)则跳过
|
// 采样:按固定节拍推入历史,帧率波动时补齐;长时间空窗(面板折叠)则跳过
|
||||||
const hist = historyRef.current;
|
const hist = historyRef.current;
|
||||||
if (activeRef.current) {
|
if (activeRef.current) {
|
||||||
@@ -151,23 +248,41 @@ export function WaveformTimeline({
|
|||||||
const textColor = getComputedStyle(canvas).color;
|
const textColor = getComputedStyle(canvas).color;
|
||||||
const rowH = (h - AXIS_HEIGHT) / 2;
|
const rowH = (h - AXIS_HEIGHT) / 2;
|
||||||
const n = hist.user.length;
|
const n = hist.user.length;
|
||||||
const ticksEvery = TICK_MS / SAMPLE_MS;
|
|
||||||
|
// 视窗换算:右缘时间 = 最新时间 - 回看偏移,可见范围由缩放决定
|
||||||
|
const msPerCol = ZOOM_LEVELS_MS_PER_COL[zoomIdxRef.current];
|
||||||
|
const plotW = w - LABEL_GUTTER;
|
||||||
|
const startMs = hist.dropped * SAMPLE_MS; // 仍保留的最旧样本时间
|
||||||
|
const totalMs = (hist.dropped + n) * SAMPLE_MS; // 最新样本时间
|
||||||
|
const visibleMs = (plotW / COL_STEP) * msPerCol;
|
||||||
|
const maxOffset = Math.max(0, totalMs - startMs - visibleMs);
|
||||||
|
if (followingRef.current) offsetMsRef.current = 0;
|
||||||
|
else offsetMsRef.current = Math.min(offsetMsRef.current, maxOffset);
|
||||||
|
const rightMs = totalMs - offsetMsRef.current;
|
||||||
|
|
||||||
ctx.font = '10px "Inter", system-ui, sans-serif';
|
ctx.font = '10px "Inter", system-ui, sans-serif';
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
// 时间刻度:竖向网格线 + 顶部 m:ss 标签
|
// 时间刻度:挑一档画出来不至于过密的间隔
|
||||||
|
const tickMs =
|
||||||
|
TICK_STEPS_MS.find((s) => (s / msPerCol) * COL_STEP >= 56) ??
|
||||||
|
TICK_STEPS_MS[TICK_STEPS_MS.length - 1];
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
for (let i = 0; i < n; i++) {
|
const leftMs = rightMs - visibleMs;
|
||||||
const sampleIndex = hist.dropped + i;
|
for (
|
||||||
if (sampleIndex % ticksEvery !== 0) continue;
|
let t = Math.ceil(Math.max(leftMs, 0) / tickMs) * tickMs;
|
||||||
const x = w - (n - i) * BAR_STEP;
|
t <= rightMs;
|
||||||
if (x < 0) continue;
|
t += tickMs
|
||||||
|
) {
|
||||||
|
const x = w - ((rightMs - t) / msPerCol) * COL_STEP;
|
||||||
|
if (x < LABEL_GUTTER + 1) continue;
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
ctx.globalAlpha = 0.12;
|
ctx.globalAlpha = 0.12;
|
||||||
ctx.fillRect(x, AXIS_HEIGHT, 1, h - AXIS_HEIGHT);
|
ctx.fillRect(x, AXIS_HEIGHT, 1, h - AXIS_HEIGHT);
|
||||||
ctx.globalAlpha = 0.75;
|
if (x >= LABEL_GUTTER + 14 && x <= w - 14) {
|
||||||
ctx.fillText(formatTick(sampleIndex * SAMPLE_MS), Math.max(14, x), AXIS_HEIGHT / 2);
|
ctx.globalAlpha = 0.75;
|
||||||
|
ctx.fillText(formatTick(t), x, AXIS_HEIGHT / 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -178,22 +293,31 @@ export function WaveformTimeline({
|
|||||||
rows.forEach((row, r) => {
|
rows.forEach((row, r) => {
|
||||||
const cy = AXIS_HEIGHT + rowH * r + rowH / 2;
|
const cy = AXIS_HEIGHT + rowH * r + rowH / 2;
|
||||||
|
|
||||||
// 中线
|
// 中线(只画在绘图区)
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.fillStyle = rgba(row.color, 0.28);
|
ctx.fillStyle = rgba(row.color, 0.28);
|
||||||
ctx.fillRect(0, cy - 0.5, w, 1);
|
ctx.fillRect(LABEL_GUTTER, cy - 0.5, plotW, 1);
|
||||||
|
|
||||||
// 音量条:最新样本贴右缘,向左回溯到画布边界为止
|
// 音量条:每列聚合 [t-msPerCol, t) 内样本的峰值,从右缘往左铺
|
||||||
ctx.fillStyle = rgba(row.color, 0.9);
|
ctx.fillStyle = rgba(row.color, 0.9);
|
||||||
const maxBarH = rowH * 0.86;
|
const maxBarH = rowH * 0.86;
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
for (let c = 0; ; c++) {
|
||||||
const x = w - (n - i) * BAR_STEP;
|
const x = w - (c + 1) * COL_STEP;
|
||||||
if (x + BAR_WIDTH < 0) break;
|
if (x + COL_WIDTH <= LABEL_GUTTER) break;
|
||||||
const bh = Math.max(1.5, row.levels[i] * maxBarH);
|
const t1 = rightMs - c * msPerCol;
|
||||||
ctx.fillRect(x, cy - bh / 2, BAR_WIDTH, bh);
|
const t0 = t1 - msPerCol;
|
||||||
|
const i0 = Math.max(0, Math.ceil((t0 - startMs) / SAMPLE_MS));
|
||||||
|
const i1 = Math.min(n - 1, Math.ceil((t1 - startMs) / SAMPLE_MS) - 1);
|
||||||
|
if (i1 < 0 || i0 > n - 1 || i0 > i1) continue;
|
||||||
|
let level = 0;
|
||||||
|
for (let i = i0; i <= i1; i++) {
|
||||||
|
if (row.levels[i] > level) level = row.levels[i];
|
||||||
|
}
|
||||||
|
const bh = Math.max(1.5, level * maxBarH);
|
||||||
|
ctx.fillRect(x, cy - bh / 2, COL_WIDTH, bh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 轨道标签
|
// 轨道标签:画在左侧 gutter 内,不与波形重叠
|
||||||
ctx.globalAlpha = 0.85;
|
ctx.globalAlpha = 0.85;
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
@@ -201,6 +325,10 @@ export function WaveformTimeline({
|
|||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// gutter 与绘图区的分隔线
|
||||||
|
ctx.globalAlpha = 0.15;
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillRect(LABEL_GUTTER - 1, AXIS_HEIGHT, 1, h - AXIS_HEIGHT);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,12 +337,64 @@ export function WaveformTimeline({
|
|||||||
}, [userAnalyserRef, agentAnalyserRef]);
|
}, [userAnalyserRef, agentAnalyserRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<div className={cn("relative", className)}>
|
||||||
ref={canvasRef}
|
<canvas
|
||||||
role="img"
|
ref={canvasRef}
|
||||||
aria-label="用户与助手语音波形时间轴"
|
role="img"
|
||||||
className={cn("block select-none text-muted-foreground", className)}
|
aria-label="用户与助手语音波形时间轴"
|
||||||
/>
|
onPointerDown={onPointerDown}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
className="block h-full w-full cursor-grab select-none text-muted-foreground active:cursor-grabbing"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 浮动控制:回到最新 / 缩放 */}
|
||||||
|
<div className="absolute right-1 top-0 flex items-center gap-1">
|
||||||
|
{!following && (
|
||||||
|
<TimelineControlButton label="回到最新" onClick={backToLive}>
|
||||||
|
<ChevronsRight size={12} />
|
||||||
|
</TimelineControlButton>
|
||||||
|
)}
|
||||||
|
<TimelineControlButton
|
||||||
|
label="缩小(查看更长时间)"
|
||||||
|
disabled={zoomIdx >= ZOOM_LEVELS_MS_PER_COL.length - 1}
|
||||||
|
onClick={() => zoomBy(1)}
|
||||||
|
>
|
||||||
|
<ZoomOut size={12} />
|
||||||
|
</TimelineControlButton>
|
||||||
|
<TimelineControlButton
|
||||||
|
label="放大(查看更多细节)"
|
||||||
|
disabled={zoomIdx <= 0}
|
||||||
|
onClick={() => zoomBy(-1)}
|
||||||
|
>
|
||||||
|
<ZoomIn size={12} />
|
||||||
|
</TimelineControlButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineControlButton({
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,56 +9,6 @@ export const API_BASE =
|
|||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
|
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
|
||||||
export type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini";
|
|
||||||
|
|
||||||
/** 列表/详情返回(api_key 已打码) */
|
|
||||||
export type Credential = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
modelId: string;
|
|
||||||
type: ModelType;
|
|
||||||
interfaceType: InterfaceType;
|
|
||||||
apiUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
voice: string;
|
|
||||||
speed: number;
|
|
||||||
language: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 创建/更新入参。apiKey 留空或打码值 → 后端保留旧 key */
|
|
||||||
export type CredentialUpsert = {
|
|
||||||
name: string;
|
|
||||||
modelId: string;
|
|
||||||
type: ModelType;
|
|
||||||
interfaceType: InterfaceType;
|
|
||||||
apiUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
voice: string;
|
|
||||||
speed: number;
|
|
||||||
language: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CredentialTestRequest = Pick<
|
|
||||||
CredentialUpsert,
|
|
||||||
| "modelId"
|
|
||||||
| "type"
|
|
||||||
| "interfaceType"
|
|
||||||
| "apiUrl"
|
|
||||||
| "apiKey"
|
|
||||||
| "voice"
|
|
||||||
| "speed"
|
|
||||||
| "language"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type CredentialTestResult = {
|
|
||||||
ok: boolean;
|
|
||||||
latencyMs: number | null;
|
|
||||||
message: string;
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -79,31 +29,85 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return (text ? JSON.parse(text) : undefined) as T;
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const credentialsApi = {
|
// ---------- 接口定义驱动的模型注册表 ----------
|
||||||
list: () => request<Credential[]>("/api/credentials"),
|
export type InterfaceField = {
|
||||||
create: (body: CredentialUpsert) =>
|
key: string;
|
||||||
request<Credential>("/api/credentials", {
|
label: string;
|
||||||
|
group: "values" | "secrets";
|
||||||
|
type: "text" | "url" | "password" | "number" | "boolean" | "select";
|
||||||
|
required: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
options?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InterfaceDefinition = {
|
||||||
|
interfaceType: string;
|
||||||
|
name: string;
|
||||||
|
capability: ModelType;
|
||||||
|
fieldSchema: { fields: InterfaceField[] };
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelResource = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
capability: ModelType;
|
||||||
|
interfaceType: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
secrets: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelResourceUpsert = Omit<
|
||||||
|
ModelResource,
|
||||||
|
"id" | "capability" | "updatedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ModelResourceTestResult = {
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs: number | null;
|
||||||
|
message: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const interfaceDefinitionsApi = {
|
||||||
|
list: (capability?: ModelType) =>
|
||||||
|
request<InterfaceDefinition[]>(
|
||||||
|
`/api/interface-definitions${capability ? `?capability=${capability}` : ""}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modelResourcesApi = {
|
||||||
|
list: () => request<ModelResource[]>("/api/model-resources"),
|
||||||
|
create: (body: ModelResourceUpsert) =>
|
||||||
|
request<ModelResource>("/api/model-resources", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
update: (id: string, body: CredentialUpsert) =>
|
update: (id: string, body: ModelResourceUpsert) =>
|
||||||
request<Credential>(`/api/credentials/${id}`, {
|
request<ModelResource>(`/api/model-resources/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
test: (body: CredentialTestRequest, id?: string) =>
|
test: (body: ModelResourceUpsert, id?: string) =>
|
||||||
request<CredentialTestResult>(
|
request<ModelResourceTestResult>(
|
||||||
id ? `/api/credentials/${id}/test` : "/api/credentials/test",
|
id ? `/api/model-resources/${id}/test` : "/api/model-resources/test",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// 服务端整行复制(含真 key,密钥不经浏览器)
|
|
||||||
duplicate: (id: string) =>
|
duplicate: (id: string) =>
|
||||||
request<Credential>(`/api/credentials/${id}/duplicate`, { method: "POST" }),
|
request<ModelResource>(`/api/model-resources/${id}/duplicate`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
request<{ ok: boolean }>(`/api/credentials/${id}`, { method: "DELETE" }),
|
request<{ ok: boolean }>(`/api/model-resources/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- 助手 ----------
|
// ---------- 助手 ----------
|
||||||
@@ -123,10 +127,7 @@ export type Assistant = {
|
|||||||
runtimeMode: RuntimeMode;
|
runtimeMode: RuntimeMode;
|
||||||
greeting: string;
|
greeting: string;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
llmCredentialId: string | null;
|
modelResourceIds: Partial<Record<ModelType, string>>;
|
||||||
asrCredentialId: string | null;
|
|
||||||
ttsCredentialId: string | null;
|
|
||||||
realtimeCredentialId: string | null;
|
|
||||||
knowledgeBaseId: string | null;
|
knowledgeBaseId: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
@@ -163,7 +164,7 @@ export type KnowledgeBase = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
embeddingCredentialId: string | null;
|
embeddingModelResourceId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user