server settings keyed (352 -> 267)

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.
This commit is contained in:
2026-08-23 05:04:22 +00:00
parent 9b0376ec39
commit 44f5246e38
9 changed files with 296 additions and 88 deletions
+4
View File
@@ -85,6 +85,10 @@ function isHuman(value: string | undefined): value is string {
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads.
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
@@ -13,6 +13,7 @@ import {
CommandList,
} from '@/components/ui/command';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type StoredApiKeys = { keys: { provider: string; value: string }[] };
@@ -38,6 +39,7 @@ type ChatProvider = { key: string; providerId: string };
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
export const AIHarnessesSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [installing, setInstalling] = useState(false);
@@ -168,7 +170,7 @@ export const AIHarnessesSection = () => {
// No auth needed — save directly
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'url' });
}
};
@@ -192,13 +194,13 @@ export const AIHarnessesSection = () => {
return;
}
if (result.needsAuth) {
toast.error('Authentication failed');
toast.error(t('settings.server.harnesses.authFailed'));
setProbeState({ step: 'auth', probe });
return;
}
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'auth', probe });
}
};
@@ -218,10 +220,10 @@ export const AIHarnessesSection = () => {
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_HEALTH'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success(`Connected to ${probe.name}`);
toast.success(t('settings.server.harnesses.connectedTo', { name: probe.name ?? '' }));
resetLocalForm();
} catch {
toast.error('Failed to save provider');
toast.error(t('settings.server.harnesses.saveFailed'));
setProbeState({ step: 'url' });
}
};
@@ -254,9 +256,9 @@ export const AIHarnessesSection = () => {
<div className="flex flex-col gap-4">
<div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
<span className="text-sm font-medium text-duck-dark">{t('settings.server.harnesses.piMono')}</span>
{piMonoLoading ? (
<span className="text-xs text-duck-dark/50">Checking...</span>
<span className="text-xs text-duck-dark/50">{t('settings.server.harnesses.checking')}</span>
) : piMonoVersion?.version ? (
<div className="flex items-center gap-2">
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
@@ -265,7 +267,7 @@ export const AIHarnessesSection = () => {
onClick={installAgent}
disabled={installing}
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Update Pi Mono"
title={t('settings.server.harnesses.updatePiMono')}
>
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
</button>
@@ -285,13 +287,21 @@ export const AIHarnessesSection = () => {
<>
{/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.localProviders')}
</span>
<div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
title={
lp.id in localHealth
? localHealth[lp.id]
? 'Online'
: 'Offline'
: t('settings.server.harnesses.checking')
}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
@@ -299,7 +309,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Remove provider"
title={t('settings.server.harnesses.removeProvider')}
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
@@ -312,7 +322,7 @@ export const AIHarnessesSection = () => {
<Input
type="text"
className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)"
placeholder={t('settings.server.harnesses.namePlaceholder')}
value={localName}
onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => {
@@ -340,7 +350,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()}
>
Connect
{t('settings.server.harnesses.connect')}
</Button>
)}
{(probeState.step === 'probing' || probeState.step === 'saving') && (
@@ -351,7 +361,7 @@ export const AIHarnessesSection = () => {
onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</div>
</form>
@@ -370,7 +380,7 @@ export const AIHarnessesSection = () => {
<Input
type="text"
className="h-8 text-xs"
placeholder="Username"
placeholder={t('settings.server.harnesses.username')}
value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus
@@ -378,7 +388,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-8 text-xs"
placeholder="Password"
placeholder={t('settings.server.harnesses.password')}
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
/>
@@ -388,7 +398,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword}
>
Authenticate
{t('settings.server.harnesses.authenticate')}
</Button>
</>
) : (
@@ -396,7 +406,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-8 text-xs"
placeholder="API Key"
placeholder={t('settings.server.harnesses.apiKey')}
value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)}
autoFocus
@@ -407,7 +417,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey}
>
Authenticate
{t('settings.server.harnesses.authenticate')}
</Button>
</>
)}
@@ -423,7 +433,7 @@ export const AIHarnessesSection = () => {
onClick={() => setAddingLocal(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider
{t('settings.server.harnesses.connectLocal')}
</Button>
)}
</div>
@@ -431,7 +441,9 @@ export const AIHarnessesSection = () => {
{/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.remoteProviders')}
</span>
<div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => (
<div key={provider.providerId} className="flex flex-col gap-2">
@@ -457,7 +469,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Edit key"
title={t('settings.server.harnesses.editKey')}
>
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
</button>
@@ -465,7 +477,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => disconnectProvider(provider)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider"
title={t('settings.server.harnesses.disconnect')}
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
@@ -478,7 +490,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
placeholder={getStoredMasked(provider.providerId) || t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -492,7 +504,7 @@ export const AIHarnessesSection = () => {
type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
title={t('settings.server.harnesses.saveKey')}
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -501,7 +513,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</form>
)}
@@ -524,7 +536,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder="Enter API key"
placeholder={t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -538,7 +550,7 @@ export const AIHarnessesSection = () => {
type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
title={t('settings.server.harnesses.saveKey')}
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -547,7 +559,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</form>
);
@@ -561,14 +573,14 @@ export const AIHarnessesSection = () => {
onClick={() => setCommandOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider
{t('settings.server.harnesses.connectProvider')}
</Button>
)}
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." />
<CommandInput placeholder={t('settings.server.harnesses.search')} />
<CommandList>
<CommandEmpty>No providers found.</CommandEmpty>
<CommandEmpty>{t('settings.server.harnesses.none')}</CommandEmpty>
<CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => (
<CommandItem
@@ -3,6 +3,7 @@ import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
/**
* The two ways to load a change without leaving the browser.
@@ -30,6 +31,7 @@ export function errorMessage(err: unknown, fallback: string): string {
}
export const MaintenanceSection = () => {
const { t } = useTranslation();
const client = useClient();
const [pulling, setPulling] = useState(false);
const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null);
@@ -58,11 +60,11 @@ export const MaintenanceSection = () => {
}>('/server-settings/pull', {});
if (!res.updated) {
setPulled(null);
toast.success('Already up to date');
toast.success(t('settings.server.maintenance.upToDate'));
return;
}
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`);
toast.success(t('settings.server.maintenance.pulled', { count: res.commits.length }));
} catch (err) {
toast.error(errorMessage(err, 'Pull failed'));
} finally {
@@ -81,7 +83,7 @@ export const MaintenanceSection = () => {
setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`);
// Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is
// the difference between "it worked" and "why does my change not show".
toast.success('Frontend rebuilt — refresh this page to see it');
toast.success(t('settings.server.maintenance.rebuilt'));
} catch (err) {
// The client throws on any status >= 400 with the raw response body as its message, so a build
// failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader
@@ -96,7 +98,7 @@ export const MaintenanceSection = () => {
setRestarting(true);
try {
await client.post('/server-settings/restart', {});
toast.success('Restarting — the page will reconnect on its own');
toast.success(t('settings.server.maintenance.restarting'));
} catch (err) {
/**
* Two failures arrive here and they are opposites.
@@ -116,7 +118,7 @@ export const MaintenanceSection = () => {
setRestarting(false);
return;
}
toast.success('Restarting — the page will reconnect on its own');
toast.success(t('settings.server.maintenance.restarting'));
}
// Left disabled on the success path, deliberately: the button must not become pressable again while
// the server is coming back.
@@ -126,14 +128,12 @@ export const MaintenanceSection = () => {
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Update the platform</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.updateTitle')}</div>
<p className="mt-1 text-xs text-muted-foreground">
Pulls this server&rsquo;s repository. Refuses if anything is uncommitted or the checkout has diverged — it
will never discard local work.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
</p>
<p className="mt-1 text-xs text-muted-foreground">{t('settings.server.maintenance.updateHint')}</p>
{pulled && (
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
<p className="font-mono text-[11px] text-muted-foreground">
@@ -163,7 +163,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.rebuild')}</div>
<p className="mt-1 text-xs text-muted-foreground">
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
refresh the page when it finishes.
@@ -178,7 +178,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Restart the server</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.restart')}</div>
<p className="mt-1 text-xs text-muted-foreground">
The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding
cannot see an edit to the backend.
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type OcrConfig = {
url: string;
@@ -15,6 +16,7 @@ type OcrConfig = {
};
export const OCRSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -59,9 +61,9 @@ export const OCRSection = () => {
try {
await client.put('/server-settings/ocr', { url, model });
queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] });
toast.success('OCR settings saved');
toast.success(t('settings.server.ocr.saved'));
} catch {
toast.error('Failed to save OCR settings');
toast.error(t('settings.server.ocr.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -75,7 +77,7 @@ export const OCRSection = () => {
if (res.error) {
toast.error(res.error);
} else {
toast.success('Vision model server is reachable');
toast.success(t('settings.server.ocr.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -91,12 +93,12 @@ export const OCRSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
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">Vision Model Server URL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.ocr.serverUrl')}</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:64203"
@@ -107,13 +109,13 @@ export const OCRSection = () => {
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<button
type="button"
onClick={() => fetchModels(url)}
disabled={modelsLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh models"
title={t('settings.server.ocr.refreshModels')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${modelsLoading ? 'animate-spin' : ''}`}
@@ -123,7 +125,7 @@ export const OCRSection = () => {
{models.length > 0 ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a model" />
<SelectValue placeholder={t('settings.server.ocr.selectModel')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{models.map((m) => (
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type Provider = 'resend' | 'smtp' | 'mailhog';
@@ -31,6 +32,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<SmtpConfig>> = {
};
export const SMTPSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { user } = useAuth();
@@ -92,9 +94,9 @@ export const SMTPSection = () => {
try {
await client.put('/server-settings/smtp', buildConfig());
queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] });
toast.success('SMTP settings saved');
toast.success(t('settings.server.smtp.saved'));
} catch {
toast.error('Failed to save SMTP settings');
toast.error(t('settings.server.smtp.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -105,7 +107,7 @@ export const SMTPSection = () => {
setIsTestingConnection(true);
try {
await client.post('/server-settings/smtp/test-connection');
toast.success('Connection successful');
toast.success(t('settings.server.smtp.connected'));
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
@@ -132,7 +134,7 @@ export const SMTPSection = () => {
if (result.error) {
toast.error(result.error);
} else {
toast.success(`Test email sent to ${testEmail}`);
toast.success(t('settings.server.smtp.testSent', { email: testEmail }));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -148,7 +150,7 @@ export const SMTPSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
const showSmtpFields = provider === 'smtp' || provider === 'mailhog';
@@ -156,22 +158,22 @@ export const SMTPSection = () => {
<div className="grid gap-4">
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
<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="mailhog">MailHog (local testing)</SelectItem>
<SelectItem value="resend">Resend</SelectItem>
<SelectItem value="smtp">Custom SMTP</SelectItem>
<SelectItem value="mailhog">{t('settings.server.smtp.mailhog')}</SelectItem>
<SelectItem value="resend">{t('settings.server.smtp.resend')}</SelectItem>
<SelectItem value="smtp">{t('settings.server.smtp.custom')}</SelectItem>
</SelectContent>
</Select>
</Label>
{provider === 'resend' && (
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.apiKey')}</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"
@@ -186,7 +188,7 @@ export const SMTPSection = () => {
<>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.host')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="smtp.example.com"
@@ -195,7 +197,7 @@ export const SMTPSection = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.port')}</span>
<Input
type="number"
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -208,16 +210,20 @@ export const SMTPSection = () => {
<>
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.username')}
</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="user@example.com"
placeholder={t('settings.server.smtp.userExample')}
value={username}
onChange={(ev) => setUsername(ev.target.value)}
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.password')}
</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -228,7 +234,7 @@ export const SMTPSection = () => {
</div>
<Label className="flex items-center gap-3">
<Switch checked={secure} onCheckedChange={setSecure} />
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.tls')}</span>
</Label>
</>
)}
@@ -237,7 +243,7 @@ export const SMTPSection = () => {
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromName')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
value={fromName}
@@ -245,7 +251,7 @@ export const SMTPSection = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromEmail')}</span>
<Input
type="email"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -276,12 +282,14 @@ export const SMTPSection = () => {
</form>
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.smtp.sendTest')}
</span>
<form onSubmit={handleTest} className="flex items-center gap-2 mt-2">
<Input
type="email"
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
placeholder="recipient@example.com"
placeholder={t('settings.server.smtp.recipientExample')}
value={testEmail}
onChange={(ev) => setTestEmail(ev.target.value)}
/>
@@ -6,12 +6,14 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type SttConfig = {
url: string;
};
export const STTSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -36,9 +38,9 @@ export const STTSection = () => {
try {
await client.put('/server-settings/stt', { url });
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
toast.success('STT settings saved');
toast.success(t('settings.server.stt.saved'));
} catch {
toast.error('Failed to save STT settings');
toast.error(t('settings.server.stt.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -52,7 +54,7 @@ export const STTSection = () => {
if (res.error) {
toast.error(res.error);
} else {
toast.success('Whisper server is reachable');
toast.success(t('settings.server.stt.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -68,12 +70,12 @@ export const STTSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
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">Whisper Server URL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</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:64201"
@@ -16,6 +16,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type Provider = 'openai' | 'elevenlabs';
@@ -33,6 +34,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<TtsConfig>> = {
};
export const TTSSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -110,9 +112,9 @@ export const TTSSection = () => {
try {
await client.put('/server-settings/tts', buildConfig());
queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] });
toast.success('TTS settings saved');
toast.success(t('settings.server.tts.saved'));
} catch {
toast.error('Failed to save TTS settings');
toast.error(t('settings.server.tts.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -143,27 +145,27 @@ export const TTSSection = () => {
const audio = new Audio(audioUrl);
audioRef.current = audio;
audio.play();
toast.success('Playing test audio');
toast.success(t('settings.server.tts.playing'));
} catch {
toast.error('Test failed — could not reach TTS server');
toast.error(t('settings.server.tts.testFailed'));
} finally {
setIsTesting(false);
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
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">Provider</span>
<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">OpenAI-compatible (Kokoro, OpenAI, etc.)</SelectItem>
<SelectItem value="elevenlabs">ElevenLabs</SelectItem>
<SelectItem value="openai">{t('settings.server.tts.openaiCompatible')}</SelectItem>
<SelectItem value="elevenlabs">{t('settings.server.tts.elevenLabs')}</SelectItem>
</SelectContent>
</Select>
</Label>
@@ -195,7 +197,7 @@ export const TTSSection = () => {
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
<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'}
@@ -205,13 +207,13 @@ export const TTSSection = () => {
</Label>
<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>
<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="Refresh voices"
title={t('settings.server.tts.refreshVoices')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
@@ -221,7 +223,7 @@ export const TTSSection = () => {
{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="Select a voice" />
<SelectValue placeholder={t('settings.server.tts.selectVoice')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{voiceGroups.length > 0
+89
View File
@@ -220,6 +220,95 @@
"integrations": {
"enterprise": "Enterprise",
"personal": "Personal"
},
"server": {
"loading": "Loading…",
"provider": "Provider",
"model": "Model",
"voice": "Voice",
"ocr": {
"serverUrl": "Vision Model Server URL",
"refreshModels": "Refresh models",
"selectModel": "Select a model",
"saved": "OCR settings saved",
"saveFailed": "Failed to save OCR settings",
"reachable": "Vision model server is reachable"
},
"stt": {
"serverUrl": "Whisper Server URL",
"saved": "STT settings saved",
"saveFailed": "Failed to save STT settings",
"reachable": "Whisper server is reachable"
},
"tts": {
"openaiCompatible": "OpenAI-compatible (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
"refreshVoices": "Refresh voices",
"selectVoice": "Select a voice",
"saved": "TTS settings saved",
"saveFailed": "Failed to save TTS settings",
"playing": "Playing test audio",
"testFailed": "Test failed — could not reach TTS server"
},
"smtp": {
"mailhog": "MailHog (local testing)",
"resend": "Resend",
"custom": "Custom SMTP",
"apiKey": "API Key",
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"tls": "TLS / SSL",
"fromName": "From Name",
"fromEmail": "From Email",
"sendTest": "Send Test Email",
"userExample": "user@example.com",
"recipientExample": "recipient@example.com",
"saved": "SMTP settings saved",
"saveFailed": "Failed to save SMTP settings",
"connected": "Connection successful",
"testSent": "Test email sent to {{email}}"
},
"maintenance": {
"updateTitle": "Update the platform",
"updateHint": "Changes nothing that is running. Whatever the update needs is listed here, and stays your call.",
"rebuild": "Rebuild the frontend",
"restart": "Restart the server",
"upToDate": "Already up to date",
"rebuilt": "Frontend rebuilt — refresh this page to see it",
"restarting": "Restarting — the page will reconnect on its own",
"pulled": {
"one": "Pulled {{count}} commit",
"other": "Pulled {{count}} commits"
}
},
"harnesses": {
"piMono": "Pi Mono",
"checking": "Checking…",
"localProviders": "Local Providers",
"remoteProviders": "Remote Providers",
"connect": "Connect",
"authenticate": "Authenticate",
"connectLocal": "Connect Local Provider",
"connectProvider": "Connect Provider",
"none": "No providers found.",
"updatePiMono": "Update Pi Mono",
"removeProvider": "Remove provider",
"namePlaceholder": "Name (e.g. My Ollama)",
"username": "Username",
"password": "Password",
"apiKey": "API Key",
"editKey": "Edit key",
"disconnect": "Disconnect provider",
"saveKey": "Save key",
"enterKey": "Enter API key",
"search": "Search providers…",
"connectFailed": "Failed to connect",
"authFailed": "Authentication failed",
"saveFailed": "Failed to save provider",
"connectedTo": "Connected to {{name}}"
}
}
},
"header": {
+89
View File
@@ -220,6 +220,95 @@
"integrations": {
"enterprise": "Empresa",
"personal": "Pessoal"
},
"server": {
"loading": "A carregar…",
"provider": "Fornecedor",
"model": "Modelo",
"voice": "Voz",
"ocr": {
"serverUrl": "URL do servidor do modelo de visão",
"refreshModels": "Atualizar modelos",
"selectModel": "Selecione um modelo",
"saved": "Definições de OCR guardadas",
"saveFailed": "Falha ao guardar as definições de OCR",
"reachable": "O servidor do modelo de visão está acessível"
},
"stt": {
"serverUrl": "URL do servidor Whisper",
"saved": "Definições de STT guardadas",
"saveFailed": "Falha ao guardar as definições de STT",
"reachable": "O servidor Whisper está acessível"
},
"tts": {
"openaiCompatible": "Compatível com OpenAI (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
"refreshVoices": "Atualizar vozes",
"selectVoice": "Selecione uma voz",
"saved": "Definições de TTS guardadas",
"saveFailed": "Falha ao guardar as definições de TTS",
"playing": "A reproduzir áudio de teste",
"testFailed": "Teste falhou — não foi possível contactar o servidor de TTS"
},
"smtp": {
"mailhog": "MailHog (testes locais)",
"resend": "Resend",
"custom": "SMTP personalizado",
"apiKey": "Chave de API",
"host": "Servidor",
"port": "Porta",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"tls": "TLS / SSL",
"fromName": "Nome do remetente",
"fromEmail": "Email do remetente",
"sendTest": "Enviar email de teste",
"userExample": "utilizador@exemplo.com",
"recipientExample": "destinatario@exemplo.com",
"saved": "Definições de SMTP guardadas",
"saveFailed": "Falha ao guardar as definições de SMTP",
"connected": "Ligação estabelecida",
"testSent": "Email de teste enviado para {{email}}"
},
"maintenance": {
"updateTitle": "Atualizar a plataforma",
"updateHint": "Não altera nada que esteja em execução. O que a atualização precisa está listado aqui, e a decisão é sua.",
"rebuild": "Reconstruir o frontend",
"restart": "Reiniciar o servidor",
"upToDate": "Já está atualizado",
"rebuilt": "Frontend reconstruído — atualize esta página para o ver",
"restarting": "A reiniciar — a página volta a ligar-se sozinha",
"pulled": {
"one": "{{count}} commit obtido",
"other": "{{count}} commits obtidos"
}
},
"harnesses": {
"piMono": "Pi Mono",
"checking": "A verificar…",
"localProviders": "Fornecedores locais",
"remoteProviders": "Fornecedores remotos",
"connect": "Ligar",
"authenticate": "Autenticar",
"connectLocal": "Ligar fornecedor local",
"connectProvider": "Ligar fornecedor",
"none": "Nenhum fornecedor encontrado.",
"updatePiMono": "Atualizar Pi Mono",
"removeProvider": "Remover fornecedor",
"namePlaceholder": "Nome (ex. O meu Ollama)",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"apiKey": "Chave de API",
"editKey": "Editar chave",
"disconnect": "Desligar fornecedor",
"saveKey": "Guardar chave",
"enterKey": "Introduza a chave de API",
"search": "Pesquisar fornecedores…",
"connectFailed": "Falha ao ligar",
"authFailed": "Falha na autenticação",
"saveFailed": "Falha ao guardar o fornecedor",
"connectedTo": "Ligado a {{name}}"
}
}
},
"header": {