import { zodResolver } from '@hookform/resolvers/zod'; import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Progress } from '@/components/ui/progress'; import { Toggle } from '@/components/ui/toggle'; import { useToast } from '@/components/ui/use-toast'; import { useAutoUpdater } from '@/hooks/useAutoUpdater'; import { useServerHealth } from '@/lib/hooks/useServer'; import { usePlatform } from '@/platform/PlatformContext'; import { useServerStore } from '@/stores/serverStore'; import { CloudSection } from './CloudSection'; import { LanguageSelect } from './LanguageSelect'; import { SettingRow, SettingSection } from './SettingRow'; import { ThemeSelect } from './ThemeSelect'; function makeConnectionSchema(invalidUrl: string) { return z.object({ serverUrl: z.string().url(invalidUrl), }); } type ConnectionFormValues = { serverUrl: string }; export function GeneralPage() { const { t } = useTranslation(); const platform = usePlatform(); const serverUrl = useServerStore((state) => state.serverUrl); const setServerUrl = useServerStore((state) => state.setServerUrl); const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose); const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose); const mode = useServerStore((state) => state.mode); const setMode = useServerStore((state) => state.setMode); const { toast } = useToast(); const { data: health, isLoading, error: healthError } = useServerHealth(); const resolver = useMemo( () => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))), [t], ); const form = useForm({ resolver, defaultValues: { serverUrl }, }); useEffect(() => { form.reset({ serverUrl }); }, [serverUrl, form]); // Re-run validation when the locale changes so existing error messages retranslate. useEffect(() => { if (form.formState.errors.serverUrl) { form.trigger('serverUrl'); } }, [t, form]); const { isDirty } = form.formState; function onSubmit(data: ConnectionFormValues) { setServerUrl(data.serverUrl); form.reset(data); toast({ title: t('settings.general.serverUrl.updatedTitle'), description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }), }); } return (
{t('settings.general.docs.title')}
docs.voicebox.sh
{t('settings.general.discord.title')}
{t('settings.general.discord.subtitle')}
} >
( )} /> {isDirty && ( )}
{ setKeepServerRunningOnClose(checked); platform.lifecycle.setKeepServerRunning(checked).catch((error) => { console.error('Failed to sync setting to Rust:', error); setKeepServerRunningOnClose(!checked); toast({ title: t('settings.general.keepServerRunning.failedTitle'), description: t('settings.general.keepServerRunning.failedDescription'), variant: 'destructive', }); return; }); toast({ title: t('settings.general.keepServerRunning.updatedTitle'), description: checked ? t('settings.general.keepServerRunning.runningDescription') : t('settings.general.keepServerRunning.stoppedDescription'), }); }} /> } /> {platform.metadata.isTauri && ( { setMode(checked ? 'remote' : 'local'); toast({ title: t('settings.general.networkAccess.updatedTitle'), description: checked ? t('settings.general.networkAccess.enabled') : t('settings.general.networkAccess.disabled'), }); }} /> } /> )} } /> } />
{platform.metadata.isTauri && }
); } function ConnectionStatus({ health, isLoading, healthError, }: { health: ReturnType['data']; isLoading: boolean; healthError: ReturnType['error']; }) { const { t } = useTranslation(); if (isLoading) { return (
{t('settings.general.connection.connecting')}
); } if (healthError) { return (
{t('settings.general.connection.offline')}
); } if (health) { return (
{t('settings.general.connection.online')}
); } return null; } function UpdatesSection() { const { t } = useTranslation(); const platform = usePlatform(); const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false); const [currentVersion, setCurrentVersion] = useState(''); const isDev = !import.meta.env?.PROD; useEffect(() => { platform.metadata .getVersion() .then(setCurrentVersion) .catch(() => setCurrentVersion(null)); }, [platform]); const versionLabel = currentVersion ?? t('common.unknown'); return ( {isDev ? ( ) : ( <> {t('settings.general.updates.check.button')} } /> {status.error && (
{status.error}
)} {status.available && !status.downloading && !status.readyToInstall && ( {t('settings.general.updates.download.button')} } /> )} {status.downloading && (
{status.downloadedBytes !== undefined && status.totalBytes !== undefined && status.totalBytes > 0 ? ( {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '} {(status.totalBytes / 1024 / 1024).toFixed(1)} MB ) : ( )} {status.downloadProgress !== undefined && {status.downloadProgress}%}
)} {status.readyToInstall && ( {t('settings.general.updates.ready.button')} } /> )} )}
); } function ApiReferenceCard({ serverUrl }: { serverUrl: string }) { const { t } = useTranslation(); const endpoints = [ { method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') }, { method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') }, { method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') }, { method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') }, ]; return (

{t('settings.general.api.title')}

, }} />

{endpoints.map((ep) => (
{ep.method} {ep.path} {ep.label}
))}

{t('settings.general.api.viewReference')}

); }