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:
@@ -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())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ''"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
124
backend/services/credential_tester.py
Normal file
124
backend/services/credential_tester.py
Normal 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],
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user