diff --git a/backend/db/models.py b/backend/db/models.py index 746016c..a9a7f57 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -9,7 +9,7 @@ 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 @@ -29,6 +29,9 @@ class ProviderCredential(Base): interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini api_url: Mapped[str] = mapped_column(String(512), default="") api_key: Mapped[str] = mapped_column(String(512), default="") # 明文 + voice: Mapped[str] = mapped_column(String(128), default="") # TTS 音色 + speed: Mapped[float] = mapped_column(Float, default=1.0) # TTS 语速 + language: Mapped[str] = mapped_column(String(32), default="") # ASR 语言 # 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选) is_default: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/db/seed_credentials.sql b/backend/db/seed_credentials.sql index 26e74fd..acc9949 100644 --- a/backend/db/seed_credentials.sql +++ b/backend/db/seed_credentials.sql @@ -4,23 +4,35 @@ -- 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。 -- * 每种 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, is_default) + (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', TRUE), - ('model_002', 'SiliconFlow-TeleSpeechASR', 'TeleAI/TeleSpeechASR', 'ASR', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', FALSE), - ('model_003', 'SiliconFlow-Qwen3-Embedding-4B', 'Qwen/Qwen3-Embedding-4B', 'Embedding', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', TRUE) - ('model_004', 'SiliconFlow-CosyVoice2-0.5B', 'FunAudioLLM/CosyVoice2-0.5B', 'TTS', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', FALSE), - ('model_005', 'Qwen-Max', 'qwen-max', 'LLM', 'openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-qwen-4d8e2a6f0c', FALSE), - ('model_006', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', 'xf-asr-9b1c3d5e7a', TRUE), - ('model_007', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', FALSE), - ('model_008', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', 'xf-tts-6c8a0b2d4f', TRUE), - ('model_009', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', FALSE), - ('model_010', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', TRUE), - ('model_011', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', FALSE), - ('model_012', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', FALSE), -ON CONFLICT (id) DO NOTHING; + ('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', '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', '', 1.0, 'zh', FALSE), + ('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', 'longxiaochun', 1.0, '', FALSE), + ('model_010', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', '', 1.0, '', TRUE), + ('model_011', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', '', 1.0, '', FALSE), + ('model_012', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', '', 1.0, '', FALSE) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + model_id = EXCLUDED.model_id, + type = EXCLUDED.type, + interface_type = EXCLUDED.interface_type, + api_url = EXCLUDED.api_url, + api_key = EXCLUDED.api_key, + voice = EXCLUDED.voice, + speed = EXCLUDED.speed, + language = EXCLUDED.language, + is_default = EXCLUDED.is_default, + updated_at = now(); diff --git a/backend/db/session.py b/backend/db/session.py index 9311336..6e7bb20 100644 --- a/backend/db/session.py +++ b/backend/db/session.py @@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator import config from db.models import Base +from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -27,3 +28,22 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: async def init_db() -> None: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # MVP 兼容迁移:create_all 不会给已存在的表补列。 + await conn.execute( + text( + "ALTER TABLE provider_credentials " + "ADD COLUMN IF NOT EXISTS voice VARCHAR(128) NOT NULL DEFAULT ''" + ) + ) + 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 ''" + ) + ) diff --git a/backend/models.py b/backend/models.py index 35ecc3b..cca7462 100644 --- a/backend/models.py +++ b/backend/models.py @@ -27,6 +27,8 @@ class AssistantConfig(BaseModel): model: str = "" # LLM asr: str = "" # STT voice: str = "" # TTS 音色 + stt_language: str = "" + tts_speed: float = 1.0 realtimeModel: str = "" enableInterrupt: bool = True diff --git a/backend/requirements.txt b/backend/requirements.txt index 1937889..930d711 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ pipecat-ai[webrtc,silero,openai]~=0.0.60 fastapi +httpx uvicorn[standard] python-dotenv pydantic diff --git a/backend/routes/credentials.py b/backend/routes/credentials.py index 5c300b4..eeb6e79 100644 --- a/backend/routes/credentials.py +++ b/backend/routes/credentials.py @@ -9,7 +9,13 @@ import uuid from db.models import ProviderCredential from db.session import get_session 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 sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -26,6 +32,9 @@ def _to_out(c: ProviderCredential) -> CredentialOut: 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, ) @@ -60,6 +69,9 @@ async def create_credential( 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) @@ -70,6 +82,44 @@ async def create_credential( 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) async def duplicate_credential( cred_id: str, session: AsyncSession = Depends(get_session) @@ -86,6 +136,9 @@ async def duplicate_credential( 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) @@ -108,6 +161,9 @@ async def update_credential( 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) diff --git a/backend/schemas.py b/backend/schemas.py index d727c5f..587294e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -101,8 +101,20 @@ class CredentialUpsert(CamelModel): interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini api_url: str = "" api_key: str = "" # 写时:占位符/空表示不改 + voice: str = "" # TTS + speed: float = 1.0 # TTS + language: str = "" # ASR 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): id: str @@ -112,4 +124,25 @@ class CredentialOut(CamelModel): interface_type: str api_url: str api_key: str # 读时:打码后的值 + voice: str + speed: float + language: str 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 = "" diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index c769644..4314811 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -58,7 +58,9 @@ async def resolve_runtime_config( # 模型/音色:凭证的模型ID优先 model=(llm.model_id if llm 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 ""), # 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底 llm_api_key=(llm.api_key if llm else config.LLM_API_KEY), diff --git a/backend/services/credential_tester.py b/backend/services/credential_tester.py new file mode 100644 index 0000000..ccfe854 --- /dev/null +++ b/backend/services/credential_tester.py @@ -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], + ) diff --git a/backend/services/pipecat/service_factory.py b/backend/services/pipecat/service_factory.py index ee7f73d..627efa0 100644 --- a/backend/services/pipecat/service_factory.py +++ b/backend/services/pipecat/service_factory.py @@ -11,6 +11,17 @@ from models import AssistantConfig from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.stt import OpenAISTTService 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): @@ -22,6 +33,7 @@ def create_stt(cfg: AssistantConfig): api_key=cfg.stt_api_key or config.STT_API_KEY, base_url=cfg.stt_base_url or config.STT_BASE_URL, 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, model=config.TTS_MODEL, voice=cfg.voice or config.TTS_VOICE, + speed=cfg.tts_speed, ) diff --git a/frontend/src/components/pages/ComponentsModelsPage.tsx b/frontend/src/components/pages/ComponentsModelsPage.tsx index 9deda1d..cd33657 100644 --- a/frontend/src/components/pages/ComponentsModelsPage.tsx +++ b/frontend/src/components/pages/ComponentsModelsPage.tsx @@ -77,6 +77,9 @@ type ModelForm = { interfaceType: InterfaceType; apiUrl: string; apiKey: string; + voice: string; + speed: number; + language: string; }; const emptyForm: ModelForm = { @@ -86,6 +89,9 @@ const emptyForm: ModelForm = { interfaceType: "openai", apiUrl: "", apiKey: "", + voice: "", + speed: 1, + language: "", }; type TypeFilter = "全部" | ModelType; @@ -114,6 +120,7 @@ export function ComponentsModelsPage() { const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle"); + const [testMessage, setTestMessage] = useState(""); const loadModels = useCallback(async () => { setLoading(true); @@ -137,20 +144,40 @@ export function ComponentsModelsPage() { setForm((prev) => ({ ...prev, [key]: value })); // 任何配置变更后,旧的测试结果不再可信,重置为待测状态 setTestResult("idle"); + setTestMessage(""); } async function handleTestConnection() { setTesting(true); setTestResult("idle"); + setTestMessage(""); try { - // TODO: 接入真实疎通接口(按 form.interfaceType 区分调用方式) - await new Promise((resolve) => setTimeout(resolve, 900)); - const reachable = Boolean( - form.apiUrl.trim() && (form.apiKey.trim() || editingModel?.apiKey), + const result = await credentialsApi.test( + { + modelId: form.modelId.trim(), + 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"); - } catch { + setTestResult(result.ok ? "ok" : "fail"); + setTestMessage( + [ + result.message, + result.latencyMs !== null ? `${result.latencyMs}ms` : "", + result.detail, + ] + .filter(Boolean) + .join(" · "), + ); + } catch (error) { setTestResult("fail"); + setTestMessage(error instanceof Error ? error.message : "测试连接失败"); } finally { setTesting(false); } @@ -165,8 +192,12 @@ export function ComponentsModelsPage() { interfaceType: options.includes(prev.interfaceType) ? prev.interfaceType : options[0], + voice: type === "TTS" ? prev.voice : "", + speed: type === "TTS" ? prev.speed : 1, + language: type === "ASR" ? prev.language : "", })); setTestResult("idle"); + setTestMessage(""); } function openCreate() { @@ -175,6 +206,7 @@ export function ComponentsModelsPage() { setForm(emptyForm); setShowKey(false); setTestResult("idle"); + setTestMessage(""); setSaveError(null); setDialogOpen(true); } @@ -190,9 +222,13 @@ export function ComponentsModelsPage() { apiUrl: model.apiUrl, // 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key apiKey: "", + voice: model.voice, + speed: model.speed, + language: model.language, }); setShowKey(false); setTestResult("idle"); + setTestMessage(""); setSaveError(null); setDialogOpen(true); } @@ -207,6 +243,9 @@ export function ComponentsModelsPage() { interfaceType: form.interfaceType, apiUrl: form.apiUrl.trim(), apiKey: form.apiKey, + voice: form.voice.trim(), + speed: form.speed, + language: form.language.trim(), // 表单未暴露 isDefault,编辑时沿用原值,新建默认 false isDefault: editingModel?.isDefault ?? false, }; @@ -285,8 +324,19 @@ export function ComponentsModelsPage() { const interfaceOptions = interfaceOptionsByType[form.type]; const hasStoredApiKey = Boolean(editingId && editingModel?.apiKey); + const hasRequiredTypeOptions = + form.type !== "TTS" || Boolean(form.voice.trim() && form.speed > 0); 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 (
@@ -685,6 +735,75 @@ export function ComponentsModelsPage() { />
+ {form.type === "TTS" && ( +
+
+ + Voice + + updateForm("voice", event.target.value)} + placeholder="例如 alloy" + className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" + /> +
+
+ + Speed + + + updateForm("speed", Number(event.target.value) || 1) + } + className="border-hairline-strong bg-background text-foreground" + /> +
+
+ )} + + {form.type === "ASR" && ( +
+ + Language + + updateForm("language", event.target.value)} + placeholder="例如 zh,留空则自动识别" + className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" + /> +
+ )} +
{testing ? ( @@ -755,13 +874,13 @@ export function ComponentsModelsPage() { {testResult === "ok" && ( - 连接成功 + {testMessage} )} {testResult === "fail" && ( - 连接失败 + {testMessage} )} {saveError && ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4247700..f77b762 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,6 +20,9 @@ export type Credential = { interfaceType: InterfaceType; apiUrl: string; apiKey: string; + voice: string; + speed: number; + language: string; isDefault: boolean; }; @@ -34,6 +37,25 @@ export type CredentialUpsert = { 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(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { headers: { "Content-Type": "application/json" }, @@ -66,6 +88,14 @@ export const credentialsApi = { method: "PUT", body: JSON.stringify(body), }), + test: (body: CredentialTestRequest, id?: string) => + request( + id ? `/api/credentials/${id}/test` : "/api/credentials/test", + { + method: "POST", + body: JSON.stringify(body), + }, + ), // 服务端整行复制(含真 key,密钥不经浏览器) duplicate: (id: string) => request(`/api/credentials/${id}/duplicate`, { method: "POST" }),