Voice libary data presistence after codex
This commit is contained in:
@@ -1,40 +1,65 @@
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown } from 'lucide-react';
|
||||
import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
||||
import { mockVoices } from '../services/mockData';
|
||||
import { Voice } from '../types';
|
||||
import { fetchVoices } from '../services/backendApi';
|
||||
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' },
|
||||
];
|
||||
|
||||
export const VoiceLibraryPage: React.FC = () => {
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动'>('all');
|
||||
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动' | 'SiliconFlow'>('all');
|
||||
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
|
||||
const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all');
|
||||
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
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 loadVoices = async () => {
|
||||
const loadVoicesAndCredentials = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const list = await fetchVoices();
|
||||
setVoices(list.length > 0 ? list : mockVoices);
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setVoices(mockVoices);
|
||||
setVoices([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVoices();
|
||||
loadVoicesAndCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = vendorCredentials[credentialVendorKey];
|
||||
setCredentialApiKey(selected?.apiKey || '');
|
||||
setCredentialBaseUrl(selected?.baseUrl || '');
|
||||
}, [credentialVendorKey, vendorCredentials]);
|
||||
|
||||
const filteredVoices = voices.filter(voice => {
|
||||
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesVendor = vendorFilter === 'all' || voice.vendor === vendorFilter;
|
||||
@@ -43,23 +68,80 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
return matchesSearch && matchesVendor && matchesGender && matchesLang;
|
||||
});
|
||||
|
||||
const handlePlayToggle = (id: string) => {
|
||||
if (playingVoiceId === id) {
|
||||
const handlePlayToggle = async (voice: Voice) => {
|
||||
if (playingVoiceId === voice.id && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setPlayingVoiceId(null);
|
||||
} else {
|
||||
setPlayingVoiceId(id);
|
||||
setTimeout(() => {
|
||||
setPlayingVoiceId((current) => current === id ? null : current);
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setPlayLoadingId(voice.id);
|
||||
const audioUrl = await previewVoice(
|
||||
voice.id,
|
||||
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 配置。');
|
||||
};
|
||||
audioRef.current = audio;
|
||||
setPlayingVoiceId(voice.id);
|
||||
await audio.play();
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '试听失败');
|
||||
setPlayingVoiceId(null);
|
||||
} finally {
|
||||
setPlayLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSuccess = (newVoice: Voice) => {
|
||||
setVoices([newVoice, ...voices]);
|
||||
const handleAddSuccess = async (newVoice: Voice) => {
|
||||
const created = await createVoice(newVoice);
|
||||
setVoices((prev) => [created, ...prev]);
|
||||
setIsAddModalOpen(false);
|
||||
setIsCloneModalOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdateSuccess = async (id: string, data: Voice) => {
|
||||
const updated = await updateVoice(id, data);
|
||||
setVoices((prev) => prev.map((voice) => (voice.id === id ? updated : voice)));
|
||||
setEditingVoice(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确认删除这个声音吗?')) return;
|
||||
await deleteVoice(id);
|
||||
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">
|
||||
@@ -94,6 +176,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
>
|
||||
<option value="all">所有厂商</option>
|
||||
<option value="硅基流动">硅基流动 (SiliconFlow)</option>
|
||||
<option value="SiliconFlow">SiliconFlow</option>
|
||||
<option value="Ali">阿里 (Ali)</option>
|
||||
<option value="Volcano">火山 (Volcano)</option>
|
||||
<option value="Minimax">Minimax</option>
|
||||
@@ -123,6 +206,34 @@ 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>
|
||||
@@ -132,6 +243,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
<TableHead>性别</TableHead>
|
||||
<TableHead>语言</TableHead>
|
||||
<TableHead className="text-right">试听</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
@@ -155,22 +267,31 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePlayToggle(voice.id)}
|
||||
onClick={() => handlePlayToggle(voice)}
|
||||
disabled={playLoadingId === voice.id}
|
||||
className={playingVoiceId === voice.id ? "text-primary animate-pulse" : ""}
|
||||
>
|
||||
{playingVoiceId === voice.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditingVoice(voice)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(voice.id)} className="text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!isLoading && filteredVoices.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
||||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">加载中...</TableCell>
|
||||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">加载中...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -183,6 +304,13 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
|
||||
<AddVoiceModal
|
||||
isOpen={!!editingVoice}
|
||||
onClose={() => setEditingVoice(null)}
|
||||
onSuccess={(voice) => handleUpdateSuccess(editingVoice!.id, voice)}
|
||||
initialVoice={editingVoice || undefined}
|
||||
/>
|
||||
|
||||
<CloneVoiceModal
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
@@ -196,15 +324,17 @@ export const VoiceLibraryPage: React.FC = () => {
|
||||
const AddVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (voice: Voice) => void;
|
||||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||||
onSuccess: (voice: Voice) => Promise<void>;
|
||||
initialVoice?: Voice;
|
||||
}> = ({ isOpen, onClose, onSuccess, initialVoice }) => {
|
||||
const [vendor, setVendor] = useState<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [sfModel, setSfModel] = useState('fishaudio/fish-speech-1.5');
|
||||
const [sfVoiceId, setSfVoiceId] = useState('fishaudio:amy');
|
||||
const [sfModel, setSfModel] = useState('FunAudioLLM/CosyVoice2-0.5B');
|
||||
const [sfVoiceId, setSfVoiceId] = useState('FunAudioLLM/CosyVoice2-0.5B:anna');
|
||||
const [sfSpeed, setSfSpeed] = useState(1);
|
||||
const [sfGain, setSfGain] = useState(0);
|
||||
const [sfPitch, setSfPitch] = useState(0);
|
||||
|
||||
const [model, setModel] = useState('');
|
||||
const [voiceKey, setVoiceKey] = useState('');
|
||||
@@ -214,40 +344,90 @@ const AddVoiceModal: React.FC<{
|
||||
|
||||
const [testInput, setTestInput] = useState('你好,正在测试语音合成效果。');
|
||||
const [isAuditioning, setIsAuditioning] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const testAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const handleAudition = () => {
|
||||
useEffect(() => {
|
||||
if (!initialVoice) return;
|
||||
const nextVendor = initialVoice.vendor === 'SiliconFlow' ? '硅基流动' : initialVoice.vendor;
|
||||
setVendor((nextVendor as any) || '硅基流动');
|
||||
setName(initialVoice.name || '');
|
||||
setGender(initialVoice.gender || 'Female');
|
||||
setLanguage(initialVoice.language || 'zh');
|
||||
setDescription(initialVoice.description || '');
|
||||
setModel(initialVoice.model || '');
|
||||
setVoiceKey(initialVoice.voiceKey || '');
|
||||
setSfModel(initialVoice.model || 'FunAudioLLM/CosyVoice2-0.5B');
|
||||
setSfVoiceId(initialVoice.voiceKey || 'FunAudioLLM/CosyVoice2-0.5B:anna');
|
||||
setSfSpeed(initialVoice.speed ?? 1);
|
||||
setSfGain(initialVoice.gain ?? 0);
|
||||
setSfPitch(initialVoice.pitch ?? 0);
|
||||
}, [initialVoice, isOpen]);
|
||||
|
||||
const handleAudition = async () => {
|
||||
if (!testInput.trim()) return;
|
||||
setIsAuditioning(true);
|
||||
setTimeout(() => setIsAuditioning(false), 2000);
|
||||
if (!initialVoice?.id) {
|
||||
alert('请先创建声音,再进行试听。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsAuditioning(true);
|
||||
const audioUrl = await previewVoice(initialVoice.id, testInput, sfSpeed);
|
||||
if (testAudioRef.current) {
|
||||
testAudioRef.current.pause();
|
||||
}
|
||||
const audio = new Audio(audioUrl);
|
||||
testAudioRef.current = audio;
|
||||
await audio.play();
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '试听失败');
|
||||
} finally {
|
||||
setIsAuditioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!name) { alert("请填写声音显示名称"); return; }
|
||||
|
||||
let newVoice: Voice = {
|
||||
id: `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`,
|
||||
|
||||
const newVoice: Voice = {
|
||||
id: initialVoice?.id || `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`,
|
||||
name: name,
|
||||
vendor: vendor,
|
||||
gender: gender,
|
||||
language: language,
|
||||
description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`)
|
||||
description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`),
|
||||
model: vendor === '硅基流动' ? sfModel : model,
|
||||
voiceKey: vendor === '硅基流动' ? sfVoiceId : voiceKey,
|
||||
speed: sfSpeed,
|
||||
gain: sfGain,
|
||||
pitch: sfPitch,
|
||||
};
|
||||
|
||||
onSuccess(newVoice);
|
||||
setName('');
|
||||
setVendor('硅基流动');
|
||||
setDescription('');
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await onSuccess(newVoice);
|
||||
setName('');
|
||||
setVendor('硅基流动');
|
||||
setDescription('');
|
||||
setModel('');
|
||||
setVoiceKey('');
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '保存失败');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="添加声音"
|
||||
title={initialVoice ? "编辑声音" : "添加声音"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} className="bg-primary hover:bg-primary/90">确认添加</Button>
|
||||
<Button onClick={handleSubmit} className="bg-primary hover:bg-primary/90" disabled={isSaving}>
|
||||
{isSaving ? '保存中...' : (initialVoice ? '保存修改' : '确认添加')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -286,9 +466,9 @@ const AddVoiceModal: React.FC<{
|
||||
value={sfModel}
|
||||
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>
|
||||
<option value="fishaudio/fish-speech-1.4">fishaudio/fish-speech-1.4</option>
|
||||
<option value="ByteDance/SA-Speech">ByteDance/SA-Speech</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -312,6 +492,13 @@ const AddVoiceModal: React.FC<{
|
||||
<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" />
|
||||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfPitch}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -382,7 +569,7 @@ const AddVoiceModal: React.FC<{
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAudition}
|
||||
disabled={isAuditioning}
|
||||
disabled={isAuditioning || !initialVoice}
|
||||
className="shrink-0 h-9"
|
||||
>
|
||||
{isAuditioning ? <Pause className="h-3.5 w-3.5 animate-pulse" /> : <Play className="h-3.5 w-3.5" />}
|
||||
@@ -397,7 +584,7 @@ const AddVoiceModal: React.FC<{
|
||||
const CloneVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (voice: Voice) => void
|
||||
onSuccess: (voice: Voice) => Promise<void>
|
||||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -410,7 +597,7 @@ const CloneVoiceModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!name || !file) {
|
||||
alert("请填写名称并上传音频文件");
|
||||
return;
|
||||
@@ -425,7 +612,7 @@ const CloneVoiceModal: React.FC<{
|
||||
description: description || 'User cloned voice'
|
||||
};
|
||||
|
||||
onSuccess(newVoice);
|
||||
await onSuccess(newVoice);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFile(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||
import { Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, VendorCredential, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||
import { apiRequest } from './apiClient';
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
@@ -46,10 +46,27 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
|
||||
const mapVoice = (raw: AnyRecord): Voice => ({
|
||||
id: String(readField(raw, ['id'], '')),
|
||||
name: readField(raw, ['name'], ''),
|
||||
vendor: readField(raw, ['vendor'], ''),
|
||||
vendor: ((): string => {
|
||||
const vendor = String(readField(raw, ['vendor'], ''));
|
||||
return vendor.toLowerCase() === 'siliconflow' ? '硅基流动' : vendor;
|
||||
})(),
|
||||
gender: readField(raw, ['gender'], ''),
|
||||
language: readField(raw, ['language'], ''),
|
||||
description: readField(raw, ['description'], ''),
|
||||
model: readField(raw, ['model'], ''),
|
||||
voiceKey: readField(raw, ['voiceKey', 'voice_key'], ''),
|
||||
speed: Number(readField(raw, ['speed'], 1)),
|
||||
gain: Number(readField(raw, ['gain'], 0)),
|
||||
pitch: Number(readField(raw, ['pitch'], 0)),
|
||||
enabled: Boolean(readField(raw, ['enabled'], true)),
|
||||
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 => ({
|
||||
@@ -178,6 +195,76 @@ export const fetchVoices = async (): Promise<Voice[]> => {
|
||||
return list.map((item) => mapVoice(item));
|
||||
};
|
||||
|
||||
export const createVoice = async (data: Partial<Voice>): Promise<Voice> => {
|
||||
const payload = {
|
||||
id: data.id || undefined,
|
||||
name: data.name || 'New Voice',
|
||||
vendor: data.vendor === '硅基流动' ? 'SiliconFlow' : (data.vendor || 'SiliconFlow'),
|
||||
gender: data.gender || 'Female',
|
||||
language: data.language || 'zh',
|
||||
description: data.description || '',
|
||||
model: data.model || undefined,
|
||||
voice_key: data.voiceKey || undefined,
|
||||
speed: data.speed ?? 1,
|
||||
gain: data.gain ?? 0,
|
||||
pitch: data.pitch ?? 0,
|
||||
enabled: data.enabled ?? true,
|
||||
};
|
||||
const response = await apiRequest<AnyRecord>('/voices', { method: 'POST', body: payload });
|
||||
return mapVoice(response);
|
||||
};
|
||||
|
||||
export const updateVoice = async (id: string, data: Partial<Voice>): Promise<Voice> => {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
vendor: data.vendor === '硅基流动' ? 'SiliconFlow' : data.vendor,
|
||||
gender: data.gender,
|
||||
language: data.language,
|
||||
description: data.description,
|
||||
model: data.model,
|
||||
voice_key: data.voiceKey,
|
||||
speed: data.speed,
|
||||
gain: data.gain,
|
||||
pitch: data.pitch,
|
||||
enabled: data.enabled,
|
||||
};
|
||||
const response = await apiRequest<AnyRecord>(`/voices/${id}`, { method: 'PUT', body: payload });
|
||||
return mapVoice(response);
|
||||
};
|
||||
|
||||
export const deleteVoice = async (id: string): Promise<void> => {
|
||||
await apiRequest(`/voices/${id}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const previewVoice = async (id: string, text: string, speed?: number, apiKey?: string): Promise<string> => {
|
||||
const response = await apiRequest<{ success: boolean; audio_url?: string; error?: string }>(`/voices/${id}/preview`, {
|
||||
method: 'POST',
|
||||
body: { text, speed, api_key: apiKey },
|
||||
});
|
||||
if (!response.success || !response.audio_url) {
|
||||
throw new Error(response.error || 'Preview failed');
|
||||
}
|
||||
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 || []);
|
||||
|
||||
14
web/types.ts
14
web/types.ts
@@ -28,6 +28,20 @@ export interface Voice {
|
||||
gender: string;
|
||||
language: string;
|
||||
description: string;
|
||||
model?: string;
|
||||
voiceKey?: string;
|
||||
speed?: number;
|
||||
gain?: number;
|
||||
pitch?: number;
|
||||
enabled?: boolean;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
export interface VendorCredential {
|
||||
vendorKey: string;
|
||||
vendorName: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBase {
|
||||
|
||||
Reference in New Issue
Block a user