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:
@@ -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'`.
|
||||
//
|
||||
|
||||
+44
-32
@@ -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
|
||||
|
||||
+11
-11
@@ -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’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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user