This commit is contained in:
2026-02-25 06:59:29 +00:00
parent 88fff9efae
commit acd7713c86
37 changed files with 1581 additions and 69 deletions
@@ -0,0 +1,168 @@
import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
type VoiceGroup = { label: string; voices: string[] };
type TtsConfig = {
provider: string;
url: string;
apiKey?: string;
model: string;
voice: string;
};
const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => {
const client = useClient();
const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]);
const [groups, setGroups] = useState<VoiceGroup[]>([]);
const [loading, setLoading] = useState(false);
const [serverConfig, setServerConfig] = useState<TtsConfig | null>(null);
const [listening, setListening] = useState<'idle' | 'loading' | 'playing'>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
const fetchConfig = async () => {
try {
const config = await client.get<TtsConfig | null>('/server-settings/tts');
setServerConfig(config);
return config;
} catch {
return null;
}
};
const fetchVoices = async (config: TtsConfig) => {
setLoading(true);
try {
const res = await client.post<{ voices?: string[]; groups?: VoiceGroup[] }>('/server-settings/tts/voices', {
provider: config.provider,
url: config.url,
apiKey: config.apiKey || undefined,
model: config.model || undefined,
});
setVoices(res.voices ?? []);
setGroups(res.groups ?? []);
} catch {
setVoices([]);
setGroups([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchConfig().then((config) => {
if (config) fetchVoices(config);
});
}, []);
const handleChange = (value: string) => {
const voice = value === SERVER_DEFAULT ? null : value;
saveSettings({ ...settings, tts: { voice } });
};
const handleRefresh = () => {
if (serverConfig) fetchVoices(serverConfig);
};
const handleListen = async () => {
if (listening === 'loading') return;
if (listening === 'playing') {
audioRef.current?.pause();
audioRef.current = null;
setListening('idle');
return;
}
if (!serverConfig) return;
setListening('loading');
try {
const effectiveVoice = settings.tts.voice ?? serverConfig.voice;
const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` },
body: JSON.stringify({ ...serverConfig, voice: effectiveVoice }),
});
if (!res.ok || res.headers.get('content-type')?.includes('json')) {
setListening('idle');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
await audio.play();
setListening('playing');
} catch {
setListening('idle');
}
};
const selectedValue = settings.tts.voice ?? SERVER_DEFAULT;
const prettify = (v: string) => v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase());
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
{groups.length > 0
? groups.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}>{prettify(v)}</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
</SelectContent>
</Select>
<button
type="button"
onClick={handleListen}
disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'}
>
{listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
) : listening === 'playing' ? (
<Square className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
) : (
<Play className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
</Label>
</div>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { User, Lock, Globe, Bot, LayoutGrid } from 'lucide-react';
import { User, Lock, Globe, Bot, LayoutGrid, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
@@ -9,6 +9,7 @@ import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { AIModels } from './AIModels';
import { DockSettings } from './DockSettings';
import { VoicePreference } from './VoicePreference';
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
@@ -17,6 +18,7 @@ const sections: SettingsSection[] = [
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: <AIModels /> },
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
{ key: 'voice', icon: Volume2, title: 'Voice', description: 'Text-to-speech voice preference', content: <VoicePreference /> },
{ key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: <DockSettings /> },
];
@@ -5,7 +5,7 @@ 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, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
type Provider = 'openai' | 'elevenlabs';
@@ -39,20 +39,23 @@ export const TTSSection = () => {
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) => {
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
setVoicesLoading(true);
try {
const res = await client.post<{ voices?: string[]; error?: string }>('/server-settings/tts/voices', {
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 {
@@ -67,7 +70,7 @@ export const TTSSection = () => {
setApiKey(config.apiKey ?? '');
setModel(config.model);
setVoice(config.voice);
fetchVoices(config.provider, config.url ?? '', config.apiKey ?? '');
fetchVoices(config.provider, config.url ?? '', config.apiKey ?? '', config.model);
}, [config]);
const handleProviderChange = (v: Provider) => {
@@ -189,7 +192,7 @@ export const TTSSection = () => {
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<button
type="button"
onClick={() => fetchVoices(provider, url, apiKey)}
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="Refresh voices"
@@ -203,9 +206,19 @@ export const TTSSection = () => {
<SelectValue placeholder="Select a voice" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
{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>
) : (