ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses. Two more hand-rolled plurals became real plural entries -- maintenance said "commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel rather than adding a fourth app-level copy of the word, which is what phase 1 put it there for. Scanner: a single token carrying digits and punctuation is a name, not a sentence -- the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf. Prose has spaces, and requiring whitespace to be absent is what keeps this from eating real one-word labels. Verified the delta is exactly that one string. Note for the owner: another session has src/servers/api/sub-agents/router.ts in flight with three type errors. Untouched by me and excluded from my gate; the full tree will not typecheck clean until they land it.
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import type { FormEvent } from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
type Provider = 'openai' | 'elevenlabs';
|
|
|
|
type TtsConfig = {
|
|
provider: Provider;
|
|
url: string;
|
|
apiKey?: string;
|
|
model: string;
|
|
voice: string;
|
|
};
|
|
|
|
const PROVIDER_DEFAULTS: Record<Provider, Partial<TtsConfig>> = {
|
|
openai: { url: 'http://localhost:64202', model: 'kokoro', voice: 'af_heart' },
|
|
elevenlabs: { url: '', model: 'eleven_multilingual_v2', voice: 'Rachel' },
|
|
};
|
|
|
|
export const TTSSection = () => {
|
|
const { t } = useTranslation();
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const { data: config, isLoading } = useQuery({
|
|
queryKey: ['TTS_CONFIG'],
|
|
queryFn: () => client.get<TtsConfig | null>('/server-settings/tts'),
|
|
});
|
|
|
|
const [provider, setProvider] = useState<Provider>('openai');
|
|
const [url, setUrl] = useState('http://localhost:64202');
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [model, setModel] = useState('kokoro');
|
|
const [voice, setVoice] = useState('af_heart');
|
|
const [voices, setVoices] = useState<string[]>([]);
|
|
const [voiceGroups, setVoiceGroups] = useState<{ label: string; voices: string[] }[]>([]);
|
|
const [voicesLoading, setVoicesLoading] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isTesting, setIsTesting] = useState(false);
|
|
|
|
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
|
|
setVoicesLoading(true);
|
|
try {
|
|
const res = await client.post<{
|
|
voices?: string[];
|
|
groups?: { label: string; voices: string[] }[];
|
|
error?: string;
|
|
}>('/server-settings/tts/voices', {
|
|
provider: p,
|
|
url: u,
|
|
apiKey: key || undefined,
|
|
model: m || undefined,
|
|
});
|
|
if (res.voices) setVoices(res.voices);
|
|
else setVoices([]);
|
|
setVoiceGroups(res.groups ?? []);
|
|
} catch {
|
|
setVoices([]);
|
|
} finally {
|
|
setVoicesLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!config) return;
|
|
setProvider(config.provider);
|
|
setUrl(config.url ?? '');
|
|
setApiKey(config.apiKey ?? '');
|
|
setModel(config.model);
|
|
setVoice(config.voice);
|
|
fetchVoices(config.provider, config.url ?? '', config.apiKey ?? '', config.model);
|
|
}, [config]);
|
|
|
|
const handleProviderChange = (v: Provider) => {
|
|
setProvider(v);
|
|
const defaults = PROVIDER_DEFAULTS[v];
|
|
if (defaults.url !== undefined) setUrl(defaults.url);
|
|
if (defaults.model !== undefined) setModel(defaults.model);
|
|
if (defaults.voice !== undefined) setVoice(defaults.voice);
|
|
setApiKey('');
|
|
};
|
|
|
|
const buildConfig = (): TtsConfig => ({
|
|
provider,
|
|
url,
|
|
model,
|
|
voice,
|
|
...(apiKey ? { apiKey } : {}),
|
|
});
|
|
|
|
const handleSave = async (ev?: FormEvent) => {
|
|
ev?.preventDefault();
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await client.put('/server-settings/tts', buildConfig());
|
|
queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] });
|
|
toast.success(t('settings.server.tts.saved'));
|
|
} catch {
|
|
toast.error(t('settings.server.tts.saveFailed'));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
if (isTesting) return;
|
|
setIsTesting(true);
|
|
try {
|
|
const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` },
|
|
body: JSON.stringify(buildConfig()),
|
|
});
|
|
|
|
if (!res.ok || res.headers.get('content-type')?.includes('json')) {
|
|
const json = await res.json().catch(() => ({ error: 'Test failed' }));
|
|
toast.error(json.error ?? 'Test failed');
|
|
return;
|
|
}
|
|
|
|
const blob = await res.blob();
|
|
const audioUrl = URL.createObjectURL(blob);
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
URL.revokeObjectURL(audioRef.current.src);
|
|
}
|
|
const audio = new Audio(audioUrl);
|
|
audioRef.current = audio;
|
|
audio.play();
|
|
toast.success(t('settings.server.tts.playing'));
|
|
} catch {
|
|
toast.error(t('settings.server.tts.testFailed'));
|
|
} finally {
|
|
setIsTesting(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
|
|
|
|
return (
|
|
<form onSubmit={handleSave} className="grid gap-4">
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
|
|
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[600]">
|
|
<SelectItem value="openai">{t('settings.server.tts.openaiCompatible')}</SelectItem>
|
|
<SelectItem value="elevenlabs">{t('settings.server.tts.elevenLabs')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</Label>
|
|
|
|
{provider === 'openai' && (
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">URL</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
|
placeholder="http://localhost:64202"
|
|
value={url}
|
|
onChange={(ev) => setUrl(ev.target.value)}
|
|
/>
|
|
</Label>
|
|
)}
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">
|
|
API Key {provider === 'openai' ? '(optional)' : ''}
|
|
</span>
|
|
<Input
|
|
type="password"
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
|
placeholder={provider === 'elevenlabs' ? 'xi-...' : 'sk-...'}
|
|
value={apiKey}
|
|
onChange={(ev) => setApiKey(ev.target.value)}
|
|
/>
|
|
</Label>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
|
placeholder={provider === 'openai' ? 'kokoro' : 'eleven_multilingual_v2'}
|
|
value={model}
|
|
onChange={(ev) => setModel(ev.target.value)}
|
|
/>
|
|
</Label>
|
|
<Label className="grid gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.voice')}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchVoices(provider, url, apiKey, model)}
|
|
disabled={voicesLoading}
|
|
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
|
title={t('settings.server.tts.refreshVoices')}
|
|
>
|
|
<RefreshCw
|
|
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{voices.length > 0 ? (
|
|
<Select value={voice} onValueChange={setVoice}>
|
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
|
<SelectValue placeholder={t('settings.server.tts.selectVoice')} />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[600] max-h-[300px]">
|
|
{voiceGroups.length > 0
|
|
? voiceGroups.map((g) => (
|
|
<SelectGroup key={g.label}>
|
|
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
|
{g.label}
|
|
</SelectLabel>
|
|
{g.voices.map((v) => (
|
|
<SelectItem key={v} value={v}>
|
|
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))
|
|
: voices.map((v) => (
|
|
<SelectItem key={v} value={v}>
|
|
{v}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
|
placeholder={provider === 'openai' ? 'af_heart' : 'Rachel'}
|
|
value={voice}
|
|
onChange={(ev) => setVoice(ev.target.value)}
|
|
/>
|
|
)}
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="submit"
|
|
disabled={isSaving}
|
|
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
|
>
|
|
{isSaving ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleTest}
|
|
disabled={isTesting}
|
|
className="h-11 cursor-pointer disabled:opacity-50"
|
|
>
|
|
{isTesting ? 'Testing...' : 'Test'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|