Enhance credential management and testing functionality

- Introduce new fields for voice, speed, and language in the AssistantConfig and ProviderCredential models to support TTS and ASR configurations.
- Update the database schema and seeding script to accommodate the new fields, ensuring backward compatibility.
- Implement credential testing endpoints and logic to validate OpenAI-compatible credentials, enhancing user experience and reliability.
- Modify frontend components to include new fields in the credential forms and improve connection testing feedback.
- Refactor related services and API interactions to support the new credential testing feature.
This commit is contained in:
Xin Wang
2026-06-09 14:42:25 +08:00
parent 3661dab81c
commit c64b7dcf99
12 changed files with 443 additions and 28 deletions

View File

@@ -9,7 +9,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, func from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -29,6 +29,9 @@ class ProviderCredential(Base):
interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini
api_url: Mapped[str] = mapped_column(String(512), default="") api_url: Mapped[str] = mapped_column(String(512), default="")
api_key: Mapped[str] = mapped_column(String(512), default="") # 明文 api_key: Mapped[str] = mapped_column(String(512), default="") # 明文
voice: Mapped[str] = mapped_column(String(128), default="") # TTS 音色
speed: Mapped[float] = mapped_column(Float, default=1.0) # TTS 语速
language: Mapped[str] = mapped_column(String(32), default="") # ASR 语言
# 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选) # 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选)
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())

View File

@@ -4,23 +4,35 @@
-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_credentials.sql -- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_credentials.sql
-- --
-- 说明: -- 说明:
-- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等,可重复执行不重复插入 -- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等更新
-- * api_key 在库里是明文(读取走 API 时才打码),这里填的是占位示例 key。 -- * api_key 在库里是明文(读取走 API 时才打码),这里填的是占位示例 key。
-- * 每种 type 选第一条置为默认(is_default),供后端 config_resolver 解析使用。 -- * 每种 type 选第一条置为默认(is_default),供后端 config_resolver 解析使用。
-- * TTS 使用 voice/speed;ASR 使用 language;其他类型保持空值/default。
INSERT INTO provider_credentials INSERT INTO provider_credentials
(id, name, model_id, type, interface_type, api_url, api_key, is_default) (id, name, model_id, type, interface_type, api_url, api_key, voice, speed, language, is_default)
VALUES VALUES
('model_001', 'DeepSeek-Chat', 'deepseek-chat', 'LLM', 'openai', 'https://api.deepseek.com/v1', 'sk-230701ff1b6143ecbf322b3170606016', TRUE), ('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', FALSE), ('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', TRUE) ('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', FALSE), ('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', 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', 'xf-asr-9b1c3d5e7a', TRUE), ('model_006', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', 'xf-asr-9b1c3d5e7a', '', 1.0, 'zh', TRUE),
('model_007', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', FALSE), ('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', 'xf-tts-6c8a0b2d4f', TRUE), ('model_008', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', 'xf-tts-6c8a0b2d4f', 'xiaoyan', 1.0, '', TRUE),
('model_009', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', FALSE), ('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', TRUE), ('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', FALSE), ('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', 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 NOTHING; ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
model_id = EXCLUDED.model_id,
type = EXCLUDED.type,
interface_type = EXCLUDED.interface_type,
api_url = EXCLUDED.api_url,
api_key = EXCLUDED.api_key,
voice = EXCLUDED.voice,
speed = EXCLUDED.speed,
language = EXCLUDED.language,
is_default = EXCLUDED.is_default,
updated_at = now();

View File

@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
import config import config
from db.models import Base from db.models import Base
from sqlalchemy import text
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
@@ -27,3 +28,22 @@ 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(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS voice VARCHAR(128) NOT NULL DEFAULT ''"
)
)
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS speed DOUBLE PRECISION NOT NULL DEFAULT 1.0"
)
)
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS language VARCHAR(32) NOT NULL DEFAULT ''"
)
)

View File

@@ -27,6 +27,8 @@ class AssistantConfig(BaseModel):
model: str = "" # LLM model: str = "" # LLM
asr: str = "" # STT asr: str = "" # STT
voice: str = "" # TTS 音色 voice: str = "" # TTS 音色
stt_language: str = ""
tts_speed: float = 1.0
realtimeModel: str = "" realtimeModel: str = ""
enableInterrupt: bool = True enableInterrupt: bool = True

View File

@@ -5,6 +5,7 @@
pipecat-ai[webrtc,silero,openai]~=0.0.60 pipecat-ai[webrtc,silero,openai]~=0.0.60
fastapi fastapi
httpx
uvicorn[standard] uvicorn[standard]
python-dotenv python-dotenv
pydantic pydantic

View File

@@ -9,7 +9,13 @@ import uuid
from db.models import ProviderCredential from db.models import ProviderCredential
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 CredentialOut, CredentialUpsert from schemas import (
CredentialOut,
CredentialTestRequest,
CredentialTestResult,
CredentialUpsert,
)
from services.credential_tester import test_openai_credential
from services.masking import mask, resolve_incoming_key from services.masking import mask, resolve_incoming_key
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +32,9 @@ def _to_out(c: ProviderCredential) -> CredentialOut:
interface_type=c.interface_type, interface_type=c.interface_type,
api_url=c.api_url, api_url=c.api_url,
api_key=mask(c.api_key), # 永远打码 api_key=mask(c.api_key), # 永远打码
voice=c.voice,
speed=c.speed,
language=c.language,
is_default=c.is_default, is_default=c.is_default,
) )
@@ -60,6 +69,9 @@ async def create_credential(
interface_type=body.interface_type, interface_type=body.interface_type,
api_url=body.api_url, api_url=body.api_url,
api_key=resolve_incoming_key(body.api_key, ""), api_key=resolve_incoming_key(body.api_key, ""),
voice=body.voice,
speed=body.speed,
language=body.language,
is_default=body.is_default, is_default=body.is_default,
) )
session.add(c) session.add(c)
@@ -70,6 +82,44 @@ async def create_credential(
return _to_out(c) return _to_out(c)
@router.post("/test", response_model=CredentialTestResult)
async def test_new_credential(body: CredentialTestRequest):
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 != "openai":
return CredentialTestResult(
ok=False,
message="暂不支持该接口类型",
detail="当前仅支持 OpenAI 兼容接口测试",
)
return await test_openai_credential(config)
@router.post("/{cred_id}/duplicate", response_model=CredentialOut) @router.post("/{cred_id}/duplicate", response_model=CredentialOut)
async def duplicate_credential( async def duplicate_credential(
cred_id: str, session: AsyncSession = Depends(get_session) cred_id: str, session: AsyncSession = Depends(get_session)
@@ -86,6 +136,9 @@ async def duplicate_credential(
interface_type=src.interface_type, interface_type=src.interface_type,
api_url=src.api_url, api_url=src.api_url,
api_key=src.api_key, # 真 key,DB→DB api_key=src.api_key, # 真 key,DB→DB
voice=src.voice,
speed=src.speed,
language=src.language,
is_default=False, # 副本不继承默认,避免抢走源的默认标记 is_default=False, # 副本不继承默认,避免抢走源的默认标记
) )
session.add(c) session.add(c)
@@ -108,6 +161,9 @@ async def update_credential(
c.type = body.type c.type = body.type
c.interface_type = body.interface_type c.interface_type = body.interface_type
c.api_url = body.api_url c.api_url = body.api_url
c.voice = body.voice
c.speed = body.speed
c.language = body.language
c.is_default = body.is_default c.is_default = body.is_default
# 写时哨兵:打码占位符 → 保留旧 key # 写时哨兵:打码占位符 → 保留旧 key
c.api_key = resolve_incoming_key(body.api_key, c.api_key) c.api_key = resolve_incoming_key(body.api_key, c.api_key)

View File

@@ -101,8 +101,20 @@ class CredentialUpsert(CamelModel):
interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini
api_url: str = "" api_url: str = ""
api_key: str = "" # 写时:占位符/空表示不改 api_key: str = "" # 写时:占位符/空表示不改
voice: str = "" # TTS
speed: float = 1.0 # TTS
language: str = "" # ASR
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 CredentialOut(CamelModel): class CredentialOut(CamelModel):
id: str id: str
@@ -112,4 +124,25 @@ class CredentialOut(CamelModel):
interface_type: str interface_type: str
api_url: str api_url: str
api_key: str # 读时:打码后的值 api_key: str # 读时:打码后的值
voice: str
speed: float
language: str
is_default: bool is_default: bool
class CredentialTestRequest(CamelModel):
model_id: str
type: ModelType
interface_type: InterfaceType = "openai"
api_url: str
api_key: str = ""
voice: str = ""
speed: float = 1.0
language: str = ""
class CredentialTestResult(CamelModel):
ok: bool
latency_ms: int | None = None
message: str
detail: str = ""

View File

@@ -58,7 +58,9 @@ async def resolve_runtime_config(
# 模型/音色:凭证的模型ID优先 # 模型/音色:凭证的模型ID优先
model=(llm.model_id if llm else ""), model=(llm.model_id if llm else ""),
asr=(stt.model_id if stt else ""), asr=(stt.model_id if stt else ""),
voice="", # 音色无独立列,留空 → service_factory 回退 .env TTS_VOICE voice=(tts.voice if tts else ""),
stt_language=(stt.language if stt else ""),
tts_speed=(tts.speed if tts else 1.0),
realtimeModel=(realtime.model_id if realtime else ""), realtimeModel=(realtime.model_id if realtime else ""),
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底 # 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY), llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),

View File

@@ -0,0 +1,124 @@
"""OpenAI 兼容模型凭证的最小连通测试。"""
from __future__ import annotations
import io
import time
import wave
import httpx
from schemas import CredentialTestRequest, CredentialTestResult
TEST_TIMEOUT_SECONDS = 10.0
def _endpoint(base_url: str, path: str) -> str:
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
def _silent_wav() -> bytes:
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16_000)
wav.writeframes(b"\x00\x00" * 1_600)
return buffer.getvalue()
def _error_detail(response: httpx.Response, api_key: str) -> str:
try:
body = response.json()
detail = (
body.get("error", {}).get("message")
if isinstance(body, dict) and isinstance(body.get("error"), dict)
else body.get("detail") if isinstance(body, dict) else None
)
except ValueError:
detail = None
text = str(detail or response.text or response.reason_phrase).strip()
return text.replace(api_key, "***")[:300]
async def test_openai_credential(
config: CredentialTestRequest,
) -> CredentialTestResult:
started = time.perf_counter()
headers = {"Authorization": f"Bearer {config.api_key}"}
try:
async with httpx.AsyncClient(timeout=TEST_TIMEOUT_SECONDS) as client:
if config.type == "LLM":
response = await client.post(
_endpoint(config.api_url, "chat/completions"),
headers=headers,
json={
"model": config.model_id,
"messages": [{"role": "user", "content": "Reply with OK."}],
"max_tokens": 1,
"stream": False,
},
)
elif config.type == "Embedding":
response = await client.post(
_endpoint(config.api_url, "embeddings"),
headers=headers,
json={"model": config.model_id, "input": "ping"},
)
elif config.type == "ASR":
response = await client.post(
_endpoint(config.api_url, "audio/transcriptions"),
headers=headers,
data={
"model": config.model_id,
**({"language": config.language} if config.language else {}),
},
files={"file": ("test.wav", _silent_wav(), "audio/wav")},
)
elif config.type == "TTS":
response = await client.post(
_endpoint(config.api_url, "audio/speech"),
headers=headers,
json={
"model": config.model_id,
"input": "测试",
"voice": config.voice,
"speed": config.speed,
},
)
else:
return CredentialTestResult(
ok=False,
message="暂不支持该资源类型的连通测试",
detail=f"当前仅支持 LLM、Embedding、ASR、TTS收到 {config.type}",
)
latency_ms = round((time.perf_counter() - started) * 1000)
if response.is_success:
return CredentialTestResult(
ok=True,
latency_ms=latency_ms,
message="连接成功",
detail=f"OpenAI 兼容接口响应正常HTTP {response.status_code}",
)
return CredentialTestResult(
ok=False,
latency_ms=latency_ms,
message=f"连接失败HTTP {response.status_code}",
detail=_error_detail(response, config.api_key),
)
except httpx.TimeoutException:
return CredentialTestResult(
ok=False,
latency_ms=round((time.perf_counter() - started) * 1000),
message="连接超时",
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应",
)
except httpx.RequestError as exc:
return CredentialTestResult(
ok=False,
latency_ms=round((time.perf_counter() - started) * 1000),
message="无法连接到模型服务",
detail=str(exc)[:300],
)

View File

@@ -11,6 +11,17 @@ from models import AssistantConfig
from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.stt import OpenAISTTService from pipecat.services.openai.stt import OpenAISTTService
from pipecat.services.openai.tts import OpenAITTSService from pipecat.services.openai.tts import OpenAITTSService
from pipecat.transcriptions.language import Language
def _language(value: str) -> Language | None:
if not value:
return None
try:
return Language(value)
except ValueError:
logger.warning(f"忽略不支持的 ASR language: {value}")
return None
def create_stt(cfg: AssistantConfig): def create_stt(cfg: AssistantConfig):
@@ -22,6 +33,7 @@ def create_stt(cfg: AssistantConfig):
api_key=cfg.stt_api_key or config.STT_API_KEY, api_key=cfg.stt_api_key or config.STT_API_KEY,
base_url=cfg.stt_base_url or config.STT_BASE_URL, base_url=cfg.stt_base_url or config.STT_BASE_URL,
model=cfg.asr or config.STT_MODEL, model=cfg.asr or config.STT_MODEL,
language=_language(cfg.stt_language),
) )
@@ -41,6 +53,7 @@ def create_tts(cfg: AssistantConfig):
base_url=cfg.tts_base_url or config.TTS_BASE_URL, base_url=cfg.tts_base_url or config.TTS_BASE_URL,
model=config.TTS_MODEL, model=config.TTS_MODEL,
voice=cfg.voice or config.TTS_VOICE, voice=cfg.voice or config.TTS_VOICE,
speed=cfg.tts_speed,
) )

View File

@@ -77,6 +77,9 @@ type ModelForm = {
interfaceType: InterfaceType; interfaceType: InterfaceType;
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
voice: string;
speed: number;
language: string;
}; };
const emptyForm: ModelForm = { const emptyForm: ModelForm = {
@@ -86,6 +89,9 @@ const emptyForm: ModelForm = {
interfaceType: "openai", interfaceType: "openai",
apiUrl: "", apiUrl: "",
apiKey: "", apiKey: "",
voice: "",
speed: 1,
language: "",
}; };
type TypeFilter = "全部" | ModelType; type TypeFilter = "全部" | ModelType;
@@ -114,6 +120,7 @@ export function ComponentsModelsPage() {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle"); const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
const [testMessage, setTestMessage] = useState("");
const loadModels = useCallback(async () => { const loadModels = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -137,20 +144,40 @@ export function ComponentsModelsPage() {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态 // 任何配置变更后,旧的测试结果不再可信,重置为待测状态
setTestResult("idle"); setTestResult("idle");
setTestMessage("");
} }
async function handleTestConnection() { async function handleTestConnection() {
setTesting(true); setTesting(true);
setTestResult("idle"); setTestResult("idle");
setTestMessage("");
try { try {
// TODO: 接入真实疎通接口(按 form.interfaceType 区分调用方式) const result = await credentialsApi.test(
await new Promise((resolve) => setTimeout(resolve, 900)); {
const reachable = Boolean( modelId: form.modelId.trim(),
form.apiUrl.trim() && (form.apiKey.trim() || editingModel?.apiKey), type: form.type,
interfaceType: form.interfaceType,
apiUrl: form.apiUrl.trim(),
apiKey: form.apiKey,
voice: form.voice.trim(),
speed: form.speed,
language: form.language.trim(),
},
editingId ?? undefined,
); );
setTestResult(reachable ? "ok" : "fail"); setTestResult(result.ok ? "ok" : "fail");
} catch { setTestMessage(
[
result.message,
result.latencyMs !== null ? `${result.latencyMs}ms` : "",
result.detail,
]
.filter(Boolean)
.join(" · "),
);
} catch (error) {
setTestResult("fail"); setTestResult("fail");
setTestMessage(error instanceof Error ? error.message : "测试连接失败");
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -165,8 +192,12 @@ export function ComponentsModelsPage() {
interfaceType: options.includes(prev.interfaceType) interfaceType: options.includes(prev.interfaceType)
? prev.interfaceType ? prev.interfaceType
: options[0], : options[0],
voice: type === "TTS" ? prev.voice : "",
speed: type === "TTS" ? prev.speed : 1,
language: type === "ASR" ? prev.language : "",
})); }));
setTestResult("idle"); setTestResult("idle");
setTestMessage("");
} }
function openCreate() { function openCreate() {
@@ -175,6 +206,7 @@ export function ComponentsModelsPage() {
setForm(emptyForm); setForm(emptyForm);
setShowKey(false); setShowKey(false);
setTestResult("idle"); setTestResult("idle");
setTestMessage("");
setSaveError(null); setSaveError(null);
setDialogOpen(true); setDialogOpen(true);
} }
@@ -190,9 +222,13 @@ export function ComponentsModelsPage() {
apiUrl: model.apiUrl, apiUrl: model.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key // 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "", apiKey: "",
voice: model.voice,
speed: model.speed,
language: model.language,
}); });
setShowKey(false); setShowKey(false);
setTestResult("idle"); setTestResult("idle");
setTestMessage("");
setSaveError(null); setSaveError(null);
setDialogOpen(true); setDialogOpen(true);
} }
@@ -207,6 +243,9 @@ export function ComponentsModelsPage() {
interfaceType: form.interfaceType, interfaceType: form.interfaceType,
apiUrl: form.apiUrl.trim(), apiUrl: form.apiUrl.trim(),
apiKey: form.apiKey, apiKey: form.apiKey,
voice: form.voice.trim(),
speed: form.speed,
language: form.language.trim(),
// 表单未暴露 isDefault,编辑时沿用原值,新建默认 false // 表单未暴露 isDefault,编辑时沿用原值,新建默认 false
isDefault: editingModel?.isDefault ?? false, isDefault: editingModel?.isDefault ?? false,
}; };
@@ -285,8 +324,19 @@ export function ComponentsModelsPage() {
const interfaceOptions = interfaceOptionsByType[form.type]; const interfaceOptions = interfaceOptionsByType[form.type];
const hasStoredApiKey = Boolean(editingId && editingModel?.apiKey); const hasStoredApiKey = Boolean(editingId && editingModel?.apiKey);
const hasRequiredTypeOptions =
form.type !== "TTS" || Boolean(form.voice.trim() && form.speed > 0);
const canSave = const canSave =
form.name.trim() && form.modelId.trim() && form.apiUrl.trim(); form.name.trim() &&
form.modelId.trim() &&
form.apiUrl.trim() &&
hasRequiredTypeOptions;
const canTest = Boolean(
form.modelId.trim() &&
form.apiUrl.trim() &&
hasRequiredTypeOptions &&
(form.apiKey.trim() || editingModel?.apiKey),
);
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">
@@ -685,6 +735,75 @@ export function ComponentsModelsPage() {
/> />
</div> </div>
{form.type === "TTS" && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="block">
<FieldLabel
htmlFor="model-voice"
hint={{
description:
"调用语音合成接口时使用的音色标识,需与服务商支持的 voice 一致。",
example: "alloy、中文女",
}}
>
Voice
</FieldLabel>
<Input
id="model-voice"
value={form.voice}
onChange={(event) => updateForm("voice", event.target.value)}
placeholder="例如 alloy"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
<div className="block">
<FieldLabel
htmlFor="model-speed"
hint={{
description: "语音合成的播放速度1 表示正常速度。",
example: "0.8、1、1.2",
}}
>
Speed
</FieldLabel>
<Input
id="model-speed"
type="number"
min="0.25"
max="4"
step="0.1"
value={form.speed}
onChange={(event) =>
updateForm("speed", Number(event.target.value) || 1)
}
className="border-hairline-strong bg-background text-foreground"
/>
</div>
</div>
)}
{form.type === "ASR" && (
<div className="block">
<FieldLabel
htmlFor="model-language"
hint={{
description:
"语音识别的语言代码,留空时由模型自动判断或使用服务商默认值。",
example: "zh、en",
}}
>
Language
</FieldLabel>
<Input
id="model-language"
value={form.language}
onChange={(event) => updateForm("language", event.target.value)}
placeholder="例如 zh留空则自动识别"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
)}
<div className="block"> <div className="block">
<FieldLabel <FieldLabel
htmlFor="model-api-key" htmlFor="model-api-key"
@@ -742,7 +861,7 @@ export function ComponentsModelsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground" className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={!canSave || testing} disabled={!canTest || testing}
onClick={handleTestConnection} onClick={handleTestConnection}
> >
{testing ? ( {testing ? (
@@ -755,13 +874,13 @@ export function ComponentsModelsPage() {
{testResult === "ok" && ( {testResult === "ok" && (
<span className="flex items-center gap-1.5 text-xs text-emerald-500"> <span className="flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 size={14} /> <CheckCircle2 size={14} />
{testMessage}
</span> </span>
)} )}
{testResult === "fail" && ( {testResult === "fail" && (
<span className="flex items-center gap-1.5 text-xs text-destructive"> <span className="flex items-center gap-1.5 text-xs text-destructive">
<XCircle size={14} /> <XCircle size={14} />
{testMessage}
</span> </span>
)} )}
{saveError && ( {saveError && (

View File

@@ -20,6 +20,9 @@ export type Credential = {
interfaceType: InterfaceType; interfaceType: InterfaceType;
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
voice: string;
speed: number;
language: string;
isDefault: boolean; isDefault: boolean;
}; };
@@ -34,6 +37,25 @@ export type CredentialUpsert = {
isDefault: boolean; 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" },
@@ -66,6 +88,14 @@ export const credentialsApi = {
method: "PUT", method: "PUT",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
test: (body: CredentialTestRequest, id?: string) =>
request<CredentialTestResult>(
id ? `/api/credentials/${id}/test` : "/api/credentials/test",
{
method: "POST",
body: JSON.stringify(body),
},
),
// 服务端整行复制(含真 key,密钥不经浏览器) // 服务端整行复制(含真 key,密钥不经浏览器)
duplicate: (id: string) => duplicate: (id: string) =>
request<Credential>(`/api/credentials/${id}/duplicate`, { method: "POST" }), request<Credential>(`/api/credentials/${id}/duplicate`, { method: "POST" }),