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>
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,17 @@ import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
|
||||
async function getUserTtsVoice(email: string): Promise<string | null> {
|
||||
try {
|
||||
const settingsFile = Bun.file(getUserSettingsFile(email));
|
||||
if (await settingsFile.exists()) {
|
||||
const settings = (await settingsFile.json()) as { tts?: { voice?: string | null } };
|
||||
return settings.tts?.voice ?? null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding'];
|
||||
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
||||
const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding');
|
||||
@@ -373,18 +384,22 @@ router.post('/tts', async (ctx) => {
|
||||
const s = await stat(absPath);
|
||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const userVoice = await getUserTtsVoice(user.email);
|
||||
const voice = userVoice ?? ttsConfig.voice;
|
||||
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||
const cacheRel = dir ? `cache/tts/${dir}/${name}.mp3` : `cache/tts/${name}.mp3`;
|
||||
const voicePrefix = `${voice}/`;
|
||||
const cacheRel = dir ? `cache/tts/${voicePrefix}${dir}/${name}.mp3` : `cache/tts/${voicePrefix}${name}.mp3`;
|
||||
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||
|
||||
if (existsSync(cacheAbs)) {
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
}
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const content = await readFile(absPath, 'utf-8');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
@@ -392,7 +407,7 @@ router.post('/tts', async (ctx) => {
|
||||
|
||||
if (ttsConfig.provider === 'elevenlabs') {
|
||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||
body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
|
||||
@@ -402,7 +417,7 @@ router.post('/tts', async (ctx) => {
|
||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: content, voice: ttsConfig.voice, response_format: 'mp3' }),
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: content, voice, response_format: 'mp3' }),
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||
@@ -421,23 +436,26 @@ router.post('/tts-text', async (ctx) => {
|
||||
if (!text) throw errors.BAD_REQUEST('text is required');
|
||||
if (!id) throw errors.BAD_REQUEST('id is required');
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const userVoice = await getUserTtsVoice(user.email);
|
||||
const voice = userVoice ?? ttsConfig.voice;
|
||||
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
const cacheRel = `cache/tts/chat/${id}.mp3`;
|
||||
const cacheRel = `cache/tts/chat/${voice}/${id}.mp3`;
|
||||
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||
|
||||
if (existsSync(cacheAbs)) {
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
}
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
let res: Response;
|
||||
|
||||
if (ttsConfig.provider === 'elevenlabs') {
|
||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||
body: JSON.stringify({ text, model_id: ttsConfig.model }),
|
||||
@@ -447,7 +465,7 @@ router.post('/tts-text', async (ctx) => {
|
||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: text, voice: ttsConfig.voice, response_format: 'mp3' }),
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: text, voice, response_format: 'mp3' }),
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||
|
||||
@@ -414,12 +414,18 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Pi doesn't provide cost info in agent_end, use zeros
|
||||
const cost: MessageCost = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
};
|
||||
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
const messages = event.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (messages) {
|
||||
for (const msg of messages) {
|
||||
const usage = msg.usage as Record<string, unknown> | undefined;
|
||||
if (!usage) continue;
|
||||
cost.inputTokens += (usage.input as number) ?? 0;
|
||||
cost.outputTokens += (usage.output as number) ?? 0;
|
||||
const usageCost = usage.cost as Record<string, unknown> | undefined;
|
||||
if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0;
|
||||
}
|
||||
}
|
||||
return { type: 'result', cost };
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ ttsRouter.put('/', async (ctx) => {
|
||||
});
|
||||
|
||||
ttsRouter.post('/voices', async (ctx) => {
|
||||
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string }>();
|
||||
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>();
|
||||
|
||||
try {
|
||||
if (body.provider === 'elevenlabs') {
|
||||
@@ -67,20 +67,88 @@ ttsRouter.post('/voices', async (ctx) => {
|
||||
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
|
||||
}
|
||||
|
||||
// OpenAI-compatible
|
||||
// OpenAI-compatible: try local server first, fallback to HuggingFace
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
const headers: Record<string, string> = {};
|
||||
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers });
|
||||
if (!res.ok) return ctx.json({ error: `Voices fetch failed: ${res.status}` }, 500);
|
||||
const json = (await res.json()) as { voices: string[] };
|
||||
return ctx.json({ voices: json.voices });
|
||||
|
||||
// Try /v1/audio/voices on the local server
|
||||
const localRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers }).catch(() => null);
|
||||
if (localRes?.ok) {
|
||||
const json = (await localRes.json()) as { voices: string[] };
|
||||
return ctx.json({ voices: json.voices });
|
||||
}
|
||||
|
||||
// Fallback: get model repo from /v1/models, then list voices from HuggingFace
|
||||
const modelsRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, { headers }).catch(() => null);
|
||||
if (modelsRes?.ok) {
|
||||
const modelsJson = (await modelsRes.json()) as { data?: { id: string }[] };
|
||||
const repoId = modelsJson.data?.[0]?.id;
|
||||
if (repoId) {
|
||||
const result = await fetchHuggingFaceVoices(repoId);
|
||||
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try model field as HuggingFace repo ID directly
|
||||
if (body.model && body.model.includes('/')) {
|
||||
const result = await fetchHuggingFaceVoices(body.model);
|
||||
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
|
||||
}
|
||||
|
||||
return ctx.json({ error: 'Could not fetch voices from server or HuggingFace' }, 500);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const VOICE_GROUP_LABELS: Record<string, string> = {
|
||||
af: 'American Female',
|
||||
am: 'American Male',
|
||||
bf: 'British Female',
|
||||
bm: 'British Male',
|
||||
ef: 'Spanish Female',
|
||||
em: 'Spanish Male',
|
||||
ff: 'French Female',
|
||||
hf: 'Hindi Female',
|
||||
hm: 'Hindi Male',
|
||||
if: 'Italian Female',
|
||||
im: 'Italian Male',
|
||||
jf: 'Japanese Female',
|
||||
jm: 'Japanese Male',
|
||||
pf: 'Brazilian Portuguese Female',
|
||||
pm: 'Brazilian Portuguese Male',
|
||||
zf: 'Mandarin Chinese Female',
|
||||
zm: 'Mandarin Chinese Male',
|
||||
};
|
||||
|
||||
type VoiceGroup = { label: string; voices: string[] };
|
||||
|
||||
async function fetchHuggingFaceVoices(repoId: string): Promise<{ flat: string[]; groups: VoiceGroup[] }> {
|
||||
const res = await fetch(`https://huggingface.co/api/models/${repoId}/tree/main/voices`);
|
||||
if (!res.ok) return { flat: [], groups: [] };
|
||||
const files = (await res.json()) as { path: string; type: string }[];
|
||||
const names = new Set<string>();
|
||||
for (const f of files) {
|
||||
if (f.type !== 'file') continue;
|
||||
const name = f.path.replace('voices/', '').replace(/\.(pt|safetensors)$/, '');
|
||||
names.add(name);
|
||||
}
|
||||
const sorted = [...names].sort();
|
||||
const groupMap = new Map<string, string[]>();
|
||||
for (const name of sorted) {
|
||||
const prefix = name.slice(0, 2);
|
||||
if (!groupMap.has(prefix)) groupMap.set(prefix, []);
|
||||
groupMap.get(prefix)!.push(name);
|
||||
}
|
||||
const groups: VoiceGroup[] = [];
|
||||
for (const [prefix, voices] of groupMap) {
|
||||
groups.push({ label: VOICE_GROUP_LABELS[prefix] ?? prefix, voices });
|
||||
}
|
||||
return { flat: sorted, groups };
|
||||
}
|
||||
|
||||
ttsRouter.post('/test', async (ctx) => {
|
||||
const body = await ctx.req.json<TtsConfig>();
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, readdir, rm, cp } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
|
||||
|
||||
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
||||
|
||||
export const workspacesRouter = createRouter();
|
||||
|
||||
// GET /workspaces
|
||||
@@ -48,8 +51,25 @@ workspacesRouter.patch('/', async (ctx) => {
|
||||
await writeJsonFile(metaFile, value);
|
||||
|
||||
if (isNew) {
|
||||
const proc = Bun.spawn(['git', 'init', projectDir]);
|
||||
await proc.exited;
|
||||
const meta = value as Record<string, unknown>;
|
||||
if (meta.projectType === 'app') {
|
||||
const templateDir = join(TEMPLATES_DIR, 'simple-app-template');
|
||||
const entries = readdirSync(templateDir);
|
||||
for (const entry of entries) {
|
||||
if (entry === '.git' || entry === '.officerdev') continue;
|
||||
await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true });
|
||||
}
|
||||
const pkgPath = join(projectDir, 'package.json');
|
||||
const pkg = await Bun.file(pkgPath).json().catch(() => null);
|
||||
if (pkg) {
|
||||
pkg.name = slug;
|
||||
await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
}
|
||||
const install = Bun.spawn(['bun', 'install'], { cwd: projectDir, stdout: 'ignore', stderr: 'ignore' });
|
||||
await install.exited;
|
||||
}
|
||||
const gitInit = Bun.spawn(['git', 'init', projectDir], { stdout: 'ignore', stderr: 'ignore' });
|
||||
await gitInit.exited;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="p-1 rounded text-duck-dark/30 hover:text-duck-dark/60 transition-colors cursor-pointer"
|
||||
className="p-1 rounded text-duck-dark dark:text-white opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
{state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{state === 'playing' && <Square className="h-3.5 w-3.5" />}
|
||||
@@ -133,9 +133,9 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
{injectImages(assistantText)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-0.5">
|
||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||
<div className="flex justify-end -mb-1 -mr-1">
|
||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,13 @@ export type PreviewContextValue = {
|
||||
port: number | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
stopped: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
iframeKey: number;
|
||||
projects: ProjectDefinition[];
|
||||
startServer: (slug: string) => void;
|
||||
stopServer: () => void;
|
||||
restartServer: () => void;
|
||||
refresh: () => void;
|
||||
setSelectedSlug: (slug: string | null) => void;
|
||||
clearError: () => void;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Globe, RefreshCw, Square } from 'lucide-react';
|
||||
import { Globe, RefreshCw, Square, Play } from 'lucide-react';
|
||||
import { usePreview } from './PreviewContext';
|
||||
|
||||
export const PreviewHeader = () => {
|
||||
const { slug, url, port, isSuperAdmin, refresh, stopServer } = usePreview();
|
||||
const { slug, url, port, stopped, isSuperAdmin, refresh, stopServer, restartServer } = usePreview();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -32,6 +32,16 @@ export const PreviewHeader = () => {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!url && stopped && slug && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={restartServer}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Start server"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [stopped, setStopped] = useState(false);
|
||||
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const cwdSlug = extractSlug(cwd);
|
||||
@@ -31,6 +32,7 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
const startServer = useCallback(async (targetSlug: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStopped(false);
|
||||
try {
|
||||
const res = await client.post<DevServerResponse>('/dev-server/start', { slug: targetSlug });
|
||||
setUrl(res.url);
|
||||
@@ -52,8 +54,14 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
}
|
||||
setUrl(null);
|
||||
setPort(null);
|
||||
setStopped(true);
|
||||
}, [slug, client]);
|
||||
|
||||
const restartServer = useCallback(() => {
|
||||
if (!slug) return;
|
||||
startServer(slug);
|
||||
}, [slug, startServer]);
|
||||
|
||||
const refresh = useCallback(() => setIframeKey((k) => k + 1), []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
@@ -108,8 +116,8 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
return (
|
||||
<PreviewContext
|
||||
value={{
|
||||
slug, cwdSlug, url, port, loading, error, isSuperAdmin, iframeKey, projects,
|
||||
startServer, stopServer, refresh, setSelectedSlug, clearError,
|
||||
slug, cwdSlug, url, port, loading, error, stopped, isSuperAdmin, iframeKey, projects,
|
||||
startServer, stopServer, restartServer, refresh, setSelectedSlug, clearError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2 } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -34,10 +34,6 @@ type LayoutTemplate = {
|
||||
};
|
||||
|
||||
const templates: LayoutTemplate[] = [
|
||||
{
|
||||
name: 'Single',
|
||||
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
||||
},
|
||||
{
|
||||
name: '2 Columns',
|
||||
layout: () => ({
|
||||
@@ -142,6 +138,40 @@ const templates: LayoutTemplate[] = [
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Cols + Split Bottom',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 30,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Template Thumbnails ---
|
||||
@@ -233,8 +263,8 @@ const TemplatePanel = () => (
|
||||
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||
|
||||
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||
{ value: 'landing-page', label: 'Landing Page' },
|
||||
{ value: 'website', label: 'Website' },
|
||||
// { value: 'landing-page', label: 'Landing Page' },
|
||||
// { value: 'website', label: 'Website' },
|
||||
{ value: 'app', label: 'App' },
|
||||
];
|
||||
|
||||
@@ -294,7 +324,7 @@ const DetailsPanel = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{projectType === 'app' && (
|
||||
{/* {projectType === 'app' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -315,7 +345,7 @@ const DetailsPanel = () => {
|
||||
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -323,6 +353,21 @@ const DetailsPanel = () => {
|
||||
|
||||
// --- Panel: Create ---
|
||||
|
||||
const assignDefaultApps = (layout: LayoutNode, apps: string[]): LayoutNode => {
|
||||
const remaining = [...apps];
|
||||
const walk = (node: LayoutNode): LayoutNode => {
|
||||
if (remaining.length === 0) return node;
|
||||
if (node.type === 'panel' && node.appType === null) {
|
||||
return { ...node, appType: remaining.shift()! };
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
return { ...node, children: node.children.map((c) => ({ ...c, node: walk(c.node) })) };
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return walk(layout);
|
||||
};
|
||||
|
||||
const CreatePanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -339,13 +384,14 @@ const CreatePanel = () => {
|
||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
const slug = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed || isSubmitting) return;
|
||||
const desc = description.trim();
|
||||
|
||||
const meta = {
|
||||
@@ -370,17 +416,23 @@ const CreatePanel = () => {
|
||||
let id = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setIsSubmitting(true);
|
||||
const finalLayout = projectType === 'app'
|
||||
? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview'])
|
||||
: previewLayout;
|
||||
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: previewLayout })
|
||||
.then((res) => {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||
navigate(`/projects/${id}`);
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
const res = await client.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
navigate(`/projects/${id}`);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -394,11 +446,11 @@ const CreatePanel = () => {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim()}
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{isEditing ? 'Update Project' : 'Create Project'}
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
{isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
||||
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
|
||||
});
|
||||
|
||||
@@ -73,6 +74,9 @@ export type UserSettings = {
|
||||
default: string;
|
||||
translateTo: string;
|
||||
};
|
||||
tts: {
|
||||
voice: string | null;
|
||||
};
|
||||
onboarding: {
|
||||
complete: boolean;
|
||||
};
|
||||
@@ -107,6 +111,9 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
default: 'en',
|
||||
translateTo: 'en',
|
||||
},
|
||||
tts: {
|
||||
voice: null,
|
||||
},
|
||||
onboarding: {
|
||||
complete: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user