projects
This commit is contained in:
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user