Update voice libary key form
This commit is contained in:
@@ -28,6 +28,8 @@ class Voice(Base):
|
||||
description: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
model: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # 厂商语音模型标识
|
||||
voice_key: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # 厂商voice_key
|
||||
api_key: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # 每个声音独立 API key
|
||||
base_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # 每个声音独立 OpenAI-compatible base_url
|
||||
speed: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
gain: Mapped[int] = mapped_column(Integer, default=0)
|
||||
pitch: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -38,17 +40,6 @@ class Voice(Base):
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
|
||||
class VendorCredential(Base):
|
||||
__tablename__ = "vendor_credentials"
|
||||
|
||||
vendor_key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
vendor_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
api_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
base_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
# ============ LLM Model ============
|
||||
class LLMModel(Base):
|
||||
__tablename__ = "llm_models"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -9,16 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import VendorCredential, Voice
|
||||
from ..schemas import (
|
||||
VendorCredentialOut,
|
||||
VendorCredentialUpsert,
|
||||
VoiceCreate,
|
||||
VoiceOut,
|
||||
VoicePreviewRequest,
|
||||
VoicePreviewResponse,
|
||||
VoiceUpdate,
|
||||
)
|
||||
from ..models import Voice
|
||||
from ..schemas import VoiceCreate, VoiceOut, VoicePreviewRequest, VoicePreviewResponse, VoiceUpdate
|
||||
|
||||
router = APIRouter(prefix="/voices", tags=["Voices"])
|
||||
|
||||
@@ -29,28 +20,10 @@ def _is_siliconflow_vendor(vendor: str) -> bool:
|
||||
return vendor.strip().lower() in {"siliconflow", "硅基流动"}
|
||||
|
||||
|
||||
def _canonical_vendor_key(vendor: str) -> str:
|
||||
normalized = vendor.strip().lower()
|
||||
alias_map = {
|
||||
"硅基流动": "siliconflow",
|
||||
"siliconflow": "siliconflow",
|
||||
"ali": "ali",
|
||||
"volcano": "volcano",
|
||||
"minimax": "minimax",
|
||||
}
|
||||
return alias_map.get(normalized, normalized)
|
||||
|
||||
|
||||
def _default_tts_base_url(vendor_key: str) -> Optional[str]:
|
||||
defaults = {
|
||||
"siliconflow": "https://api.siliconflow.cn/v1",
|
||||
}
|
||||
return defaults.get(vendor_key)
|
||||
|
||||
|
||||
def _resolve_vendor_credential(db: Session, vendor: str) -> Optional[VendorCredential]:
|
||||
vendor_key = _canonical_vendor_key(vendor)
|
||||
return db.query(VendorCredential).filter(VendorCredential.vendor_key == vendor_key).first()
|
||||
def _default_base_url(vendor: str) -> Optional[str]:
|
||||
if _is_siliconflow_vendor(vendor):
|
||||
return "https://api.siliconflow.cn/v1"
|
||||
return None
|
||||
|
||||
|
||||
def _build_siliconflow_voice_key(voice: Voice, model: str) -> str:
|
||||
@@ -108,6 +81,8 @@ def create_voice(data: VoiceCreate, db: Session = Depends(get_db)):
|
||||
description=data.description,
|
||||
model=model,
|
||||
voice_key=voice_key,
|
||||
api_key=data.api_key,
|
||||
base_url=data.base_url,
|
||||
speed=data.speed,
|
||||
gain=data.gain,
|
||||
pitch=data.pitch,
|
||||
@@ -165,56 +140,6 @@ def delete_voice(id: str, db: Session = Depends(get_db)):
|
||||
return {"message": "Deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/vendors/credentials")
|
||||
def list_vendor_credentials(db: Session = Depends(get_db)):
|
||||
items = db.query(VendorCredential).order_by(VendorCredential.updated_at.desc()).all()
|
||||
return {"list": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/vendors/credentials/{vendor_key}", response_model=VendorCredentialOut)
|
||||
def get_vendor_credential(vendor_key: str, db: Session = Depends(get_db)):
|
||||
key = _canonical_vendor_key(vendor_key)
|
||||
item = db.query(VendorCredential).filter(VendorCredential.vendor_key == key).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Vendor credential not found")
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/vendors/credentials/{vendor_key}", response_model=VendorCredentialOut)
|
||||
def upsert_vendor_credential(vendor_key: str, data: VendorCredentialUpsert, db: Session = Depends(get_db)):
|
||||
key = _canonical_vendor_key(vendor_key)
|
||||
item = db.query(VendorCredential).filter(VendorCredential.vendor_key == key).first()
|
||||
|
||||
if item:
|
||||
item.vendor_name = data.vendor_name or item.vendor_name
|
||||
item.api_key = data.api_key
|
||||
item.base_url = data.base_url
|
||||
item.updated_at = datetime.utcnow()
|
||||
else:
|
||||
item = VendorCredential(
|
||||
vendor_key=key,
|
||||
vendor_name=data.vendor_name or vendor_key,
|
||||
api_key=data.api_key,
|
||||
base_url=data.base_url,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/vendors/credentials/{vendor_key}")
|
||||
def delete_vendor_credential(vendor_key: str, db: Session = Depends(get_db)):
|
||||
key = _canonical_vendor_key(vendor_key)
|
||||
item = db.query(VendorCredential).filter(VendorCredential.vendor_key == key).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Vendor credential not found")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return {"message": "Deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{id}/preview", response_model=VoicePreviewResponse)
|
||||
def preview_voice(id: str, data: VoicePreviewRequest, db: Session = Depends(get_db)):
|
||||
"""试听指定声音,基于 OpenAI-compatible /audio/speech 接口。"""
|
||||
@@ -226,22 +151,17 @@ def preview_voice(id: str, data: VoicePreviewRequest, db: Session = Depends(get_
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Preview text cannot be empty")
|
||||
|
||||
credential = _resolve_vendor_credential(db, voice.vendor)
|
||||
api_key = (data.api_key or "").strip()
|
||||
if not api_key and credential:
|
||||
api_key = credential.api_key
|
||||
api_key = (data.api_key or "").strip() or (voice.api_key or "").strip()
|
||||
if not api_key and _is_siliconflow_vendor(voice.vendor):
|
||||
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
api_key = os.getenv("SILICONFLOW_API_KEY") if _is_siliconflow_vendor(voice.vendor) else ""
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail=f"Vendor API key is required for {voice.vendor}")
|
||||
raise HTTPException(status_code=400, detail=f"API key is required for voice: {voice.name}")
|
||||
|
||||
base_url = (voice.base_url or "").strip() or (_default_base_url(voice.vendor) or "")
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail=f"Base URL is required for voice: {voice.name}")
|
||||
|
||||
model = voice.model or SILICONFLOW_DEFAULT_MODEL
|
||||
vendor_key = _canonical_vendor_key(voice.vendor)
|
||||
base_url = (credential.base_url.strip() if credential and credential.base_url else "") or _default_tts_base_url(vendor_key)
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail=f"Vendor base_url is required for {voice.vendor}")
|
||||
tts_api_url = f"{base_url.rstrip('/')}/audio/speech"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": text,
|
||||
@@ -253,7 +173,7 @@ def preview_voice(id: str, data: VoicePreviewRequest, db: Session = Depends(get_
|
||||
try:
|
||||
with httpx.Client(timeout=45.0) as client:
|
||||
response = client.post(
|
||||
tts_api_url,
|
||||
f"{base_url.rstrip('/')}/audio/speech",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,8 @@ class VoiceCreate(VoiceBase):
|
||||
id: Optional[str] = None
|
||||
model: Optional[str] = None # 厂商语音模型标识
|
||||
voice_key: Optional[str] = None # 厂商voice_key
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
speed: float = 1.0
|
||||
gain: int = 0
|
||||
pitch: int = 0
|
||||
@@ -67,6 +69,8 @@ class VoiceUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
voice_key: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
speed: Optional[float] = None
|
||||
gain: Optional[int] = None
|
||||
pitch: Optional[int] = None
|
||||
@@ -78,6 +82,8 @@ class VoiceOut(VoiceBase):
|
||||
user_id: Optional[int] = None
|
||||
model: Optional[str] = None
|
||||
voice_key: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
speed: float = 1.0
|
||||
gain: int = 0
|
||||
pitch: int = 0
|
||||
@@ -104,24 +110,6 @@ class VoicePreviewResponse(BaseModel):
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class VendorCredentialUpsert(BaseModel):
|
||||
vendor_name: Optional[str] = None
|
||||
api_key: str
|
||||
base_url: Optional[str] = None
|
||||
|
||||
|
||||
class VendorCredentialOut(BaseModel):
|
||||
vendor_key: str
|
||||
vendor_name: str
|
||||
api_key: str
|
||||
base_url: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ LLM Model ============
|
||||
class LLMModelBase(BaseModel):
|
||||
name: str
|
||||
|
||||
@@ -180,11 +180,12 @@ class TestVoiceAPI:
|
||||
encoded = payload["audio_url"].split(",", 1)[1]
|
||||
assert base64.b64decode(encoded) == b"fake-mp3-bytes"
|
||||
|
||||
def test_vendor_credential_persist_and_preview_use_db_key(self, client, monkeypatch):
|
||||
"""Test vendor credential persisted in DB and used by preview endpoint"""
|
||||
def test_voice_credential_persist_and_preview_use_voice_key(self, client, monkeypatch):
|
||||
"""Test per-voice api_key/base_url persisted and used by preview endpoint"""
|
||||
from app.routers import voices as voice_router
|
||||
|
||||
captured_auth = {"value": ""}
|
||||
captured_url = {"value": ""}
|
||||
|
||||
class DummyResponse:
|
||||
status_code = 200
|
||||
@@ -207,22 +208,13 @@ class TestVoiceAPI:
|
||||
def post(self, *args, **kwargs):
|
||||
headers = kwargs.get("headers", {})
|
||||
captured_auth["value"] = headers.get("Authorization", "")
|
||||
if args:
|
||||
captured_url["value"] = args[0]
|
||||
return DummyResponse()
|
||||
|
||||
monkeypatch.delenv("SILICONFLOW_API_KEY", raising=False)
|
||||
monkeypatch.setattr(voice_router.httpx, "Client", DummyClient)
|
||||
|
||||
save_cred = client.put(
|
||||
"/api/voices/vendors/credentials/siliconflow",
|
||||
json={
|
||||
"vendor_name": "SiliconFlow",
|
||||
"api_key": "db-key-123",
|
||||
"base_url": "https://api.siliconflow.cn/v1"
|
||||
},
|
||||
)
|
||||
assert save_cred.status_code == 200
|
||||
assert save_cred.json()["vendor_key"] == "siliconflow"
|
||||
|
||||
create_resp = client.post("/api/voices", json={
|
||||
"id": "anna2",
|
||||
"name": "Anna 2",
|
||||
@@ -231,10 +223,13 @@ class TestVoiceAPI:
|
||||
"language": "zh",
|
||||
"description": "voice",
|
||||
"model": "FunAudioLLM/CosyVoice2-0.5B",
|
||||
"voice_key": "FunAudioLLM/CosyVoice2-0.5B:anna"
|
||||
"voice_key": "FunAudioLLM/CosyVoice2-0.5B:anna",
|
||||
"api_key": "voice-key-123",
|
||||
"base_url": "https://api.siliconflow.cn/v1"
|
||||
})
|
||||
assert create_resp.status_code == 200
|
||||
|
||||
preview_resp = client.post("/api/voices/anna2/preview", json={"text": "hello"})
|
||||
assert preview_resp.status_code == 200
|
||||
assert captured_auth["value"] == "Bearer db-key-123"
|
||||
assert captured_auth["value"] == "Bearer voice-key-123"
|
||||
assert captured_url["value"] == "https://api.siliconflow.cn/v1/audio/speech"
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Search, Mic2, Play, Pause, Upload, Filter, Plus, Volume2, Sparkles, ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
||||
import { VendorCredential, Voice } from '../types';
|
||||
import { createVoice, deleteVoice, fetchVendorCredentials, fetchVoices, previewVoice, saveVendorCredential, updateVoice } from '../services/backendApi';
|
||||
|
||||
const VENDOR_OPTIONS = [
|
||||
{ key: 'siliconflow', label: '硅基流动 (SiliconFlow)' },
|
||||
{ key: 'ali', label: 'Ali' },
|
||||
{ key: 'volcano', label: 'Volcano' },
|
||||
{ key: 'minimax', label: 'Minimax' },
|
||||
];
|
||||
import { Voice } from '../types';
|
||||
import { createVoice, deleteVoice, fetchVoices, previewVoice, updateVoice } from '../services/backendApi';
|
||||
|
||||
export const VoiceLibraryPage: React.FC = () => {
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
@@ -25,24 +17,13 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
const [editingVoice, setEditingVoice] = useState<Voice | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [playLoadingId, setPlayLoadingId] = useState<string | null>(null);
|
||||
const [vendorCredentials, setVendorCredentials] = useState<Record<string, VendorCredential>>({});
|
||||
const [credentialVendorKey, setCredentialVendorKey] = useState('siliconflow');
|
||||
const [credentialApiKey, setCredentialApiKey] = useState('');
|
||||
const [credentialBaseUrl, setCredentialBaseUrl] = useState('');
|
||||
const [isSavingCredential, setIsSavingCredential] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadVoicesAndCredentials = async () => {
|
||||
const loadVoices = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [list, credentials] = await Promise.all([fetchVoices(), fetchVendorCredentials()]);
|
||||
setVoices(list);
|
||||
const mapped = credentials.reduce((acc, item) => {
|
||||
acc[item.vendorKey] = item;
|
||||
return acc;
|
||||
}, {} as Record<string, VendorCredential>);
|
||||
setVendorCredentials(mapped);
|
||||
setVoices(await fetchVoices());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setVoices([]);
|
||||
@@ -50,17 +31,10 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVoicesAndCredentials();
|
||||
loadVoices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = vendorCredentials[credentialVendorKey];
|
||||
setCredentialApiKey(selected?.apiKey || '');
|
||||
setCredentialBaseUrl(selected?.baseUrl || '');
|
||||
}, [credentialVendorKey, vendorCredentials]);
|
||||
|
||||
const filteredVoices = voices.filter(voice => {
|
||||
const filteredVoices = voices.filter((voice) => {
|
||||
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesVendor = vendorFilter === 'all' || voice.vendor === vendorFilter;
|
||||
const matchesGender = genderFilter === 'all' || voice.gender === genderFilter;
|
||||
@@ -75,6 +49,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
setPlayingVoiceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPlayLoadingId(voice.id);
|
||||
const audioUrl = await previewVoice(
|
||||
@@ -82,15 +57,18 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
voice.language === 'en' ? 'Hello, this is a voice preview.' : '你好,这是一段语音试听。',
|
||||
voice.speed
|
||||
);
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.onended = () => setPlayingVoiceId(null);
|
||||
audio.onerror = () => {
|
||||
setPlayingVoiceId(null);
|
||||
alert('试听失败,请检查 SiliconFlow 配置。');
|
||||
alert('试听失败,请检查该声音的 API Key / Base URL。');
|
||||
};
|
||||
|
||||
audioRef.current = audio;
|
||||
setPlayingVoiceId(voice.id);
|
||||
await audio.play();
|
||||
@@ -121,27 +99,6 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
setVoices((prev) => prev.filter((voice) => voice.id !== id));
|
||||
};
|
||||
|
||||
const handleSaveVendorCredential = async () => {
|
||||
if (!credentialApiKey.trim()) {
|
||||
alert('请填写 API Key');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSavingCredential(true);
|
||||
const option = VENDOR_OPTIONS.find((item) => item.key === credentialVendorKey);
|
||||
const saved = await saveVendorCredential(credentialVendorKey, {
|
||||
vendorName: option?.label || credentialVendorKey,
|
||||
apiKey: credentialApiKey.trim(),
|
||||
baseUrl: credentialBaseUrl.trim(),
|
||||
});
|
||||
setVendorCredentials((prev) => ({ ...prev, [saved.vendorKey]: saved }));
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '保存厂商配置失败');
|
||||
} finally {
|
||||
setIsSavingCredential(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -156,7 +113,6 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -164,7 +120,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
placeholder="搜索声音名称..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -206,34 +162,6 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={credentialVendorKey}
|
||||
onChange={(e) => setCredentialVendorKey(e.target.value)}
|
||||
>
|
||||
{VENDOR_OPTIONS.map((item) => (
|
||||
<option key={item.key} value={item.key}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Vendor API Key (持久化到后端)"
|
||||
className="border-0 bg-white/5"
|
||||
value={credentialApiKey}
|
||||
onChange={e => setCredentialApiKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Base URL (OpenAI compatible, 选填)"
|
||||
className="border-0 bg-white/5"
|
||||
value={credentialBaseUrl}
|
||||
onChange={e => setCredentialBaseUrl(e.target.value)}
|
||||
/>
|
||||
<Button onClick={handleSaveVendorCredential} disabled={isSavingCredential}>
|
||||
{isSavingCredential ? '保存中...' : '保存厂商配置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
@@ -247,7 +175,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{!isLoading && filteredVoices.map(voice => (
|
||||
{!isLoading && filteredVoices.map((voice) => (
|
||||
<TableRow key={voice.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
@@ -269,7 +197,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
size="icon"
|
||||
onClick={() => handlePlayToggle(voice)}
|
||||
disabled={playLoadingId === voice.id}
|
||||
className={playingVoiceId === voice.id ? "text-primary animate-pulse" : ""}
|
||||
className={playingVoiceId === voice.id ? 'text-primary animate-pulse' : ''}
|
||||
>
|
||||
{playingVoiceId === voice.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -298,11 +226,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AddVoiceModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
<AddVoiceModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} onSuccess={handleAddSuccess} />
|
||||
|
||||
<AddVoiceModal
|
||||
isOpen={!!editingVoice}
|
||||
@@ -311,16 +235,11 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
initialVoice={editingVoice || undefined}
|
||||
/>
|
||||
|
||||
<CloneVoiceModal
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
<CloneVoiceModal isOpen={isCloneModalOpen} onClose={() => setIsCloneModalOpen(false)} onSuccess={handleAddSuccess} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Unified Add Voice Modal ---
|
||||
const AddVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -341,6 +260,8 @@ const AddVoiceModal: React.FC<{
|
||||
const [gender, setGender] = useState('Female');
|
||||
const [language, setLanguage] = useState('zh');
|
||||
const [description, setDescription] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
|
||||
const [testInput, setTestInput] = useState('你好,正在测试语音合成效果。');
|
||||
const [isAuditioning, setIsAuditioning] = useState(false);
|
||||
@@ -362,6 +283,8 @@ const AddVoiceModal: React.FC<{
|
||||
setSfSpeed(initialVoice.speed ?? 1);
|
||||
setSfGain(initialVoice.gain ?? 0);
|
||||
setSfPitch(initialVoice.pitch ?? 0);
|
||||
setApiKey(initialVoice.apiKey || '');
|
||||
setBaseUrl(initialVoice.baseUrl || '');
|
||||
}, [initialVoice, isOpen]);
|
||||
|
||||
const handleAudition = async () => {
|
||||
@@ -372,7 +295,7 @@ const AddVoiceModal: React.FC<{
|
||||
}
|
||||
try {
|
||||
setIsAuditioning(true);
|
||||
const audioUrl = await previewVoice(initialVoice.id, testInput, sfSpeed);
|
||||
const audioUrl = await previewVoice(initialVoice.id, testInput, sfSpeed, apiKey || undefined);
|
||||
if (testAudioRef.current) {
|
||||
testAudioRef.current.pause();
|
||||
}
|
||||
@@ -387,21 +310,27 @@ const AddVoiceModal: React.FC<{
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name) { alert("请填写声音显示名称"); return; }
|
||||
if (!name) {
|
||||
alert('请填写声音显示名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const newVoice: Voice = {
|
||||
id: initialVoice?.id || `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`,
|
||||
name: name,
|
||||
vendor: vendor,
|
||||
gender: gender,
|
||||
language: language,
|
||||
name,
|
||||
vendor,
|
||||
gender,
|
||||
language,
|
||||
description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`),
|
||||
model: vendor === '硅基流动' ? sfModel : model,
|
||||
voiceKey: vendor === '硅基流动' ? sfVoiceId : voiceKey,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
speed: sfSpeed,
|
||||
gain: sfGain,
|
||||
pitch: sfPitch,
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await onSuccess(newVoice);
|
||||
@@ -410,6 +339,8 @@ const AddVoiceModal: React.FC<{
|
||||
setDescription('');
|
||||
setModel('');
|
||||
setVoiceKey('');
|
||||
setApiKey('');
|
||||
setBaseUrl('');
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '保存失败');
|
||||
} finally {
|
||||
@@ -421,12 +352,12 @@ const AddVoiceModal: React.FC<{
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={initialVoice ? "编辑声音" : "添加声音"}
|
||||
title={initialVoice ? '编辑声音' : '添加声音'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} className="bg-primary hover:bg-primary/90" disabled={isSaving}>
|
||||
{isSaving ? '保存中...' : (initialVoice ? '保存修改' : '确认添加')}
|
||||
{isSaving ? '保存中...' : initialVoice ? '保存修改' : '确认添加'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -453,7 +384,7 @@ const AddVoiceModal: React.FC<{
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">声音名称</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="例如: 客服小美" />
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="例如: 客服小美" />
|
||||
</div>
|
||||
|
||||
{vendor === '硅基流动' ? (
|
||||
@@ -464,7 +395,7 @@ const AddVoiceModal: React.FC<{
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||||
value={sfModel}
|
||||
onChange={e => setSfModel(e.target.value)}
|
||||
onChange={(e) => setSfModel(e.target.value)}
|
||||
>
|
||||
<option value="FunAudioLLM/CosyVoice2-0.5B">FunAudioLLM/CosyVoice2-0.5B</option>
|
||||
<option value="fishaudio/fish-speech-1.5">fishaudio/fish-speech-1.5</option>
|
||||
@@ -473,7 +404,7 @@ const AddVoiceModal: React.FC<{
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">声音 ID (Voice)</label>
|
||||
<Input value={sfVoiceId} onChange={e => setSfVoiceId(e.target.value)} placeholder="fishaudio:amy" />
|
||||
<Input value={sfVoiceId} onChange={(e) => setSfVoiceId(e.target.value)} placeholder="FunAudioLLM/CosyVoice2-0.5B:anna" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -481,21 +412,21 @@ const AddVoiceModal: React.FC<{
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">语速 (Speed)</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="range" min="0.5" max="2" step="0.1" value={sfSpeed} onChange={e => setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" />
|
||||
<input type="range" min="0.5" max="2" step="0.1" value={sfSpeed} onChange={(e) => setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" />
|
||||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfSpeed}x</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">增益 (Gain)</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="range" min="-10" max="10" step="1" value={sfGain} onChange={e => setSfGain(parseInt(e.target.value))} className="flex-1 accent-primary" />
|
||||
<input type="range" min="-10" max="10" step="1" value={sfGain} onChange={(e) => setSfGain(parseInt(e.target.value, 10))} className="flex-1 accent-primary" />
|
||||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfGain}dB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">音调 (Pitch)</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="range" min="-12" max="12" step="1" value={sfPitch} onChange={e => setSfPitch(parseInt(e.target.value))} className="flex-1 accent-primary" />
|
||||
<input type="range" min="-12" max="12" step="1" value={sfPitch} onChange={(e) => setSfPitch(parseInt(e.target.value, 10))} className="flex-1 accent-primary" />
|
||||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfPitch}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,23 +437,34 @@ const AddVoiceModal: React.FC<{
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">模型标识</label>
|
||||
<Input value={model} onChange={e => setModel(e.target.value)} placeholder="API Model Key" />
|
||||
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="API Model Key" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">发音人标识</label>
|
||||
<Input value={voiceKey} onChange={e => setVoiceKey(e.target.value)} placeholder="Voice Key" />
|
||||
<Input value={voiceKey} onChange={(e) => setVoiceKey(e.target.value)} placeholder="Voice Key" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">API Key</label>
|
||||
<Input value={apiKey} type="password" onChange={(e) => setApiKey(e.target.value)} placeholder="每个声音独立 API Key" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Base URL</label>
|
||||
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://.../v1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">性别</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||||
value={gender}
|
||||
onChange={e => setGender(e.target.value)}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
>
|
||||
<option value="Female">女 (Female)</option>
|
||||
<option value="Male">男 (Male)</option>
|
||||
@@ -533,7 +475,7 @@ const AddVoiceModal: React.FC<{
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||||
value={language}
|
||||
onChange={e => setLanguage(e.target.value)}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="en">英文 (English)</option>
|
||||
@@ -556,12 +498,11 @@ const AddVoiceModal: React.FC<{
|
||||
<h4 className="text-[10px] font-black text-primary flex items-center tracking-widest uppercase">
|
||||
<Volume2 className="w-3.5 h-3.5 mr-1.5" /> 参数试听 (Preview)
|
||||
</h4>
|
||||
{vendor === '硅基流动' && <Badge variant="outline" className="text-[8px] border-primary/20 text-primary/70">SiliconFlow Audio API</Badge>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={testInput}
|
||||
onChange={e => setTestInput(e.target.value)}
|
||||
onChange={(e) => setTestInput(e.target.value)}
|
||||
placeholder="输入测试文本..."
|
||||
className="text-xs bg-black/20"
|
||||
/>
|
||||
@@ -584,7 +525,7 @@ const AddVoiceModal: React.FC<{
|
||||
const CloneVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (voice: Voice) => Promise<void>
|
||||
onSuccess: (voice: Voice) => Promise<void>;
|
||||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -599,17 +540,17 @@ const CloneVoiceModal: React.FC<{
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name || !file) {
|
||||
alert("请填写名称并上传音频文件");
|
||||
alert('请填写名称并上传音频文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const newVoice: Voice = {
|
||||
id: `v-${Date.now()}`,
|
||||
name: name,
|
||||
name,
|
||||
vendor: 'Volcano',
|
||||
gender: 'Female',
|
||||
language: 'zh',
|
||||
description: description || 'User cloned voice'
|
||||
description: description || 'User cloned voice',
|
||||
};
|
||||
|
||||
await onSuccess(newVoice);
|
||||
@@ -633,11 +574,7 @@ const CloneVoiceModal: React.FC<{
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">语音名称</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="给新声音起个名字"
|
||||
/>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="给新声音起个名字" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -646,13 +583,7 @@ const CloneVoiceModal: React.FC<{
|
||||
className="flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed border-white/10 bg-white/5 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input ref={inputRef} type="file" accept="audio/*" className="hidden" onChange={handleFileChange} />
|
||||
{file ? (
|
||||
<div className="flex items-center space-x-2 text-primary">
|
||||
<Mic2 className="h-6 w-6" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, VendorCredential, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||
import { Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||
import { apiRequest } from './apiClient';
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
@@ -55,6 +55,8 @@ const mapVoice = (raw: AnyRecord): Voice => ({
|
||||
description: readField(raw, ['description'], ''),
|
||||
model: readField(raw, ['model'], ''),
|
||||
voiceKey: readField(raw, ['voiceKey', 'voice_key'], ''),
|
||||
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
||||
baseUrl: readField(raw, ['baseUrl', 'base_url'], ''),
|
||||
speed: Number(readField(raw, ['speed'], 1)),
|
||||
gain: Number(readField(raw, ['gain'], 0)),
|
||||
pitch: Number(readField(raw, ['pitch'], 0)),
|
||||
@@ -62,13 +64,6 @@ const mapVoice = (raw: AnyRecord): Voice => ({
|
||||
isSystem: Boolean(readField(raw, ['isSystem', 'is_system'], false)),
|
||||
});
|
||||
|
||||
const mapVendorCredential = (raw: AnyRecord): VendorCredential => ({
|
||||
vendorKey: String(readField(raw, ['vendorKey', 'vendor_key'], '')),
|
||||
vendorName: readField(raw, ['vendorName', 'vendor_name'], ''),
|
||||
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
||||
baseUrl: readField(raw, ['baseUrl', 'base_url'], ''),
|
||||
});
|
||||
|
||||
const mapWorkflowNode = (raw: AnyRecord): WorkflowNode => ({
|
||||
name: readField(raw, ['name'], ''),
|
||||
type: readField(raw, ['type'], 'conversation') as 'conversation' | 'tool' | 'human' | 'end',
|
||||
@@ -205,6 +200,8 @@ export const createVoice = async (data: Partial<Voice>): Promise<Voice> => {
|
||||
description: data.description || '',
|
||||
model: data.model || undefined,
|
||||
voice_key: data.voiceKey || undefined,
|
||||
api_key: data.apiKey || undefined,
|
||||
base_url: data.baseUrl || undefined,
|
||||
speed: data.speed ?? 1,
|
||||
gain: data.gain ?? 0,
|
||||
pitch: data.pitch ?? 0,
|
||||
@@ -223,6 +220,8 @@ export const updateVoice = async (id: string, data: Partial<Voice>): Promise<Voi
|
||||
description: data.description,
|
||||
model: data.model,
|
||||
voice_key: data.voiceKey,
|
||||
api_key: data.apiKey,
|
||||
base_url: data.baseUrl,
|
||||
speed: data.speed,
|
||||
gain: data.gain,
|
||||
pitch: data.pitch,
|
||||
@@ -247,24 +246,6 @@ export const previewVoice = async (id: string, text: string, speed?: number, api
|
||||
return response.audio_url;
|
||||
};
|
||||
|
||||
export const fetchVendorCredentials = async (): Promise<VendorCredential[]> => {
|
||||
const response = await apiRequest<{ list?: AnyRecord[] }>('/voices/vendors/credentials');
|
||||
const list = response.list || [];
|
||||
return list.map((item) => mapVendorCredential(item));
|
||||
};
|
||||
|
||||
export const saveVendorCredential = async (vendorKey: string, data: { vendorName: string; apiKey: string; baseUrl?: string }): Promise<VendorCredential> => {
|
||||
const response = await apiRequest<AnyRecord>(`/voices/vendors/credentials/${vendorKey}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
vendor_name: data.vendorName,
|
||||
api_key: data.apiKey,
|
||||
base_url: data.baseUrl || undefined,
|
||||
},
|
||||
});
|
||||
return mapVendorCredential(response);
|
||||
};
|
||||
|
||||
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
||||
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/workflows');
|
||||
const list = Array.isArray(response) ? response : (response.list || []);
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface Voice {
|
||||
description: string;
|
||||
model?: string;
|
||||
voiceKey?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
speed?: number;
|
||||
gain?: number;
|
||||
pitch?: number;
|
||||
@@ -37,13 +39,6 @@ export interface Voice {
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
export interface VendorCredential {
|
||||
vendorKey: string;
|
||||
vendorName: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user