diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 469d3ea1..7cbdbc57 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -169,6 +169,27 @@ class MyEngineBackend(TTSBackend):
---
+## Frontend file structure & size limits
+
+Frontend code stays modular so an edit loads one small file, not a 1900-line
+one. The rules:
+
+- **Size caps:** **soft 300 lines**, **hard 500 lines** per `.jsx`/`.css` file.
+ Anything over 500 lines must be split.
+- **Pages are thin orchestrators.** A file in `frontend/src/pages/` is just
+ layout + routing + state wiring that composes feature components — no inline
+ sub-component over ~50 lines.
+- **One component per file.** Co-locate `Foo.jsx` + `Foo.css` + `Foo.test.jsx`
+ together in a per-page feature folder under `frontend/src/components/` (e.g.
+ `components/settings/`, `components/dub/`).
+- **Shared bits go in a `primitives/` folder** inside the feature folder
+ (`components/settings/primitives/` is the existing example).
+- **Enforced by ESLint `max-lines`** (`max: 500`) — **warn-only for now** so it
+ never breaks CI, with the goal of upgrading to `error` once the backlog of
+ oversized files clears.
+
+---
+
## Commit Messages
Write clear, concise messages. The PR title becomes the squash-merge commit.
diff --git a/docs/maintenance-pages-modularization.md b/docs/maintenance-pages-modularization.md
new file mode 100644
index 00000000..5d44b277
--- /dev/null
+++ b/docs/maintenance-pages-modularization.md
@@ -0,0 +1,93 @@
+# Maintenance Refactor — `frontend/src/pages` Modularization
+
+**Status:** Plan (not yet executed) · **Drafted:** 2026-06-30 · **Type:** Pure mechanical refactor, no behavior change
+
+## Why
+
+`frontend/src/pages/` has grown a few files large enough that any edit reloads the
+whole thing into context and risks unrelated breakage. Editing one Settings panel
+should touch a ~150-line file, not a 1969-line one. This both improves
+maintainability and cuts token cost per edit.
+
+The fix is **not** a new architecture — `components/settings/` already proves the
+target pattern (13 extracted `*Panel.jsx`, each with co-located `.css`/`.test.jsx`,
+plus a shared `primitives/` folder). This refactor **finishes a migration that
+stalled**, then locks it in so files can't silently regrow.
+
+## Current state (measured 2026-06-30)
+
+| File | Lines | Notes |
+|------|------:|-------|
+| `pages/Settings.jsx` | 1969 | Still inline: `ModelStoreTab` (~790L), `Settings` orchestrator (~600L), `GeneralTab`, `EnginesTab`, `HotkeyTab`, `CredentialsTab`, plus `Row`/`fmtBytes`/`orgColor` helpers |
+| `pages/DubTab.jsx` | 1592 | One mega-component + inline `DubFailureNotice`, `DubPipelineStepper`, `PrepOverlay`, `TranscribeOverlay`, `FooterBtn` |
+| `pages/CloneDesignTab.jsx` | 837 | |
+| `pages/VoiceGallery.jsx` | 768 | |
+| `pages/VoiceProfile.jsx` | 515 | |
+| `pages/AudiobookTab.jsx` | 402 | within target after Phase 3 sweep |
+| everything else | <340 | within target |
+
+Already-extracted, do **not** touch (reference pattern): `components/settings/*Panel.jsx`,
+`components/settings/primitives/`.
+
+## The gold standard (proposed)
+
+1. **Size caps:** soft **300 lines**, hard **500 lines** per `.jsx`/`.css`. Over 500 must split.
+2. **Pages are thin orchestrators:** a page = layout + routing + state wiring that
+ composes feature components. No inline sub-component over ~50 lines.
+3. **One component per file**, co-located `Foo.jsx` + `Foo.css` + `Foo.test.jsx`,
+ grouped in a per-page feature folder:
+ - `components/settings/` (exists)
+ - `components/dub/` (new)
+ - `components/clone/` (new)
+ - `components/gallery/` (new)
+4. **Shared bits → `primitives/`** in the feature folder (settings already has this).
+5. **Enforce with ESLint `max-lines`** — **warn-only first** so it never breaks CI
+ (respects the "keep main green" rule), upgrade to error after the backlog clears.
+
+## Phases (each = one mergeable, CI-green PR)
+
+### Phase 0 — Standard + guardrail
+- Add the size/structure rule to `CONTRIBUTING.md` (required by the docs-sync rule anyway).
+- Add ESLint `max-lines: ['warn', { max: 500, skipBlankLines: true, skipComments: true }]`.
+- No code moves. Smallest possible PR; establishes the contract.
+
+### Phase 1 — `Settings.jsx` (biggest win: 1969 → ~300L)
+Extract into `components/settings/`, mirroring existing panel naming:
+| Extract | Current lines (approx) | New file |
+|---------|------------------------|----------|
+| `ModelStoreTab` (+ `Row`, `fmtBytes`, `orgColor`, `MODEL_ROLE_*`) | 229–1021 | `ModelStoreTab.jsx` (likely split further: table vs. matrix vs. row) |
+| `GeneralTab` | 80–201 | `GeneralTab.jsx` |
+| `EnginesTab` | 1022–1072 | `EnginesTab.jsx` |
+| `HotkeyTab` (+ `CREDENTIAL_FIELDS`, `keyEventToAccelerator`) | 1693–1870 | `HotkeyTab.jsx` |
+| `CredentialsTab` | 1871–1969 | `CredentialsTab.jsx` |
+`Settings.jsx` keeps only: imports, `TAB_DEFS`/`LOG_SOURCE_DEFS`, the `Settings`
+default export (tab router + shared state), and `askConfirm`.
+
+### Phase 2 — `DubTab.jsx` (1592 → orchestrator + `components/dub/`)
+Extract `DubFailureNotice`, `DubPipelineStepper`, `PrepOverlay`,
+`TranscribeOverlay`, `FooterBtn`, and the large render sub-sections into
+`components/dub/`. `DubTab.jsx` retains the pipeline state machine + composition.
+
+### Phase 3 — `CloneDesignTab`, `VoiceGallery`, `VoiceProfile`, `AudiobookTab`
+Same treatment into `components/clone/` and `components/gallery/`. Smaller, lower risk.
+
+## Constraints honored
+- **No behavior change** — pure moves; diff is verifiable by "app renders
+ identically + existing tests pass." Each panel that has a test keeps it.
+- **Keep main green** — ESLint rule is warn-only; each phase is independently CI-green.
+- **Docs-sync** — Phase 0 lands the `CONTRIBUTING.md` change in the same PR as the rule.
+- **No versioning impact** — frontend-only refactor; no `package.json` version bump,
+ no lockfile/dep change, no Docker/Tauri/Python surface touched.
+
+## Verification per phase
+1. `bun run build` (or the project's typecheck/lint) passes.
+2. Existing `components/settings/*.test.jsx` (and any new co-located tests) pass.
+3. Manual smoke: open Settings → every tab renders; open Dub → pipeline renders.
+4. `git diff --stat` shows only moves (line counts shift between files, net ~0 logic change).
+
+## Out of scope (explicitly)
+- No redesign of the Settings *UI* itself (the "unorganised" look) — that's a separate
+ visual-polish task; this refactor only restructures the *code*. Flag if you want
+ that bundled.
+- No conversion of `.jsx` → `.tsx` (pages are currently JS; TS migration is a
+ different decision).
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 4fa125da..023fbfb9 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -24,6 +24,9 @@ export default defineConfig([
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ // Modularization guardrail (warn-only so CI stays green). See
+ // CONTRIBUTING.md → "Frontend file structure & size limits".
+ 'max-lines': ['warn', { max: 500, skipBlankLines: true, skipComments: true }],
},
},
])
diff --git a/frontend/src/components/settings/AboutTab.jsx b/frontend/src/components/settings/AboutTab.jsx
new file mode 100644
index 00000000..102a5361
--- /dev/null
+++ b/frontend/src/components/settings/AboutTab.jsx
@@ -0,0 +1,175 @@
+import React from 'react';
+import {
+ Info, CheckCircle, AlertCircle, Download, Activity, Copy, ExternalLink, Building2,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { openExternal } from '../../api/external';
+import { resolveAboutVersion } from '../../utils/appVersion';
+import { Segmented, Button, Badge } from '../../ui';
+import { SettingsSection, SettingRow } from './primitives';
+import { useAppStore } from '../../store';
+import { isTauri } from './native';
+import Row from './Row';
+
+export default function AboutTab({
+ appVersion,
+ tauriVersion,
+ info,
+ hw,
+ status,
+ updateChannel,
+ changeChannel,
+ checkForUpdates,
+ updateState,
+ selfCheck,
+ selfCheckRunning,
+ runSelfCheck,
+ bundleBuilding,
+ saveDiagnosticBundle,
+ copyDiagnostics,
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+ {t('about.yes')}
+ : {t('about.no')} } />
+
+
+ {status?.status || 'unknown'}} />
+
+
+
+
+
+
+
+ {/* Auto-updater + channel toggle are desktop-only (Tauri). The Docker
+ web build updates by pulling a new image tag, so hide these rows
+ there to avoid a non-functional control (issue #249). */}
+ {isTauri() && (
+ <>
+
+ }
+ />
+
+ >
+ )}
+
+ {isTauri() && (
+ }
+ onClick={checkForUpdates}
+ loading={updateState === 'checking' || updateState === 'downloading'}
+ >
+ {updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
+
+ )}
+ }
+ onClick={runSelfCheck}
+ loading={selfCheckRunning}
+ >
+ {t('about.self_check')}
+
+ }
+ onClick={saveDiagnosticBundle}
+ loading={bundleBuilding}
+ >
+ {t('about.save_bundle')}
+
+ }
+ onClick={copyDiagnostics}
+ >
+ {t('about.copy_diagnostics')}
+
+ }
+ onClick={() => openExternal('https://github.com/k2-fsa/OmniVoice')}
+ >
+ {t('about.github')}
+
+ }
+ onClick={() => openExternal('https://huggingface.co/k2-fsa/OmniVoice')}
+ >
+ {t('about.model_card')}
+
+ }
+ onClick={() => { useAppStore.getState().setMode?.('enterprise'); }}
+ >
+ {t('about.commercial_license')}
+
+
+ {selfCheck && (
+
+ {selfCheck.checks.map((c) => (
+
+
+ {c.status === 'ok'
+ ?
+ : } {t(`about.self_check_${c.status}`)}
+
+ {' '}{c.detail}
+ {c.hint && — {c.hint} }
+
+ }
+ />
+ ))}
+
+ {selfCheck.summary.ok
+ ? t('about.self_check_healthy')
+ : t('about.self_check_attention', { count: selfCheck.summary.failures })}
+
+
+ )}
+
|
+ );
+}
diff --git a/frontend/src/components/settings/CredentialsTab.jsx b/frontend/src/components/settings/CredentialsTab.jsx
new file mode 100644
index 00000000..05963eef
--- /dev/null
+++ b/frontend/src/components/settings/CredentialsTab.jsx
@@ -0,0 +1,128 @@
+import React, { useState } from 'react';
+import { KeyRound } from 'lucide-react';
+import { toast } from 'react-hot-toast';
+import { Trans, useTranslation } from 'react-i18next';
+import { openExternal } from '../../api/external';
+import { Badge, Button } from '../../ui';
+import { SettingsSection, SettingRow, SettingsInput, Collapsible } from './primitives';
+import ApiKeysPanel from './ApiKeysPanel';
+import LLMEndpointPanel from './LLMEndpointPanel';
+
+const CREDENTIAL_FIELDS = [
+ { key: 'HF_TOKEN', labelKey: 'credentials.hf_token', placeholderKey: 'hf_xxxxxxxxxxxx',
+ helpKey: 'credentials.hf_help', link: 'https://huggingface.co/settings/tokens', isPassword: true },
+ { key: 'TRANSLATE_API_KEY', labelKey: 'credentials.translate_key', placeholderKey: 'API key',
+ helpKey: 'credentials.translate_help', isPassword: true },
+ { key: 'TRANSLATE_BASE_URL', labelKey: 'credentials.llm_base_url', placeholderKey: 'https://api.openai.com/v1',
+ helpKey: 'credentials.llm_base_url_help' },
+ { key: 'TRANSLATE_MODEL', labelKey: 'credentials.llm_model', placeholderKey: 'gpt-4o',
+ helpKey: 'credentials.llm_model_help' },
+ { key: 'DEEPL_API_KEY', labelKey: 'credentials.deepl_key', placeholderKey: 'DeepL API key',
+ helpKey: 'credentials.deepl_key', isPassword: true },
+ { key: 'DEEPL_BASE_URL', labelKey: 'credentials.deepl_base_url', placeholderKey: 'https://api.deepl.com/v2',
+ helpKey: 'credentials.deepl_base_url_help' },
+ { key: 'MICROSOFT_API_KEY', labelKey: 'credentials.microsoft_key', placeholderKey: 'Microsoft API key',
+ helpKey: 'credentials.microsoft_key', isPassword: true },
+ { key: 'MICROSOFT_BASE_URL', labelKey: 'credentials.microsoft_base_url', placeholderKey: 'https://api.cognitive.microsofttranslator.com',
+ helpKey: 'credentials.microsoft_base_url_help' },
+];
+
+export default function CredentialsTab({ info }) {
+ const { t } = useTranslation();
+ const [values, setValues] = useState({});
+ const [saving, setSaving] = useState(null);
+ const [saved, setSaved] = useState({});
+
+ const save = async (key) => {
+ const value = (values[key] || '').trim();
+ if (!value) return;
+ setSaving(key);
+ try {
+ const { API } = await import('../../api/client');
+ const res = await fetch(`${API}/system/set-env`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value }),
+ });
+ if (res.ok) {
+ toast.success(t('credentials.saved_session', { key }));
+ setSaved(prev => ({ ...prev, [key]: true }));
+ setValues(prev => ({ ...prev, [key]: '' }));
+ } else {
+ const d = await res.json().catch(() => ({}));
+ toast.error(d.detail || t('credentials.save_failed'));
+ }
+ } catch (e) {
+ toast.error(t('credentials.save_error', { message: e.message }));
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ return (
+
+ {/* Wave 2 AUTH-03 panel — 3-source cascade with Active badge,
+ encrypted-at-rest App-source storage, and live whoami status. */}
+
+
+ {/* Wave 2.4 — OpenAI-compatible LLM endpoint (Ollama/LM Studio/vLLM). */}
+
+
+
+
+ }} />
+
+ {CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => (
+
+ {t(field.labelKey)}
+ {field.key === 'HF_TOKEN' && (
+
+ {info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')}
+
+ )}
+ >
+ }
+ note={
+ <>
+ {t(field.helpKey)}
+ {field.link && (
+ <> { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')} >
+ )}
+ >
+ }
+ control={
+ <>
+ setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
+ onKeyDown={e => e.key === 'Enter' && save(field.key)}
+ />
+ save(field.key)}
+ disabled={!(values[field.key] || '').trim()}
+ >
+ {t('credentials.save')}
+
+ >
+ }
+ />
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/EnginesTab.jsx b/frontend/src/components/settings/EnginesTab.jsx
new file mode 100644
index 00000000..ef3792b1
--- /dev/null
+++ b/frontend/src/components/settings/EnginesTab.jsx
@@ -0,0 +1,52 @@
+import React, { useCallback } from 'react';
+import { toast } from 'react-hot-toast';
+import { useTranslation } from 'react-i18next';
+import { addBreadcrumb } from '../../utils/breadcrumbs';
+import { selectEngine } from '../../api/engines';
+import { Segmented } from '../../ui';
+import { useAppStore } from '../../store';
+import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix';
+
+export default function EnginesTab() {
+ const { t } = useTranslation();
+ const reviewMode = useAppStore(s => s.reviewMode);
+ const setReviewMode = useAppStore(s => s.setReviewMode);
+
+ // Plan 02-04 / ENGINE-06 — engine selection is wired through the
+ // matrix component's optional onSelect callback so the matrix doubles
+ // as a picker. Keeps a single source of truth for the engine list +
+ // its install / GPU / isolation state.
+ const onSelect = useCallback(async (family, backendId) => {
+ try {
+ addBreadcrumb(`engine:${family}=${backendId}`);
+ const r = await selectEngine(family, backendId);
+ toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active }));
+ } catch (e) {
+ toast.error(e.message || t('engines.switch_failed'));
+ }
+ }, []);
+
+ return (
+
+
+
+
+ ·
+
+ {reviewMode === 'on' ? t('engines.banners_on') : t('engines.banners_off')}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/GeneralTab.jsx b/frontend/src/components/settings/GeneralTab.jsx
new file mode 100644
index 00000000..f491ed90
--- /dev/null
+++ b/frontend/src/components/settings/GeneralTab.jsx
@@ -0,0 +1,209 @@
+import React, { useEffect, useState } from 'react';
+import { toast } from 'react-hot-toast';
+import { useTranslation } from 'react-i18next';
+import { Settings2, Globe, Palette } from 'lucide-react';
+import { useQueryClient } from '@tanstack/react-query';
+import i18n, { LANGUAGES } from '../../i18n';
+import { useSystemInfo, queryKeys } from '../../api/hooks';
+import { Button, Badge, Select } from '../../ui';
+import { SettingsSection, SettingRow, SettingsInput, Collapsible } from './primitives';
+import { useAppStore } from '../../store';
+
+export default function GeneralTab() {
+ const { t } = useTranslation();
+ const locale = useAppStore(s => s.locale);
+ const setLocale = useAppStore(s => s.setLocale);
+ const theme = useAppStore(s => s.theme);
+ const setTheme = useAppStore(s => s.setTheme);
+ const { data: sysInfo } = useSystemInfo();
+ const [proxyUrl, setProxyUrl] = useState('');
+ const [proxySaved, setProxySaved] = useState(false);
+ const [proxySaving, setProxySaving] = useState(false);
+ const [ffmpegPath, setFfmpegPath] = useState('');
+ const [ffmpegSaving, setFfmpegSaving] = useState(false);
+ const queryClient = useQueryClient();
+
+ // Sync inputs with persisted values from backend on load
+ useEffect(() => {
+ if (!proxyUrl && !proxySaved) setProxyUrl(sysInfo?.proxy_url || '');
+ }, [sysInfo?.proxy_url]);
+
+ useEffect(() => {
+ if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || '');
+ }, [sysInfo?.ffmpeg_path]);
+
+ const ffmpegOk = sysInfo?.ffmpeg_ok;
+ const ffmpegCurrent = sysInfo?.ffmpeg_path;
+
+ const saveFfmpeg = async () => {
+ const value = ffmpegPath.trim();
+ setFfmpegSaving(true);
+ try {
+ const { API } = await import('../../api/client');
+ const r = await fetch(`${API}/system/set-env`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key: 'FFMPEG_PATH', value }),
+ });
+ if (r.ok) {
+ toast.success(t('settings.ffmpeg_saved'));
+ setFfmpegPath('');
+ queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
+ } else {
+ const d = await r.json().catch(() => ({}));
+ toast.error(d.detail || t('credentials.save_failed'));
+ }
+ } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); }
+ finally { setFfmpegSaving(false); }
+ };
+
+ const handleLocaleChange = (e) => {
+ const id = e.target.value;
+ setLocale(id);
+ i18n.changeLanguage(id);
+ };
+
+ const saveProxy = async () => {
+ const value = proxyUrl.trim();
+ setProxySaving(true);
+ try {
+ const { API } = await import('../../api/client');
+ const setEnv = (key, val) => fetch(`${API}/system/set-env`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value: val }),
+ });
+ const r = await setEnv('HTTP_PROXY', value);
+ if (r.ok) {
+ await Promise.all([
+ setEnv('HTTPS_PROXY', value),
+ setEnv('ALL_PROXY', value),
+ setEnv('http_proxy', value),
+ setEnv('https_proxy', value),
+ setEnv('all_proxy', value),
+ ]);
+ toast.success(t('settings.proxy_saved'));
+ setProxySaved(true);
+ queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
+ } else {
+ const d = await r.json().catch(() => ({}));
+ toast.error(d.detail || t('settings.proxy_save_failed'));
+ }
+ } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); }
+ finally { setProxySaving(false); }
+ };
+
+ const clearProxy = async () => {
+ setProxySaving(true);
+ try {
+ const { API } = await import('../../api/client');
+ const setEnv = (key, val) => fetch(`${API}/system/set-env`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value: val }),
+ });
+ await Promise.all([
+ setEnv('HTTP_PROXY', ''),
+ setEnv('HTTPS_PROXY', ''),
+ setEnv('ALL_PROXY', ''),
+ setEnv('http_proxy', ''),
+ setEnv('https_proxy', ''),
+ setEnv('all_proxy', ''),
+ ]);
+ setProxyUrl('');
+ setProxySaved(false);
+ toast.success(t('settings.proxy_cleared'));
+ queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
+ } catch (e) { toast.error(t('settings.clear_failed', { message: e.message })); }
+ finally { setProxySaving(false); }
+ };
+
+ return (
+
+
+ {LANGUAGES.map((l) => (
+ {l.label}
+ ))}
+
+ }
+ />
+ setTheme(e.target.value)}>
+ Gruvbox
+ Midnight
+ Nord
+ Solarized
+ Rose Pine
+ Catppuccin
+
+ }
+ />
+
+
+
+ {t('settings.proxy')}
+ {proxySaved && {t('credentials.saved')} }
+ >
+ }
+ note={t('settings.proxy_desc')}
+ control={
+ <>
+ setProxyUrl(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && saveProxy()}
+ />
+
+ {t('credentials.save')}
+
+ {proxySaved && (
+
+ {t('settings.proxy_clear')}
+
+ )}
+ >
+ }
+ />
+
+
+ {t('settings.ffmpeg')}
+
+ {ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
+
+ >
+ }
+ note={ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')}
+ control={
+ <>
+ setFfmpegPath(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && saveFfmpeg()}
+ />
+
+ {t('credentials.save')}
+
+ >
+ }
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/settings/HotkeyTab.jsx b/frontend/src/components/settings/HotkeyTab.jsx
new file mode 100644
index 00000000..158c8cdb
--- /dev/null
+++ b/frontend/src/components/settings/HotkeyTab.jsx
@@ -0,0 +1,166 @@
+import React, { useEffect, useState } from 'react';
+import { Keyboard } from 'lucide-react';
+import { toast } from 'react-hot-toast';
+import { Trans, useTranslation } from 'react-i18next';
+import { Button } from '../../ui';
+import { SettingsSection, SettingRow } from './primitives';
+import { isTauri } from './native';
+
+// Convert a KeyboardEvent into a tauri-plugin-global-shortcut accelerator
+// string, e.g. "CmdOrCtrl+Shift+Space". Returns null when only modifiers
+// are held (the user hasn't picked a "real" key yet).
+function keyEventToAccelerator(e) {
+ const isMacLike = typeof navigator !== 'undefined'
+ && /Mac|iPad|iPhone|iPod/.test(navigator.platform || '');
+ const mods = [];
+ if (e.metaKey) mods.push(isMacLike ? 'Cmd' : 'Super');
+ if (e.ctrlKey) mods.push('Ctrl');
+ if (e.altKey) mods.push('Alt');
+ if (e.shiftKey) mods.push('Shift');
+
+ // e.code is the physical key — already in the shape tauri expects for
+ // Letter/Digit/Function keys ("KeyA", "Digit1", "F5"). Strip the prefix
+ // so we get "A" / "1" / "F5" which matches the accelerator grammar.
+ let key = e.code;
+ if (!key) return null;
+ if (key.startsWith('Key')) key = key.slice(3);
+ else if (key.startsWith('Digit')) key = key.slice(5);
+ // Skip pure modifier keys — we want the user to pick a real trigger.
+ if (/^(Meta|Control|Alt|Shift|OS)(Left|Right)?$/.test(key)) return null;
+
+ if (mods.length === 0) return null;
+ return [...mods, key].join('+');
+}
+
+export default function HotkeyTab() {
+ const { t } = useTranslation();
+ const [current, setCurrent] = useState('');
+ const [recording, setRecording] = useState(false);
+ const [pending, setPending] = useState('');
+ const [saving, setSaving] = useState(false);
+ const tauri = isTauri();
+
+ // Load the saved shortcut on mount.
+ useEffect(() => {
+ if (!tauri) return;
+ (async () => {
+ try {
+ const { invoke } = await import('@tauri-apps/api/core');
+ const v = await invoke('get_dictation_shortcut');
+ setCurrent(v || '');
+ } catch (e) {
+ toast.error(t('settings.shortcut_load_failed', { message: e?.message || e }));
+ }
+ })();
+ }, [tauri]);
+
+ // While recording, swallow keystrokes globally and convert the next real
+ // press into an accelerator string. Escape cancels.
+ useEffect(() => {
+ if (!recording) return;
+ const onKeyDown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === 'Escape') {
+ setRecording(false);
+ setPending('');
+ return;
+ }
+ const accel = keyEventToAccelerator(e);
+ if (accel) {
+ setPending(accel);
+ setRecording(false);
+ }
+ };
+ window.addEventListener('keydown', onKeyDown, true);
+ return () => window.removeEventListener('keydown', onKeyDown, true);
+ }, [recording]);
+
+ const save = async () => {
+ if (!pending || pending === current) return;
+ setSaving(true);
+ try {
+ const { invoke } = await import('@tauri-apps/api/core');
+ const saved = await invoke('set_dictation_shortcut', { accelerator: pending });
+ setCurrent(saved);
+ setPending('');
+ toast.success(t('settings.shortcut_set', { shortcut: saved }));
+ } catch (e) {
+ // Common cause: the OS or another app already owns the combo. Surface
+ // the raw error so the user can pick something else.
+ toast.error(t('settings.shortcut_register_failed', { message: e?.message || e }));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const resetDefault = async () => {
+ setSaving(true);
+ try {
+ const { invoke } = await import('@tauri-apps/api/core');
+ const saved = await invoke('set_dictation_shortcut', {
+ accelerator: 'CmdOrCtrl+Shift+Space',
+ });
+ setCurrent(saved);
+ setPending('');
+ toast.success(t('settings.shortcut_reset'));
+ } catch (e) {
+ toast.error(t('settings.shortcut_reset_failed', { message: e?.message || e }));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ {!tauri && (
+
+ }} />
+
+ )}
+
+
+ , 2: }} />}
+ control={recording ? t('capture.listening') : (pending || '—')}
+ mono
+ />
+
+
+ { setPending(''); setRecording(true); }}
+ disabled={!tauri || saving}
+ leading={ }
+ >
+ {recording ? t('capture.recording') : t('capture.record_shortcut')}
+
+
+ {t('capture.save')}
+
+
+ {t('capture.reset_default')}
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/LogsTab.jsx b/frontend/src/components/settings/LogsTab.jsx
new file mode 100644
index 00000000..f3ee82f0
--- /dev/null
+++ b/frontend/src/components/settings/LogsTab.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { FileText, RefreshCw, Trash2, AlertCircle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Segmented, Button, Badge } from '../../ui';
+import { SettingsSection } from './primitives';
+import ReportBugButton from '../ReportBugButton';
+
+const LOG_SOURCE_DEFS = [
+ { value: 'backend', key: 'backend' },
+ { value: 'frontend', key: 'frontend' },
+ { value: 'tauri', key: 'tauri' },
+];
+
+export default function LogsTab({
+ logSource,
+ setLogSource,
+ logs,
+ logMeta,
+ loadingLogs,
+ refreshLogs,
+ onClearLogs,
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ }
+ >
+ {t('common.refresh')}
+
+ }
+ >
+ {t('common.clear')}
+
+ >
+ }
+ >
+ ({ ...d, label: t(`common.${d.key}`) }))}
+ value={logSource}
+ onChange={setLogSource}
+ />
+
+
+
{logMeta.path || '—'}
+ {logSource === 'tauri' && !logMeta.exists && (
+
+ {t('logs.no_tauri_log')}
+
+ )}
+
+
+ {logs.length === 0
+ ?
+ {logSource === 'frontend'
+ ? t('logs.empty_frontend')
+ : logSource === 'tauri'
+ ? t('logs.empty_tauri')
+ : t('logs.empty_backend')}
+
+ : logs.join('')}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/ModelStoreTab.jsx b/frontend/src/components/settings/ModelStoreTab.jsx
new file mode 100644
index 00000000..fc05db59
--- /dev/null
+++ b/frontend/src/components/settings/ModelStoreTab.jsx
@@ -0,0 +1,439 @@
+import React, { useEffect, useState, useCallback, useMemo } from 'react';
+import {
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import {
+ Cpu, RefreshCw, KeyRound,
+} from 'lucide-react';
+import { toast } from 'react-hot-toast';
+import { useTranslation } from 'react-i18next';
+import { openExternal } from '../../api/external';
+import { setupDownloadStreamUrl } from '../../api/setup';
+import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../../api/hooks';
+import { Button, Segmented } from '../../ui';
+import { SettingsSection, SettingsInput } from './primitives';
+import { askConfirm } from './native';
+import { fmtBytes } from './models/format';
+import { computeRowRuntime } from './models/runtime';
+import { makeModelColumns } from './models/columns';
+import RecoBanner from './models/RecoBanner';
+import ModelsTable from './models/ModelsTable';
+
+const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
+const MODEL_ROLE_LABEL = { all: 'All', tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
+
+/**
+ * Model store — list every known HF model, show install state, let the
+ * user install / reinstall / delete individual models. Per-model download
+ * progress is pulled from the shared /setup/download-stream SSE.
+ */
+export default function ModelStoreTab({ info, modelBadge }) {
+ const { t } = useTranslation();
+ const modelsQuery = useModels();
+ const recoQuery = useRecommendations();
+ const data = modelsQuery.data;
+ const loading = modelsQuery.isLoading;
+ const reco = recoQuery.data;
+ const installMutation = useInstallModel();
+ const deleteMutation = useDeleteModel();
+
+ const [busy, setBusy] = useState(new Set()); // repo_ids currently working
+ // Per-repo active state. Tracks aggregate download across all files of
+ // a running install so the row can show a determinate progress bar.
+ // { [repo_id]: { phase, files: { [filename]: { downloaded, total, pct } }, error } }
+ const [rowState, setRowState] = useState({});
+ const [query, setQuery] = useState('');
+ const [installingReco, setInstallingReco] = useState(false);
+ const [activeRole, setActiveRole] = useState(null);
+ const [sorting, setSorting] = useState([]);
+ const [columnFilters, setColumnFilters] = useState([]);
+ const esRef = React.useRef(null);
+ const tableBodyRef = React.useRef(null);
+ // Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } }
+ const speedRef = React.useRef({});
+ // Tick counter — forces re-render every second while a download is active
+ // so speed/ETA displays update smoothly between SSE events.
+ const [, setTick] = useState(0);
+ // Boolean derived from rowState so the interval effect below only re-runs
+ // when activity starts/stops — not on every SSE progress event (several per
+ // second during installs), which would clear + recreate the 1s tick forever.
+ const hasActive = useMemo(() => Object.values(rowState).some(s =>
+ ['install_start', 'active', 'delete_start'].includes(s.phase)), [rowState]);
+ useEffect(() => {
+ if (!hasActive) return;
+ const iv = setInterval(() => setTick(t => t + 1), 1000);
+ return () => clearInterval(iv);
+ }, [hasActive]);
+
+ // HF token inline — compact input in the toolbar
+ const [hfToken, setHfToken] = useState('');
+ const [hfSaved, setHfSaved] = useState(false);
+ const [hfSaving, setHfSaving] = useState(false);
+ const [hfExpanded, setHfExpanded] = useState(false);
+ const saveHfToken = async () => {
+ const value = hfToken.trim();
+ if (!value) return;
+ setHfSaving(true);
+ try {
+ const { API } = await import('../../api/client');
+ const res = await fetch(`${API}/system/set-env`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key: 'HF_TOKEN', value }),
+ });
+ if (res.ok) {
+ toast.success(t('models.hf_token_set_toast'));
+ setHfSaved(true);
+ setHfToken('');
+ setHfExpanded(false);
+ } else {
+ const d = await res.json().catch(() => ({}));
+ toast.error(d.detail || t('models.hf_token_save_failed'));
+ }
+ } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); }
+ finally { setHfSaving(false); }
+ };
+ const hfTokenSet = hfSaved || info?.has_hf_token;
+
+ // Open the progress stream once when the tab mounts; close on unmount.
+ useEffect(() => {
+ const es = new EventSource(setupDownloadStreamUrl());
+ esRef.current = es;
+ es.onmessage = (evt) => {
+ try {
+ const ev = JSON.parse(evt.data);
+ if (!ev?.repo_id) return;
+ setRowState(prev => {
+ const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
+ // Lifecycle events (install_start/install_done/install_error,
+ // delete_start/delete_done) flip the row's phase without
+ // touching per-file accounting.
+ if (ev.phase === 'install_start' || ev.phase === 'delete_start') {
+ return { ...prev, [ev.repo_id]: { phase: ev.phase, files: {}, error: null } };
+ }
+ // Heartbeat from backend while resolving repo metadata
+ if (ev.phase === 'resolving') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 } };
+ }
+ if (ev.phase === 'install_retry') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error } };
+ }
+ if (ev.phase === 'install_done') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_done' } };
+ }
+ if (ev.phase === 'delete_done') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'delete_done' } };
+ }
+ if (ev.phase === 'install_error') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } };
+ }
+ if (ev.phase === 'install_cancelled') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_cancelled' } };
+ }
+ // Pre-flight plan (FDL-05): accurate total/cached/remaining BEFORE
+ // bytes flow. Keep the current phase (usually resolving) — the plan
+ // is metadata, not a state change.
+ if (ev.phase === 'install_plan') {
+ return { ...prev, [ev.repo_id]: { ...cur, plan: {
+ total_bytes: ev.total_bytes ?? null,
+ cached_bytes: ev.cached_bytes ?? null,
+ to_download_bytes: ev.to_download_bytes ?? null,
+ n_files: ev.n_files ?? null,
+ n_cached: ev.n_cached ?? null,
+ } } };
+ }
+ // Overall aggregate (FDL-06): one rolling event that is the source of
+ // truth for the overall bar / speed / remaining / ETA.
+ if (ev.phase === 'aggregate') {
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', agg: {
+ bytes_done: ev.bytes_done ?? 0,
+ total_bytes: ev.total_bytes ?? null,
+ rate: ev.rate ?? 0,
+ eta_seconds: ev.eta_seconds ?? null,
+ files_done: ev.files_done ?? 0,
+ files_total: ev.files_total ?? null,
+ } } };
+ }
+ // Per-file tqdm events — aggregate across files.
+ const files = { ...cur.files, [ev.filename]: {
+ downloaded: ev.downloaded || 0,
+ total: ev.total || 0,
+ pct: ev.pct || 0,
+ phase: ev.phase,
+ rate: ev.rate || 0,
+ }};
+ return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', files } };
+ });
+ } catch { /* keepalive / ignore */ }
+ };
+ return () => es.close();
+ }, []);
+
+ // When a lifecycle terminator fires, refresh the list so "installed"
+ // flips server-side info into the row.
+ useEffect(() => {
+ const term = Object.entries(rowState).find(([, s]) =>
+ ['install_done', 'delete_done', 'install_error', 'install_cancelled'].includes(s.phase));
+ if (!term) return;
+ const t = setTimeout(() => {
+ modelsQuery.refetch();
+ recoQuery.refetch();
+ // Clear stale speed data for this repo.
+ delete speedRef.current[term[0]];
+ // Clear the terminal entry so the row reverts to the authoritative
+ // `installed` flag from /models without keeping stale progress.
+ setRowState(prev => {
+ const next = { ...prev };
+ delete next[term[0]];
+ return next;
+ });
+ }, 800);
+ return () => clearTimeout(t);
+ }, [rowState, modelsQuery, recoQuery]);
+
+ const reload = useCallback(() => {
+ modelsQuery.refetch();
+ recoQuery.refetch();
+ }, [modelsQuery, recoQuery]);
+
+ const withBusy = useCallback(async (repoId, fn, successMsg) => {
+ setBusy(prev => new Set(prev).add(repoId));
+ try {
+ await fn();
+ if (successMsg) toast.success(successMsg);
+ } catch (e) {
+ toast.error(e.message || String(e));
+ } finally {
+ setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; });
+ }
+ }, []);
+
+ const onInstall = useCallback((repoId) =>
+ withBusy(repoId, () => installMutation.mutateAsync(repoId), t('models.install_started')),
+ [installMutation, withBusy]);
+ const onDelete = useCallback(async (repoId) => {
+ if (!(await askConfirm(t('models.delete_confirm', { repoId }), t('models.delete_confirm_title')))) return;
+ return withBusy(repoId, () => deleteMutation.mutateAsync(repoId), t('models.deleted', { repoId }));
+ }, [deleteMutation, withBusy]);
+ const onReinstall = useCallback(async (repoId) => {
+ if (!(await askConfirm(t('models.reinstall_confirm', { repoId }), t('models.reinstall_confirm_title')))) return;
+ await withBusy(repoId, async () => {
+ await deleteMutation.mutateAsync(repoId);
+ await installMutation.mutateAsync(repoId);
+ }, t('models.reinstalling'));
+ }, [deleteMutation, installMutation, withBusy]);
+
+ const onInstallRecommended = async () => {
+ if (!reco) return;
+ const missing = reco.models.filter(m => !m.installed);
+ if (missing.length === 0) {
+ toast.success(t('models.recommended_installed'));
+ return;
+ }
+ setInstallingReco(true);
+ try {
+ // Parallel install — backend /models/install spawns each download on
+ // its own asyncio task so ordering doesn't matter.
+ await Promise.all(missing.map(m => installMutation.mutateAsync(m.repo_id)));
+ toast.success(t('models.started_downloading', { count: missing.length }));
+ } catch (e) {
+ toast.error(t('models.install_failed', { message: e.message || e }));
+ } finally {
+ setInstallingReco(false);
+ }
+ };
+
+ const allModels = React.useMemo(() => data?.models || [], [data]);
+ const groups = allModels.reduce((acc, m) => {
+ const k = (m.role || 'other').toLowerCase();
+ (acc[k] = acc[k] || []).push(m);
+ return acc;
+ }, {});
+ const roles = Object.keys(groups).sort((a, b) => {
+ const ai = MODEL_ROLE_ORDER.indexOf(a), bi = MODEL_ROLE_ORDER.indexOf(b);
+ return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
+ });
+ // 'all' is a virtual role — shows every model regardless of category.
+ const currentRole = activeRole === 'all' ? 'all'
+ : activeRole && groups[activeRole] ? activeRole
+ : 'all';
+
+ const allInstalled = allModels.filter(m => m.installed).length;
+
+ useEffect(() => {
+ setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]);
+ }, [currentRole]);
+
+ const getRowRuntime = React.useCallback(
+ (m) => computeRowRuntime(m, rowState, busy),
+ [busy, rowState],
+ );
+
+ const columns = React.useMemo(
+ () => makeModelColumns({ t, getRowRuntime, speedRef, MODEL_ROLE_LABEL, onInstall, onDelete, onReinstall }),
+ [getRowRuntime, onDelete, onInstall, onReinstall, t],
+ );
+
+ const table = useReactTable({
+ data: allModels,
+ columns,
+ getRowId: row => row.repo_id,
+ state: {
+ sorting,
+ globalFilter: query,
+ columnFilters,
+ },
+ onSortingChange: setSorting,
+ onGlobalFilterChange: setQuery,
+ onColumnFiltersChange: setColumnFilters,
+ globalFilterFn: (row, _columnId, value) => {
+ const q = String(value || '').trim().toLowerCase();
+ if (!q) return true;
+ const m = row.original;
+ return [m.repo_id, m.label, m.note, m.role]
+ .filter(Boolean)
+ .some(v => String(v).toLowerCase().includes(q));
+ },
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ });
+
+ const tableRows = table.getRowModel().rows;
+ const rowVirtualizer = useVirtualizer({
+ count: tableRows.length,
+ getScrollElement: () => tableBodyRef.current,
+ estimateSize: () => 68,
+ overscan: 8,
+ });
+
+ if (loading && !data) {
+ return (
+
+ {t('common.loading')}
+
+ );
+ }
+ if (!data) return null;
+
+ return (
+
+
+
+ {fmtBytes(data.total_installed_bytes)}
+ ·
+ {data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')}
+ {info && · }
+ {info && {modelBadge} }
+ {info?.fast_download?.xet_enabled && (
+ <>
+ ·
+
+ ⚡ {t('models.fast_download_badge') || 'fast download'}
+
+ >
+ )}
+
+
+ {/* Compact HF token inline */}
+ {!hfTokenSet && !hfExpanded && (
+
setHfExpanded(true)}
+ title={t('models.hf_set_title')}
+ >
+ {t('models.hf_token_btn')}
+
+ )}
+ {!hfTokenSet && hfExpanded && (
+
+ )}
+ {hfTokenSet && (
+
✓
+ )}
+
}>
+ {t('common.refresh')}
+
+
+
+
+
+
+
+ {
+ const installed = groups[r].filter(m => m.installed).length;
+ return {
+ value: r,
+ label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
+ };
+ }),
+ ]}
+ />
+ setQuery(e.target.value)}
+ aria-label={t('models.search_label')}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/PrivacyTab.jsx b/frontend/src/components/settings/PrivacyTab.jsx
new file mode 100644
index 00000000..235f1853
--- /dev/null
+++ b/frontend/src/components/settings/PrivacyTab.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { ShieldCheck, CheckCircle, AlertCircle } from 'lucide-react';
+import { Trans, useTranslation } from 'react-i18next';
+import { Badge } from '../../ui';
+import { SettingsSection } from './primitives';
+import Row from './Row';
+
+export default function PrivacyTab({ info }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ }} />
+
+
+
+ {t('privacy.local_sqlite')}} />
+ {t('privacy.translator_online', { provider: info.translate_provider })}
+ : {t('privacy.translator_offline')}
+ }
+ />
+ {t('privacy.no_tracking')}}
+ />
+ |
|
+ );
+}
diff --git a/frontend/src/components/settings/Row.jsx b/frontend/src/components/settings/Row.jsx
new file mode 100644
index 00000000..4b6e8390
--- /dev/null
+++ b/frontend/src/components/settings/Row.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import { SettingRow } from './primitives';
+
+// About/Privacy read-only data rows delegate to the shared SettingRow primitive
+// so they pick up the redesigned grid + mono value styling unchanged.
+export default function Row({ label, value, mono }) {
+ return ;
+}
diff --git a/frontend/src/components/settings/models/ModelsTable.jsx b/frontend/src/components/settings/models/ModelsTable.jsx
new file mode 100644
index 00000000..fd3fccf4
--- /dev/null
+++ b/frontend/src/components/settings/models/ModelsTable.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { flexRender } from '@tanstack/react-table';
+import { Table } from '../../../ui';
+
+/**
+ * Virtualized model table view. Purely presentational — the table instance,
+ * virtualizer, and row runtime resolver are all created by the host
+ * ModelStoreTab and passed in.
+ */
+export default function ModelsTable({ table, tableRows, rowVirtualizer, tableBodyRef, getRowRuntime, t }) {
+ return (
+
+
+ {table.getHeaderGroups().map(headerGroup => (
+
+ {headerGroup.headers.map(header => {
+ const meta = header.column.columnDef.meta || {};
+ const canSort = header.column.getCanSort();
+ return (
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ {header.column.getIsSorted() === 'asc' && ↑ }
+ {header.column.getIsSorted() === 'desc' && ↓ }
+
+ );
+ })}
+
+ ))}
+
+
+
+ {rowVirtualizer.getVirtualItems().map(virtualRow => {
+ const row = tableRows[virtualRow.index];
+ const m = row.original;
+ const rt = getRowRuntime(m);
+ return (
+
+ {row.getVisibleCells().map(cell => {
+ const meta = cell.column.columnDef.meta || {};
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ );
+ })}
+
+ );
+ })}
+ {tableRows.length === 0 && (
+
{t('models.no_matches')}
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/models/RecoBanner.jsx b/frontend/src/components/settings/models/RecoBanner.jsx
new file mode 100644
index 00000000..edd67bea
--- /dev/null
+++ b/frontend/src/components/settings/models/RecoBanner.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { RefreshCw, CheckCircle } from 'lucide-react';
+import { toast } from 'react-hot-toast';
+import { Button } from '../../../ui';
+
+/**
+ * Recommendation banner — shows the device's recommended model set and lets the
+ * user kick off the required / all installs. Purely presentational; all state
+ * and mutations are supplied by the host ModelStoreTab.
+ */
+export default function RecoBanner({
+ reco,
+ t,
+ installMutation,
+ installingReco,
+ setInstallingReco,
+ onInstallRecommended,
+}) {
+ if (!reco) return null;
+ if (reco.all_installed) {
+ return (
+
+
+ {t('models.reco_installed_for', { device: reco.device.label })}
+ {reco.total_gb} GB
+
+ );
+ }
+ return (
+
+
+
{t('models.reco_for', { device: reco.device.label })}
+
+ {(() => {
+ const requiredMissing = reco.models.filter(m => m.required && !m.installed);
+ const requiredGb = requiredMissing.reduce((s, m) => s + m.size_gb, 0);
+ if (requiredMissing.length === 0) return null;
+ return (
+ {
+ setInstallingReco(true);
+ try {
+ await Promise.all(requiredMissing.map(m => installMutation.mutateAsync(m.repo_id)));
+ toast.success(t('models.started_downloading_required', { count: requiredMissing.length }));
+ } catch (e) { toast.error(t('models.install_failed', { message: e.message || e })); }
+ finally { setInstallingReco(false); }
+ }}
+ disabled={installingReco}
+ leading={installingReco ? : null}
+ >
+ {installingReco ? t('models.starting') : t('models.required_size', { size: requiredGb.toFixed(1) })}
+
+ );
+ })()}
+
+ {t('models.all_size', { size: reco.download_gb_remaining })}
+
+
+
+
+ {reco.models.map(m => (
+
+ {m.installed ? '✓' : '○'} {m.label}
+ {m.size_gb}
+ {m.required && {t('models.req_tag')} }
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/models/columns.jsx b/frontend/src/components/settings/models/columns.jsx
new file mode 100644
index 00000000..832c4130
--- /dev/null
+++ b/frontend/src/components/settings/models/columns.jsx
@@ -0,0 +1,244 @@
+import React from 'react';
+import {
+ RefreshCw, Trash2, ExternalLink, Download,
+} from 'lucide-react';
+import { openExternal } from '../../../api/external';
+import { Button, Badge, Progress } from '../../../ui';
+import { fmtBytes, orgColor } from './format';
+
+/**
+ * Build the TanStack column definitions for the model store table.
+ *
+ * The column cells close over runtime values, so this is a factory rather than
+ * a static array: pass in the live callbacks/refs from the host component and
+ * memoize the result with the same dependency array the inline definition used.
+ */
+export function makeModelColumns({
+ t,
+ getRowRuntime,
+ speedRef,
+ MODEL_ROLE_LABEL,
+ onInstall,
+ onDelete,
+ onReinstall,
+}) {
+ return [
+ {
+ id: 'name',
+ accessorFn: m => `${m.label || ''} ${m.repo_id || ''}`,
+ header: t('models.column_model'),
+ size: 260,
+ meta: { className: 'models-row__name' },
+ cell: ({ row }) => {
+ const m = row.original;
+ const rt = getRowRuntime(m);
+ return (
+ <>
+
+
+ {m.repo_id.split('/')[0].slice(0, 2).toUpperCase()}
+
+ {m.label}
+ {m.required && {t('models.required_tag')} }
+
+
+ {m.repo_id}
+ {m.note && · {m.note} }
+
+ {rt.showBar && (
+
+
+
+ {(() => {
+ if (rt.isDeleting) return t('models.removing_cached');
+ const hasAgg = !!rt.agg;
+ if (!rt.hasFiles && !hasAgg) {
+ if (rt.phase === 'resolving') {
+ const dots = '.'.repeat((rt.rs?.resolvingStep || 0) % 4);
+ // Once the preflight plan lands we can show the real size
+ // even before the first byte (FDL-05).
+ const planBytes = rt.plan?.to_download_bytes;
+ const planStr = planBytes ? ` · ${fmtBytes(planBytes)} ${t('models.to_download') || 'to download'}` : '';
+ return `${t('models.resolving_metadata')}${dots}${planStr}`;
+ }
+ if (rt.phase === 'install_retry') {
+ return t('models.retry_attempt', { attempt: rt.rs?.retryAttempt || '?', error: rt.rs?.error || 'reconnecting' });
+ }
+ return t('models.connecting_hf');
+ }
+
+ // Prefer the backend aggregate's windowed rate (FDL-06).
+ // Fall back to the frontend sampler only until it arrives.
+ let speed = rt.aggRate ?? 0;
+ if (!(speed > 0)) {
+ const sp = speedRef.current[m.repo_id];
+ const now = Date.now();
+ if (sp && rt.dispDownloaded > 0) {
+ const dt = (now - sp.lastTime) / 1000;
+ if (dt >= 1) {
+ sp.speed = Math.max(0, (rt.dispDownloaded - sp.lastBytes) / dt);
+ sp.lastBytes = rt.dispDownloaded;
+ sp.lastTime = now;
+ }
+ } else {
+ speedRef.current[m.repo_id] = { lastBytes: rt.dispDownloaded, lastTime: now, speed: 0 };
+ }
+ speed = rt.backendRate > 0 ? rt.backendRate : (sp?.speed || 0);
+ }
+
+ // Total unknown and nothing downloaded yet → still resolving
+ if (rt.dispTotal === 0 && rt.dispDownloaded === 0) {
+ const activeFile = rt.activeFilename?.split('/').pop();
+ return activeFile
+ ? t('models.resolving_files_active', { count: rt.fileList.length, file: activeFile })
+ : t('models.resolving_files', { count: rt.fileList.length });
+ }
+
+ // ETA: prefer the backend's, else derive from remaining/speed.
+ const remaining = Math.max(0, rt.dispTotal - rt.dispDownloaded);
+ const etaSec = rt.aggEtaSec != null ? rt.aggEtaSec
+ : (speed > 0 && rt.dispTotal > 0 ? remaining / speed : 0);
+ const etaStr = etaSec > 0
+ ? etaSec < 60 ? `~${Math.ceil(etaSec)}s`
+ : etaSec < 3600 ? `~${Math.ceil(etaSec / 60)}m`
+ : `~${(etaSec / 3600).toFixed(1)}h`
+ : '';
+ const dlStr = fmtBytes(rt.dispDownloaded) || '0 B';
+ const totalStr = rt.dispTotal > 0 ? fmtBytes(rt.dispTotal) : '…';
+ const pctStr = rt.aggPct != null && rt.aggPct > 0 ? `${Math.round(rt.aggPct)}%` : '';
+ const speedStr = speed > 0 ? `${fmtBytes(speed)}/s` : '';
+
+ const parts = [
+ `${dlStr} / ${totalStr}`,
+ pctStr,
+ speedStr || (rt.dispDownloaded > 0 ? t('models.measuring') : ''),
+ etaStr,
+ ].filter(Boolean);
+
+ const extra = [];
+ if (rt.cachedBytes > 0) extra.push(`${fmtBytes(rt.cachedBytes)} ${t('models.cached') || 'cached'}`);
+ if (rt.filesTotal > 1) extra.push(t('models.files_progress', { done: rt.filesDone, total: rt.filesTotal }));
+ if (rt.activeFilename) extra.push(rt.activeFilename.split('/').pop());
+
+ return extra.length
+ ? `${parts.join(' · ')} ⸱ ${extra.join(' · ')}`
+ : parts.join(' · ');
+ })()}
+
+
+ )}
+ {rt.phase === 'install_error' && rt.rs?.error && (
+ {t('models.install_error', { error: rt.rs.error })}
+ )}
+ >
+ );
+ },
+ },
+ {
+ id: 'role',
+ accessorFn: m => (m.role || 'other').toLowerCase(),
+ header: t('models.column_role'),
+ size: 58,
+ filterFn: (row, id, value) => !value || row.getValue(id) === value,
+ cell: ({ row }) => {MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'} ,
+ },
+ {
+ id: 'size',
+ accessorFn: m => m.installed ? (m.size_on_disk_bytes || 0) : (m.size_gb || 0) * 1024 ** 3,
+ header: t('models.column_size'),
+ size: 68,
+ meta: { align: 'right', className: 'models-row__size' },
+ cell: ({ row }) => {
+ const m = row.original;
+ const rt = getRowRuntime(m);
+ // During active download, show live downloaded / total
+ if (rt.showBar && rt.hasFiles && rt.totals.total > 0) {
+ return {fmtBytes(rt.totals.downloaded)}/ {fmtBytes(rt.totals.total)} ;
+ }
+ return m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`;
+ },
+ },
+ {
+ id: 'status',
+ accessorFn: m => m.installed ? 2 : (m.supported === false ? 0 : 1),
+ header: t('models.column_status'),
+ size: 96,
+ meta: { align: 'center', className: 'models-row__status' },
+ cell: ({ row }) => {
+ const m = row.original;
+ const rt = getRowRuntime(m);
+ return rt.isInstalling
+ ? {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : t('models.downloading')}
+ : rt.isDeleting
+ ? {t('models.deleting')}
+ : rt.rowBusy
+ ? {t('models.working')}
+ : m.installed
+ ? {t('models.installed')}
+ : rt.unsupported
+ ? {(m.platforms || []).join(', ')}
+ : {t('models.not_installed')} ;
+ },
+ },
+ {
+ id: 'actions',
+ header: '',
+ size: 90,
+ enableSorting: false,
+ meta: { align: 'right', className: 'models-row__actions' },
+ cell: ({ row }) => {
+ const m = row.original;
+ const rt = getRowRuntime(m);
+ return (
+ <>
+ openExternal(`https://huggingface.co/${m.repo_id}`)}
+ title={t('models.view_on_hf')}
+ aria-label={t('models.view_on_hf')}
+ >
+
+
+ {!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && (
+ onInstall(m.repo_id)}
+ leading={ }
+ >
+ {t('models.install_btn')}
+
+ )}
+ {m.installed && !rt.rowBusy && !rt.isDeleting && (
+ <>
+ onReinstall(m.repo_id)}
+ title={t('models.reinstall_btn')}
+ aria-label={t('models.reinstall_btn')}
+ >
+
+
+ onDelete(m.repo_id)}
+ title={t('models.delete_btn')}
+ aria-label={t('models.delete_btn')}
+ >
+
+
+ >
+ )}
+ >
+ );
+ },
+ },
+ ];
+}
diff --git a/frontend/src/components/settings/models/format.js b/frontend/src/components/settings/models/format.js
new file mode 100644
index 00000000..60d4cb7d
--- /dev/null
+++ b/frontend/src/components/settings/models/format.js
@@ -0,0 +1,17 @@
+/** Pure, closure-free formatting helpers for the model store. */
+
+export function fmtBytes(n) {
+ if (n == null || n < 0) return '—';
+ if (n === 0) return '0 B';
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`;
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
+ return `${Math.round(n / 1024)} KB`;
+}
+
+/** Deterministic muted HSL color from an org/user name in a repo_id. */
+export function orgColor(repoId) {
+ const org = (repoId || '').split('/')[0];
+ let h = 0;
+ for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) & 0xffff;
+ return `hsl(${h % 360}, 35%, 28%)`;
+}
diff --git a/frontend/src/components/settings/models/runtime.js b/frontend/src/components/settings/models/runtime.js
new file mode 100644
index 00000000..86db4ac4
--- /dev/null
+++ b/frontend/src/components/settings/models/runtime.js
@@ -0,0 +1,75 @@
+/**
+ * Derive a model row's runtime display state from the live SSE-driven
+ * `rowState` map and the set of `busy` repo ids. Pure — no React/closure deps
+ * beyond its arguments — so it can be memoized by the host component.
+ */
+export function computeRowRuntime(m, rowState, busy) {
+ const rs = rowState[m.repo_id];
+ const rowBusy = busy.has(m.repo_id);
+ const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error);
+ const isDeleting = rs?.phase === 'delete_start';
+ const phase = rs?.phase;
+ const fileList = rs?.files ? Object.entries(rs.files) : [];
+ const totals = fileList.reduce((a, [, f]) => ({
+ downloaded: a.downloaded + (f.downloaded || 0),
+ total: a.total + (f.total || 0),
+ done: a.done + (f.phase === 'done' ? 1 : 0),
+ }), { downloaded: 0, total: 0, done: 0 });
+ // Sum backend-reported rate from active (non-done) files
+ const backendRate = fileList
+ .filter(([, f]) => f.phase !== 'done' && f.rate > 0)
+ .reduce((s, [, f]) => s + f.rate, 0);
+ const hasFiles = fileList.length > 0;
+ const showBar = ['install_start', 'resolving', 'install_retry', 'active', 'delete_start'].includes(phase);
+ const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
+ const unsupported = m.supported === false;
+
+ // Overall progress: prefer the backend aggregate (FDL-06) — it sums bytes
+ // across all parallel files/chunks and samples a windowed rate, which is
+ // accurate under Xet's parallel fetch. Fall back to per-file summation +
+ // the frontend speed sampler only until the first aggregate event lands.
+ const agg = rs?.agg || null;
+ const plan = rs?.plan || null;
+ const dispDownloaded = agg ? (agg.bytes_done || 0) : totals.downloaded;
+ // Denominator: aggregate total → preflight "to download" → per-file totals.
+ const dispTotal = (agg?.total_bytes ?? plan?.to_download_bytes ?? totals.total) || 0;
+ // Bar %: prefer byte-fraction, but under Xet the byte bars don't advance
+ // mid-download (only the file-count bar does), so fall back to the file
+ // fraction so the bar still moves. Take the max so whichever signal is
+ // live drives it; complete() flushes both to 100% at the end.
+ const bytePct = dispTotal > 0 ? (dispDownloaded / dispTotal) * 100 : 0;
+ const filesTotalForPct = agg?.files_total ?? plan?.n_files ?? 0;
+ const filePct = filesTotalForPct > 0 ? ((agg?.files_done ?? 0) / filesTotalForPct) * 100 : 0;
+ const aggPct = (dispTotal > 0 || filesTotalForPct > 0) ? Math.max(bytePct, filePct) : null;
+ const cachedBytes = plan?.cached_bytes ?? null;
+ const filesTotal = agg?.files_total ?? plan?.n_files ?? (hasFiles ? fileList.length : null);
+ const filesDone = agg?.files_done ?? totals.done;
+ // Backend rate (windowed aggregate) wins over the per-file rate sum.
+ const aggRate = agg?.rate ?? null;
+ const aggEtaSec = agg?.eta_seconds ?? null;
+
+ return {
+ rs,
+ rowBusy,
+ isInstalling,
+ isDeleting,
+ phase,
+ fileList,
+ totals,
+ hasFiles,
+ aggPct,
+ showBar,
+ activeFilename,
+ unsupported,
+ backendRate,
+ agg,
+ plan,
+ dispDownloaded,
+ dispTotal,
+ cachedBytes,
+ filesTotal,
+ filesDone,
+ aggRate,
+ aggEtaSec,
+ };
+}
diff --git a/frontend/src/components/settings/native.js b/frontend/src/components/settings/native.js
new file mode 100644
index 00000000..ea48a85d
--- /dev/null
+++ b/frontend/src/components/settings/native.js
@@ -0,0 +1,16 @@
+import { isTauri as _isTauri } from '../../utils/media';
+
+/** True when running inside the Tauri webview (vs. vite dev / web preview / tests). */
+export const isTauri = () => _isTauri;
+
+// Tauri v2's webview disables native window.confirm/alert — they return
+// false silently, making Delete/Reinstall buttons appear dead. Route through
+// the dialog plugin when running in Tauri, fall back to browser confirm
+// elsewhere (vite dev, tests).
+export async function askConfirm(message, title = 'Confirm') {
+ if (isTauri()) {
+ const { ask } = await import('@tauri-apps/plugin-dialog');
+ return ask(message, { title, kind: 'warning' });
+ }
+ return Promise.resolve(window.confirm(message));
+}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 81bcbb9c..a15bc74b 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,39 +1,22 @@
-import React, { useEffect, useState, useCallback, useMemo } from 'react';
+import React, { useEffect, useState, useCallback } from 'react';
import { copyText } from "../utils/copyText";
-import { isTauri as _isTauri } from '../utils/media';
import { normalizeChannel } from '../utils/updateChannel';
import { setChannel } from '../utils/channelControl';
import {
- flexRender,
- getCoreRowModel,
- getFilteredRowModel,
- getSortedRowModel,
- useReactTable,
-} from '@tanstack/react-table';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import {
- Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
- CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound,
- Keyboard, Wifi, Palette, Activity, ArrowDownToLine, Settings2, Globe,
+ Cpu, FileText, Info, ShieldCheck, RefreshCw,
+ CheckCircle, Plug, KeyRound,
+ Keyboard, Wifi, Palette, ArrowDownToLine, Settings2,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
-import { openExternal } from '../api/external';
import { API } from '../api/client';
-import { addBreadcrumb } from '../utils/breadcrumbs';
-import { Trans, useTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system';
-import i18n, { LANGUAGES } from '../i18n';
-import { useSysinfo, useModelStatus, useSystemInfo, queryKeys } from '../api/hooks';
-import { useQueryClient } from '@tanstack/react-query';
-import { selectEngine } from '../api/engines';
-import { setupDownloadStreamUrl } from '../api/setup';
+import { useSysinfo, useModelStatus, useSystemInfo } from '../api/hooks';
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { resolveAboutVersion } from '../utils/appVersion';
-import { Tabs, Segmented, Button, Badge, Table, Progress, Select } from '../ui';
-import { SettingsSection, SettingRow, SettingsInput, Collapsible } from '../components/settings/primitives';
+import { Tabs, Badge } from '../ui';
+import { SettingsSection } from '../components/settings/primitives';
import { useAppStore } from '../store';
-import ApiKeysPanel from '../components/settings/ApiKeysPanel';
-import LLMEndpointPanel from '../components/settings/LLMEndpointPanel';
import PerformancePanel from '../components/settings/PerformancePanel';
import RefinementPanel from '../components/settings/RefinementPanel';
import AecPanel from '../components/settings/AecPanel';
@@ -45,10 +28,17 @@ import SharingPanel from '../components/settings/SharingPanel';
import RemoteBackendPanel from '../components/settings/RemoteBackendPanel';
import MCPBindingsPanel from '../components/settings/MCPBindingsPanel';
import PronunciationPanel from '../components/settings/PronunciationPanel';
-import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix';
import DictationDemo from '../components/DictationDemo';
import UpdatesPanel from '../components/UpdatesPanel';
-import ReportBugButton from '../components/ReportBugButton';
+import GeneralTab from '../components/settings/GeneralTab';
+import ModelStoreTab from '../components/settings/ModelStoreTab';
+import EnginesTab from '../components/settings/EnginesTab';
+import HotkeyTab from '../components/settings/HotkeyTab';
+import CredentialsTab from '../components/settings/CredentialsTab';
+import AboutTab from '../components/settings/AboutTab';
+import PrivacyTab from '../components/settings/PrivacyTab';
+import LogsTab from '../components/settings/LogsTab';
+import { isTauri, askConfirm } from '../components/settings/native';
import './Settings.css';
// Ordered as a logical flow: setup basics first (General/Appearance), then the
@@ -68,1095 +58,6 @@ const TAB_DEFS = [
{ id: 'privacy', icon: ShieldCheck },
];
-const LOG_SOURCE_DEFS = [
- { value: 'backend', key: 'backend' },
- { value: 'frontend', key: 'frontend' },
- { value: 'tauri', key: 'tauri' },
-];
-
-const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
-const MODEL_ROLE_LABEL = { all: 'All', tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
-
-function GeneralTab() {
- const { t } = useTranslation();
- const locale = useAppStore(s => s.locale);
- const setLocale = useAppStore(s => s.setLocale);
- const theme = useAppStore(s => s.theme);
- const setTheme = useAppStore(s => s.setTheme);
- const { data: sysInfo } = useSystemInfo();
- const [proxyUrl, setProxyUrl] = useState('');
- const [proxySaved, setProxySaved] = useState(false);
- const [proxySaving, setProxySaving] = useState(false);
- const [ffmpegPath, setFfmpegPath] = useState('');
- const [ffmpegSaving, setFfmpegSaving] = useState(false);
- const queryClient = useQueryClient();
-
- // Sync inputs with persisted values from backend on load
- useEffect(() => {
- if (!proxyUrl && !proxySaved) setProxyUrl(sysInfo?.proxy_url || '');
- }, [sysInfo?.proxy_url]);
-
- useEffect(() => {
- if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || '');
- }, [sysInfo?.ffmpeg_path]);
-
- const ffmpegOk = sysInfo?.ffmpeg_ok;
- const ffmpegCurrent = sysInfo?.ffmpeg_path;
-
- const saveFfmpeg = async () => {
- const value = ffmpegPath.trim();
- setFfmpegSaving(true);
- try {
- const { API } = await import('../api/client');
- const r = await fetch(`${API}/system/set-env`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key: 'FFMPEG_PATH', value }),
- });
- if (r.ok) {
- toast.success(t('settings.ffmpeg_saved'));
- setFfmpegPath('');
- queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
- } else {
- const d = await r.json().catch(() => ({}));
- toast.error(d.detail || t('credentials.save_failed'));
- }
- } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); }
- finally { setFfmpegSaving(false); }
- };
-
- const handleLocaleChange = (e) => {
- const id = e.target.value;
- setLocale(id);
- i18n.changeLanguage(id);
- };
-
- const saveProxy = async () => {
- const value = proxyUrl.trim();
- setProxySaving(true);
- try {
- const { API } = await import('../api/client');
- const setEnv = (key, val) => fetch(`${API}/system/set-env`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key, value: val }),
- });
- const r = await setEnv('HTTP_PROXY', value);
- if (r.ok) {
- await Promise.all([
- setEnv('HTTPS_PROXY', value),
- setEnv('ALL_PROXY', value),
- setEnv('http_proxy', value),
- setEnv('https_proxy', value),
- setEnv('all_proxy', value),
- ]);
- toast.success(t('settings.proxy_saved'));
- setProxySaved(true);
- queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
- } else {
- const d = await r.json().catch(() => ({}));
- toast.error(d.detail || t('settings.proxy_save_failed'));
- }
- } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); }
- finally { setProxySaving(false); }
- };
-
- const clearProxy = async () => {
- setProxySaving(true);
- try {
- const { API } = await import('../api/client');
- const setEnv = (key, val) => fetch(`${API}/system/set-env`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key, value: val }),
- });
- await Promise.all([
- setEnv('HTTP_PROXY', ''),
- setEnv('HTTPS_PROXY', ''),
- setEnv('ALL_PROXY', ''),
- setEnv('http_proxy', ''),
- setEnv('https_proxy', ''),
- setEnv('all_proxy', ''),
- ]);
- setProxyUrl('');
- setProxySaved(false);
- toast.success(t('settings.proxy_cleared'));
- queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
- } catch (e) { toast.error(t('settings.clear_failed', { message: e.message })); }
- finally { setProxySaving(false); }
- };
-
- return (
-
-
- {LANGUAGES.map((l) => (
- {l.label}
- ))}
-
- }
- />
- setTheme(e.target.value)}>
- Gruvbox
- Midnight
- Nord
- Solarized
- Rose Pine
- Catppuccin
-
- }
- />
-
-
-
- {t('settings.proxy')}
- {proxySaved && {t('credentials.saved')} }
- >
- }
- note={t('settings.proxy_desc')}
- control={
- <>
- setProxyUrl(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && saveProxy()}
- />
-
- {t('credentials.save')}
-
- {proxySaved && (
-
- {t('settings.proxy_clear')}
-
- )}
- >
- }
- />
-
-
- {t('settings.ffmpeg')}
-
- {ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
-
- >
- }
- note={ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')}
- control={
- <>
- setFfmpegPath(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && saveFfmpeg()}
- />
-
- {t('credentials.save')}
-
- >
- }
- />
-
-
- );
-}
-
-// About/Privacy read-only data rows delegate to the shared SettingRow primitive
-// so they pick up the redesigned grid + mono value styling unchanged.
-function Row({ label, value, mono }) {
- return ;
-}
-
-function fmtBytes(n) {
- if (n == null || n < 0) return '—';
- if (n === 0) return '0 B';
- if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`;
- if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
- return `${Math.round(n / 1024)} KB`;
-}
-
-/** Deterministic muted HSL color from an org/user name in a repo_id. */
-function orgColor(repoId) {
- const org = (repoId || '').split('/')[0];
- let h = 0;
- for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) & 0xffff;
- return `hsl(${h % 360}, 35%, 28%)`;
-}
-
-import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../api/hooks';
-
-/**
- * Model store — list every known HF model, show install state, let the
- * user install / reinstall / delete individual models. Per-model download
- * progress is pulled from the shared /setup/download-stream SSE.
- */
-export function ModelStoreTab({ info, modelBadge }) {
- const { t } = useTranslation();
- const modelsQuery = useModels();
- const recoQuery = useRecommendations();
- const data = modelsQuery.data;
- const loading = modelsQuery.isLoading;
- const reco = recoQuery.data;
- const installMutation = useInstallModel();
- const deleteMutation = useDeleteModel();
-
- const [busy, setBusy] = useState(new Set()); // repo_ids currently working
- // Per-repo active state. Tracks aggregate download across all files of
- // a running install so the row can show a determinate progress bar.
- // { [repo_id]: { phase, files: { [filename]: { downloaded, total, pct } }, error } }
- const [rowState, setRowState] = useState({});
- const [query, setQuery] = useState('');
- const [installingReco, setInstallingReco] = useState(false);
- const [activeRole, setActiveRole] = useState(null);
- const [sorting, setSorting] = useState([]);
- const [columnFilters, setColumnFilters] = useState([]);
- const esRef = React.useRef(null);
- const tableBodyRef = React.useRef(null);
- // Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } }
- const speedRef = React.useRef({});
- // Tick counter — forces re-render every second while a download is active
- // so speed/ETA displays update smoothly between SSE events.
- const [, setTick] = useState(0);
- // Boolean derived from rowState so the interval effect below only re-runs
- // when activity starts/stops — not on every SSE progress event (several per
- // second during installs), which would clear + recreate the 1s tick forever.
- const hasActive = useMemo(() => Object.values(rowState).some(s =>
- ['install_start', 'active', 'delete_start'].includes(s.phase)), [rowState]);
- useEffect(() => {
- if (!hasActive) return;
- const iv = setInterval(() => setTick(t => t + 1), 1000);
- return () => clearInterval(iv);
- }, [hasActive]);
-
- // HF token inline — compact input in the toolbar
- const [hfToken, setHfToken] = useState('');
- const [hfSaved, setHfSaved] = useState(false);
- const [hfSaving, setHfSaving] = useState(false);
- const [hfExpanded, setHfExpanded] = useState(false);
- const saveHfToken = async () => {
- const value = hfToken.trim();
- if (!value) return;
- setHfSaving(true);
- try {
- const { API } = await import('../api/client');
- const res = await fetch(`${API}/system/set-env`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key: 'HF_TOKEN', value }),
- });
- if (res.ok) {
- toast.success(t('models.hf_token_set_toast'));
- setHfSaved(true);
- setHfToken('');
- setHfExpanded(false);
- } else {
- const d = await res.json().catch(() => ({}));
- toast.error(d.detail || t('models.hf_token_save_failed'));
- }
- } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); }
- finally { setHfSaving(false); }
- };
- const hfTokenSet = hfSaved || info?.has_hf_token;
-
- // Open the progress stream once when the tab mounts; close on unmount.
- useEffect(() => {
- const es = new EventSource(setupDownloadStreamUrl());
- esRef.current = es;
- es.onmessage = (evt) => {
- try {
- const ev = JSON.parse(evt.data);
- if (!ev?.repo_id) return;
- setRowState(prev => {
- const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
- // Lifecycle events (install_start/install_done/install_error,
- // delete_start/delete_done) flip the row's phase without
- // touching per-file accounting.
- if (ev.phase === 'install_start' || ev.phase === 'delete_start') {
- return { ...prev, [ev.repo_id]: { phase: ev.phase, files: {}, error: null } };
- }
- // Heartbeat from backend while resolving repo metadata
- if (ev.phase === 'resolving') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 } };
- }
- if (ev.phase === 'install_retry') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error } };
- }
- if (ev.phase === 'install_done') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_done' } };
- }
- if (ev.phase === 'delete_done') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'delete_done' } };
- }
- if (ev.phase === 'install_error') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } };
- }
- if (ev.phase === 'install_cancelled') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_cancelled' } };
- }
- // Pre-flight plan (FDL-05): accurate total/cached/remaining BEFORE
- // bytes flow. Keep the current phase (usually resolving) — the plan
- // is metadata, not a state change.
- if (ev.phase === 'install_plan') {
- return { ...prev, [ev.repo_id]: { ...cur, plan: {
- total_bytes: ev.total_bytes ?? null,
- cached_bytes: ev.cached_bytes ?? null,
- to_download_bytes: ev.to_download_bytes ?? null,
- n_files: ev.n_files ?? null,
- n_cached: ev.n_cached ?? null,
- } } };
- }
- // Overall aggregate (FDL-06): one rolling event that is the source of
- // truth for the overall bar / speed / remaining / ETA.
- if (ev.phase === 'aggregate') {
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', agg: {
- bytes_done: ev.bytes_done ?? 0,
- total_bytes: ev.total_bytes ?? null,
- rate: ev.rate ?? 0,
- eta_seconds: ev.eta_seconds ?? null,
- files_done: ev.files_done ?? 0,
- files_total: ev.files_total ?? null,
- } } };
- }
- // Per-file tqdm events — aggregate across files.
- const files = { ...cur.files, [ev.filename]: {
- downloaded: ev.downloaded || 0,
- total: ev.total || 0,
- pct: ev.pct || 0,
- phase: ev.phase,
- rate: ev.rate || 0,
- }};
- return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', files } };
- });
- } catch { /* keepalive / ignore */ }
- };
- return () => es.close();
- }, []);
-
- // When a lifecycle terminator fires, refresh the list so "installed"
- // flips server-side info into the row.
- useEffect(() => {
- const term = Object.entries(rowState).find(([, s]) =>
- ['install_done', 'delete_done', 'install_error', 'install_cancelled'].includes(s.phase));
- if (!term) return;
- const t = setTimeout(() => {
- modelsQuery.refetch();
- recoQuery.refetch();
- // Clear stale speed data for this repo.
- delete speedRef.current[term[0]];
- // Clear the terminal entry so the row reverts to the authoritative
- // `installed` flag from /models without keeping stale progress.
- setRowState(prev => {
- const next = { ...prev };
- delete next[term[0]];
- return next;
- });
- }, 800);
- return () => clearTimeout(t);
- }, [rowState, modelsQuery, recoQuery]);
-
- const reload = useCallback(() => {
- modelsQuery.refetch();
- recoQuery.refetch();
- }, [modelsQuery, recoQuery]);
-
- const withBusy = useCallback(async (repoId, fn, successMsg) => {
- setBusy(prev => new Set(prev).add(repoId));
- try {
- await fn();
- if (successMsg) toast.success(successMsg);
- } catch (e) {
- toast.error(e.message || String(e));
- } finally {
- setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; });
- }
- }, []);
-
- const onInstall = useCallback((repoId) =>
- withBusy(repoId, () => installMutation.mutateAsync(repoId), t('models.install_started')),
- [installMutation, withBusy]);
- const onDelete = useCallback(async (repoId) => {
- if (!(await askConfirm(t('models.delete_confirm', { repoId }), t('models.delete_confirm_title')))) return;
- return withBusy(repoId, () => deleteMutation.mutateAsync(repoId), t('models.deleted', { repoId }));
- }, [deleteMutation, withBusy]);
- const onReinstall = useCallback(async (repoId) => {
- if (!(await askConfirm(t('models.reinstall_confirm', { repoId }), t('models.reinstall_confirm_title')))) return;
- await withBusy(repoId, async () => {
- await deleteMutation.mutateAsync(repoId);
- await installMutation.mutateAsync(repoId);
- }, t('models.reinstalling'));
- }, [deleteMutation, installMutation, withBusy]);
-
- const onInstallRecommended = async () => {
- if (!reco) return;
- const missing = reco.models.filter(m => !m.installed);
- if (missing.length === 0) {
- toast.success(t('models.recommended_installed'));
- return;
- }
- setInstallingReco(true);
- try {
- // Parallel install — backend /models/install spawns each download on
- // its own asyncio task so ordering doesn't matter.
- await Promise.all(missing.map(m => installMutation.mutateAsync(m.repo_id)));
- toast.success(t('models.started_downloading', { count: missing.length }));
- } catch (e) {
- toast.error(t('models.install_failed', { message: e.message || e }));
- } finally {
- setInstallingReco(false);
- }
- };
-
- const allModels = React.useMemo(() => data?.models || [], [data]);
- const groups = allModels.reduce((acc, m) => {
- const k = (m.role || 'other').toLowerCase();
- (acc[k] = acc[k] || []).push(m);
- return acc;
- }, {});
- const roles = Object.keys(groups).sort((a, b) => {
- const ai = MODEL_ROLE_ORDER.indexOf(a), bi = MODEL_ROLE_ORDER.indexOf(b);
- return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
- });
- // 'all' is a virtual role — shows every model regardless of category.
- const currentRole = activeRole === 'all' ? 'all'
- : activeRole && groups[activeRole] ? activeRole
- : 'all';
-
- const allInstalled = allModels.filter(m => m.installed).length;
-
- useEffect(() => {
- setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]);
- }, [currentRole]);
-
- const getRowRuntime = React.useCallback((m) => {
- const rs = rowState[m.repo_id];
- const rowBusy = busy.has(m.repo_id);
- const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error);
- const isDeleting = rs?.phase === 'delete_start';
- const phase = rs?.phase;
- const fileList = rs?.files ? Object.entries(rs.files) : [];
- const totals = fileList.reduce((a, [, f]) => ({
- downloaded: a.downloaded + (f.downloaded || 0),
- total: a.total + (f.total || 0),
- done: a.done + (f.phase === 'done' ? 1 : 0),
- }), { downloaded: 0, total: 0, done: 0 });
- // Sum backend-reported rate from active (non-done) files
- const backendRate = fileList
- .filter(([, f]) => f.phase !== 'done' && f.rate > 0)
- .reduce((s, [, f]) => s + f.rate, 0);
- const hasFiles = fileList.length > 0;
- const showBar = ['install_start', 'resolving', 'install_retry', 'active', 'delete_start'].includes(phase);
- const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
- const unsupported = m.supported === false;
-
- // Overall progress: prefer the backend aggregate (FDL-06) — it sums bytes
- // across all parallel files/chunks and samples a windowed rate, which is
- // accurate under Xet's parallel fetch. Fall back to per-file summation +
- // the frontend speed sampler only until the first aggregate event lands.
- const agg = rs?.agg || null;
- const plan = rs?.plan || null;
- const dispDownloaded = agg ? (agg.bytes_done || 0) : totals.downloaded;
- // Denominator: aggregate total → preflight "to download" → per-file totals.
- const dispTotal = (agg?.total_bytes ?? plan?.to_download_bytes ?? totals.total) || 0;
- // Bar %: prefer byte-fraction, but under Xet the byte bars don't advance
- // mid-download (only the file-count bar does), so fall back to the file
- // fraction so the bar still moves. Take the max so whichever signal is
- // live drives it; complete() flushes both to 100% at the end.
- const bytePct = dispTotal > 0 ? (dispDownloaded / dispTotal) * 100 : 0;
- const filesTotalForPct = agg?.files_total ?? plan?.n_files ?? 0;
- const filePct = filesTotalForPct > 0 ? ((agg?.files_done ?? 0) / filesTotalForPct) * 100 : 0;
- const aggPct = (dispTotal > 0 || filesTotalForPct > 0) ? Math.max(bytePct, filePct) : null;
- const cachedBytes = plan?.cached_bytes ?? null;
- const filesTotal = agg?.files_total ?? plan?.n_files ?? (hasFiles ? fileList.length : null);
- const filesDone = agg?.files_done ?? totals.done;
- // Backend rate (windowed aggregate) wins over the per-file rate sum.
- const aggRate = agg?.rate ?? null;
- const aggEtaSec = agg?.eta_seconds ?? null;
-
- return {
- rs,
- rowBusy,
- isInstalling,
- isDeleting,
- phase,
- fileList,
- totals,
- hasFiles,
- aggPct,
- showBar,
- activeFilename,
- unsupported,
- backendRate,
- agg,
- plan,
- dispDownloaded,
- dispTotal,
- cachedBytes,
- filesTotal,
- filesDone,
- aggRate,
- aggEtaSec,
- };
- }, [busy, rowState]);
-
- const columns = React.useMemo(() => [
- {
- id: 'name',
- accessorFn: m => `${m.label || ''} ${m.repo_id || ''}`,
- header: t('models.column_model'),
- size: 260,
- meta: { className: 'models-row__name' },
- cell: ({ row }) => {
- const m = row.original;
- const rt = getRowRuntime(m);
- return (
- <>
-
-
- {m.repo_id.split('/')[0].slice(0, 2).toUpperCase()}
-
- {m.label}
- {m.required && {t('models.required_tag')} }
-
-
- {m.repo_id}
- {m.note && · {m.note} }
-
- {rt.showBar && (
-
-
-
- {(() => {
- if (rt.isDeleting) return t('models.removing_cached');
- const hasAgg = !!rt.agg;
- if (!rt.hasFiles && !hasAgg) {
- if (rt.phase === 'resolving') {
- const dots = '.'.repeat((rt.rs?.resolvingStep || 0) % 4);
- // Once the preflight plan lands we can show the real size
- // even before the first byte (FDL-05).
- const planBytes = rt.plan?.to_download_bytes;
- const planStr = planBytes ? ` · ${fmtBytes(planBytes)} ${t('models.to_download') || 'to download'}` : '';
- return `${t('models.resolving_metadata')}${dots}${planStr}`;
- }
- if (rt.phase === 'install_retry') {
- return t('models.retry_attempt', { attempt: rt.rs?.retryAttempt || '?', error: rt.rs?.error || 'reconnecting' });
- }
- return t('models.connecting_hf');
- }
-
- // Prefer the backend aggregate's windowed rate (FDL-06).
- // Fall back to the frontend sampler only until it arrives.
- let speed = rt.aggRate ?? 0;
- if (!(speed > 0)) {
- const sp = speedRef.current[m.repo_id];
- const now = Date.now();
- if (sp && rt.dispDownloaded > 0) {
- const dt = (now - sp.lastTime) / 1000;
- if (dt >= 1) {
- sp.speed = Math.max(0, (rt.dispDownloaded - sp.lastBytes) / dt);
- sp.lastBytes = rt.dispDownloaded;
- sp.lastTime = now;
- }
- } else {
- speedRef.current[m.repo_id] = { lastBytes: rt.dispDownloaded, lastTime: now, speed: 0 };
- }
- speed = rt.backendRate > 0 ? rt.backendRate : (sp?.speed || 0);
- }
-
- // Total unknown and nothing downloaded yet → still resolving
- if (rt.dispTotal === 0 && rt.dispDownloaded === 0) {
- const activeFile = rt.activeFilename?.split('/').pop();
- return activeFile
- ? t('models.resolving_files_active', { count: rt.fileList.length, file: activeFile })
- : t('models.resolving_files', { count: rt.fileList.length });
- }
-
- // ETA: prefer the backend's, else derive from remaining/speed.
- const remaining = Math.max(0, rt.dispTotal - rt.dispDownloaded);
- const etaSec = rt.aggEtaSec != null ? rt.aggEtaSec
- : (speed > 0 && rt.dispTotal > 0 ? remaining / speed : 0);
- const etaStr = etaSec > 0
- ? etaSec < 60 ? `~${Math.ceil(etaSec)}s`
- : etaSec < 3600 ? `~${Math.ceil(etaSec / 60)}m`
- : `~${(etaSec / 3600).toFixed(1)}h`
- : '';
- const dlStr = fmtBytes(rt.dispDownloaded) || '0 B';
- const totalStr = rt.dispTotal > 0 ? fmtBytes(rt.dispTotal) : '…';
- const pctStr = rt.aggPct != null && rt.aggPct > 0 ? `${Math.round(rt.aggPct)}%` : '';
- const speedStr = speed > 0 ? `${fmtBytes(speed)}/s` : '';
-
- const parts = [
- `${dlStr} / ${totalStr}`,
- pctStr,
- speedStr || (rt.dispDownloaded > 0 ? t('models.measuring') : ''),
- etaStr,
- ].filter(Boolean);
-
- const extra = [];
- if (rt.cachedBytes > 0) extra.push(`${fmtBytes(rt.cachedBytes)} ${t('models.cached') || 'cached'}`);
- if (rt.filesTotal > 1) extra.push(t('models.files_progress', { done: rt.filesDone, total: rt.filesTotal }));
- if (rt.activeFilename) extra.push(rt.activeFilename.split('/').pop());
-
- return extra.length
- ? `${parts.join(' · ')} ⸱ ${extra.join(' · ')}`
- : parts.join(' · ');
- })()}
-
-
- )}
- {rt.phase === 'install_error' && rt.rs?.error && (
- {t('models.install_error', { error: rt.rs.error })}
- )}
- >
- );
- },
- },
- {
- id: 'role',
- accessorFn: m => (m.role || 'other').toLowerCase(),
- header: t('models.column_role'),
- size: 58,
- filterFn: (row, id, value) => !value || row.getValue(id) === value,
- cell: ({ row }) => {MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'} ,
- },
- {
- id: 'size',
- accessorFn: m => m.installed ? (m.size_on_disk_bytes || 0) : (m.size_gb || 0) * 1024 ** 3,
- header: t('models.column_size'),
- size: 68,
- meta: { align: 'right', className: 'models-row__size' },
- cell: ({ row }) => {
- const m = row.original;
- const rt = getRowRuntime(m);
- // During active download, show live downloaded / total
- if (rt.showBar && rt.hasFiles && rt.totals.total > 0) {
- return {fmtBytes(rt.totals.downloaded)}/ {fmtBytes(rt.totals.total)} ;
- }
- return m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`;
- },
- },
- {
- id: 'status',
- accessorFn: m => m.installed ? 2 : (m.supported === false ? 0 : 1),
- header: t('models.column_status'),
- size: 96,
- meta: { align: 'center', className: 'models-row__status' },
- cell: ({ row }) => {
- const m = row.original;
- const rt = getRowRuntime(m);
- return rt.isInstalling
- ? {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : t('models.downloading')}
- : rt.isDeleting
- ? {t('models.deleting')}
- : rt.rowBusy
- ? {t('models.working')}
- : m.installed
- ? {t('models.installed')}
- : rt.unsupported
- ? {(m.platforms || []).join(', ')}
- : {t('models.not_installed')} ;
- },
- },
- {
- id: 'actions',
- header: '',
- size: 90,
- enableSorting: false,
- meta: { align: 'right', className: 'models-row__actions' },
- cell: ({ row }) => {
- const m = row.original;
- const rt = getRowRuntime(m);
- return (
- <>
- openExternal(`https://huggingface.co/${m.repo_id}`)}
- title={t('models.view_on_hf')}
- aria-label={t('models.view_on_hf')}
- >
-
-
- {!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && (
- onInstall(m.repo_id)}
- leading={ }
- >
- {t('models.install_btn')}
-
- )}
- {m.installed && !rt.rowBusy && !rt.isDeleting && (
- <>
- onReinstall(m.repo_id)}
- title={t('models.reinstall_btn')}
- aria-label={t('models.reinstall_btn')}
- >
-
-
- onDelete(m.repo_id)}
- title={t('models.delete_btn')}
- aria-label={t('models.delete_btn')}
- >
-
-
- >
- )}
- >
- );
- },
- },
- ], [getRowRuntime, onDelete, onInstall, onReinstall, t]);
-
- const table = useReactTable({
- data: allModels,
- columns,
- getRowId: row => row.repo_id,
- state: {
- sorting,
- globalFilter: query,
- columnFilters,
- },
- onSortingChange: setSorting,
- onGlobalFilterChange: setQuery,
- onColumnFiltersChange: setColumnFilters,
- globalFilterFn: (row, _columnId, value) => {
- const q = String(value || '').trim().toLowerCase();
- if (!q) return true;
- const m = row.original;
- return [m.repo_id, m.label, m.note, m.role]
- .filter(Boolean)
- .some(v => String(v).toLowerCase().includes(q));
- },
- getCoreRowModel: getCoreRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- getSortedRowModel: getSortedRowModel(),
- });
-
- const tableRows = table.getRowModel().rows;
- const rowVirtualizer = useVirtualizer({
- count: tableRows.length,
- getScrollElement: () => tableBodyRef.current,
- estimateSize: () => 68,
- overscan: 8,
- });
-
- if (loading && !data) {
- return (
-
- {t('common.loading')}
-
- );
- }
- if (!data) return null;
-
- return (
-
-
-
- {fmtBytes(data.total_installed_bytes)}
- ·
- {data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')}
- {info && · }
- {info && {modelBadge} }
- {info?.fast_download?.xet_enabled && (
- <>
- ·
-
- ⚡ {t('models.fast_download_badge') || 'fast download'}
-
- >
- )}
-
-
- {/* Compact HF token inline */}
- {!hfTokenSet && !hfExpanded && (
-
setHfExpanded(true)}
- title={t('models.hf_set_title')}
- >
- {t('models.hf_token_btn')}
-
- )}
- {!hfTokenSet && hfExpanded && (
-
- )}
- {hfTokenSet && (
-
✓
- )}
-
}>
- {t('common.refresh')}
-
-
-
-
- {reco && reco.all_installed && (
-
-
- {t('models.reco_installed_for', { device: reco.device.label })}
- {reco.total_gb} GB
-
- )}
- {reco && !reco.all_installed && (
-
-
-
{t('models.reco_for', { device: reco.device.label })}
-
- {(() => {
- const requiredMissing = reco.models.filter(m => m.required && !m.installed);
- const requiredGb = requiredMissing.reduce((s, m) => s + m.size_gb, 0);
- if (requiredMissing.length === 0) return null;
- return (
- {
- setInstallingReco(true);
- try {
- await Promise.all(requiredMissing.map(m => installMutation.mutateAsync(m.repo_id)));
- toast.success(t('models.started_downloading_required', { count: requiredMissing.length }));
- } catch (e) { toast.error(t('models.install_failed', { message: e.message || e })); }
- finally { setInstallingReco(false); }
- }}
- disabled={installingReco}
- leading={installingReco ? : null}
- >
- {installingReco ? t('models.starting') : t('models.required_size', { size: requiredGb.toFixed(1) })}
-
- );
- })()}
-
- {t('models.all_size', { size: reco.download_gb_remaining })}
-
-
-
-
- {reco.models.map(m => (
-
- {m.installed ? '✓' : '○'} {m.label}
- {m.size_gb}
- {m.required && {t('models.req_tag')} }
-
- ))}
-
-
- )}
-
-
- {
- const installed = groups[r].filter(m => m.installed).length;
- return {
- value: r,
- label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
- };
- }),
- ]}
- />
- setQuery(e.target.value)}
- aria-label={t('models.search_label')}
- />
-
-
-
-
- {table.getHeaderGroups().map(headerGroup => (
-
- {headerGroup.headers.map(header => {
- const meta = header.column.columnDef.meta || {};
- const canSort = header.column.getCanSort();
- return (
-
- {flexRender(header.column.columnDef.header, header.getContext())}
- {header.column.getIsSorted() === 'asc' && ↑ }
- {header.column.getIsSorted() === 'desc' && ↓ }
-
- );
- })}
-
- ))}
-
-
-
- {rowVirtualizer.getVirtualItems().map(virtualRow => {
- const row = tableRows[virtualRow.index];
- const m = row.original;
- const rt = getRowRuntime(m);
- return (
-
- {row.getVisibleCells().map(cell => {
- const meta = cell.column.columnDef.meta || {};
- return (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- );
- })}
-
- );
- })}
- {tableRows.length === 0 && (
-
{t('models.no_matches')}
- )}
-
-
-
-
- );
-}
-
-
-export function EnginesTab() {
- const { t } = useTranslation();
- const reviewMode = useAppStore(s => s.reviewMode);
- const setReviewMode = useAppStore(s => s.setReviewMode);
-
- // Plan 02-04 / ENGINE-06 — engine selection is wired through the
- // matrix component's optional onSelect callback so the matrix doubles
- // as a picker. Keeps a single source of truth for the engine list +
- // its install / GPU / isolation state.
- const onSelect = useCallback(async (family, backendId) => {
- try {
- addBreadcrumb(`engine:${family}=${backendId}`);
- const r = await selectEngine(family, backendId);
- toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active }));
- } catch (e) {
- toast.error(e.message || t('engines.switch_failed'));
- }
- }, []);
-
- return (
-
-
-
-
- ·
-
- {reviewMode === 'on' ? t('engines.banners_on') : t('engines.banners_off')}
-
-
-
-
-
-
- );
-}
-
-
-const isTauri = () => _isTauri;
-
-// Tauri v2's webview disables native window.confirm/alert — they return
-// false silently, making Delete/Reinstall buttons appear dead. Route through
-// the dialog plugin when running in Tauri, fall back to browser confirm
-// elsewhere (vite dev, tests).
-async function askConfirm(message, title = 'Confirm') {
- if (isTauri()) {
- const { ask } = await import('@tauri-apps/plugin-dialog');
- return ask(message, { title, kind: 'warning' });
- }
- return Promise.resolve(window.confirm(message));
-}
-
export default function Settings() {
const { t } = useTranslation();
// One-shot deep-link: a caller (e.g. the footer version badge → Updates) can
@@ -1453,58 +354,15 @@ export default function Settings() {
{activeTab === 'credentials' && }
{activeTab === 'logs' && (
-
-
- }
- >
- {t('common.refresh')}
-
- }
- >
- {t('common.clear')}
-
- >
- }
- >
- ({ ...d, label: t(`common.${d.key}`) }))}
- value={logSource}
- onChange={setLogSource}
- />
-
-
-
{logMeta.path || '—'}
- {logSource === 'tauri' && !logMeta.exists && (
-
- {t('logs.no_tauri_log')}
-
- )}
-
-
- {logs.length === 0
- ?
- {logSource === 'frontend'
- ? t('logs.empty_frontend')
- : logSource === 'tauri'
- ? t('logs.empty_tauri')
- : t('logs.empty_backend')}
-
- : logs.join('')}
-
-
+
)}
{activeTab === 'updates' && (
@@ -1514,456 +372,28 @@ export default function Settings() {
)}
{activeTab === 'about' && (
-
-
-
-
-
-
-
-
- {t('about.yes')}
- : {t('about.no')} } />
-
-
- {status?.status || 'unknown'}} />
-
-
-
-
-
-
-
- {/* Auto-updater + channel toggle are desktop-only (Tauri). The Docker
- web build updates by pulling a new image tag, so hide these rows
- there to avoid a non-functional control (issue #249). */}
- {isTauri() && (
- <>
-
- }
- />
-
- >
- )}
-
- {isTauri() && (
- }
- onClick={checkForUpdates}
- loading={updateState === 'checking' || updateState === 'downloading'}
- >
- {updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
-
- )}
- }
- onClick={runSelfCheck}
- loading={selfCheckRunning}
- >
- {t('about.self_check')}
-
- }
- onClick={saveDiagnosticBundle}
- loading={bundleBuilding}
- >
- {t('about.save_bundle')}
-
- }
- onClick={copyDiagnostics}
- >
- {t('about.copy_diagnostics')}
-
- }
- onClick={() => openExternal('https://github.com/k2-fsa/OmniVoice')}
- >
- {t('about.github')}
-
- }
- onClick={() => openExternal('https://huggingface.co/k2-fsa/OmniVoice')}
- >
- {t('about.model_card')}
-
- }
- onClick={() => { useAppStore.getState().setMode?.('enterprise'); }}
- >
- {t('about.commercial_license')}
-
-
- {selfCheck && (
-
- {selfCheck.checks.map((c) => (
-
-
- {c.status === 'ok'
- ?
- : } {t(`about.self_check_${c.status}`)}
-
- {' '}{c.detail}
- {c.hint && — {c.hint} }
-
- }
- />
- ))}
-
- {selfCheck.summary.ok
- ? t('about.self_check_healthy')
- : t('about.self_check_attention', { count: selfCheck.summary.failures })}
-
-
- )}
-
|
+
)}
- {activeTab === 'privacy' && (
-
-
- }} />
-
-
-
- {t('privacy.local_sqlite')}} />
- {t('privacy.translator_online', { provider: info.translate_provider })}
- : {t('privacy.translator_offline')}
- }
- />
- {t('privacy.no_tracking')}}
- />
- |
|
- )}
+ {activeTab === 'privacy' && }
);
}
-// ── Credentials Tab ───────────────────────────────────────────────────────
-
-// Flat field list rendered by CredentialsTab. HF_TOKEN is handled by
-// (3-source cascade) and filtered out of this loop; the
-// rest are session/persisted env keys set via /system/set-env. labelKey/
-// helpKey are i18n keys; placeholderKey holds a literal example value
-// (URL / token shape) rendered as-is, matching the original design.
-const CREDENTIAL_FIELDS = [
- { key: 'HF_TOKEN', labelKey: 'credentials.hf_token', placeholderKey: 'hf_xxxxxxxxxxxx',
- helpKey: 'credentials.hf_help', link: 'https://huggingface.co/settings/tokens', isPassword: true },
- { key: 'TRANSLATE_API_KEY', labelKey: 'credentials.translate_key', placeholderKey: 'API key',
- helpKey: 'credentials.translate_help', isPassword: true },
- { key: 'TRANSLATE_BASE_URL', labelKey: 'credentials.llm_base_url', placeholderKey: 'https://api.openai.com/v1',
- helpKey: 'credentials.llm_base_url_help' },
- { key: 'TRANSLATE_MODEL', labelKey: 'credentials.llm_model', placeholderKey: 'gpt-4o',
- helpKey: 'credentials.llm_model_help' },
- { key: 'DEEPL_API_KEY', labelKey: 'credentials.deepl_key', placeholderKey: 'DeepL API key',
- helpKey: 'credentials.deepl_key', isPassword: true },
- { key: 'DEEPL_BASE_URL', labelKey: 'credentials.deepl_base_url', placeholderKey: 'https://api.deepl.com/v2',
- helpKey: 'credentials.deepl_base_url_help' },
- { key: 'MICROSOFT_API_KEY', labelKey: 'credentials.microsoft_key', placeholderKey: 'Microsoft API key',
- helpKey: 'credentials.microsoft_key', isPassword: true },
- { key: 'MICROSOFT_BASE_URL', labelKey: 'credentials.microsoft_base_url', placeholderKey: 'https://api.cognitive.microsofttranslator.com',
- helpKey: 'credentials.microsoft_base_url_help' },
-];
-
-// Convert a KeyboardEvent into a tauri-plugin-global-shortcut accelerator
-// string, e.g. "CmdOrCtrl+Shift+Space". Returns null when only modifiers
-// are held (the user hasn't picked a "real" key yet).
-function keyEventToAccelerator(e) {
- const isMacLike = typeof navigator !== 'undefined'
- && /Mac|iPad|iPhone|iPod/.test(navigator.platform || '');
- const mods = [];
- if (e.metaKey) mods.push(isMacLike ? 'Cmd' : 'Super');
- if (e.ctrlKey) mods.push('Ctrl');
- if (e.altKey) mods.push('Alt');
- if (e.shiftKey) mods.push('Shift');
-
- // e.code is the physical key — already in the shape tauri expects for
- // Letter/Digit/Function keys ("KeyA", "Digit1", "F5"). Strip the prefix
- // so we get "A" / "1" / "F5" which matches the accelerator grammar.
- let key = e.code;
- if (!key) return null;
- if (key.startsWith('Key')) key = key.slice(3);
- else if (key.startsWith('Digit')) key = key.slice(5);
- // Skip pure modifier keys — we want the user to pick a real trigger.
- if (/^(Meta|Control|Alt|Shift|OS)(Left|Right)?$/.test(key)) return null;
-
- if (mods.length === 0) return null;
- return [...mods, key].join('+');
-}
-
-function HotkeyTab() {
- const { t } = useTranslation();
- const [current, setCurrent] = useState('');
- const [recording, setRecording] = useState(false);
- const [pending, setPending] = useState('');
- const [saving, setSaving] = useState(false);
- const tauri = isTauri();
-
- // Load the saved shortcut on mount.
- useEffect(() => {
- if (!tauri) return;
- (async () => {
- try {
- const { invoke } = await import('@tauri-apps/api/core');
- const v = await invoke('get_dictation_shortcut');
- setCurrent(v || '');
- } catch (e) {
- toast.error(t('settings.shortcut_load_failed', { message: e?.message || e }));
- }
- })();
- }, [tauri]);
-
- // While recording, swallow keystrokes globally and convert the next real
- // press into an accelerator string. Escape cancels.
- useEffect(() => {
- if (!recording) return;
- const onKeyDown = (e) => {
- e.preventDefault();
- e.stopPropagation();
- if (e.key === 'Escape') {
- setRecording(false);
- setPending('');
- return;
- }
- const accel = keyEventToAccelerator(e);
- if (accel) {
- setPending(accel);
- setRecording(false);
- }
- };
- window.addEventListener('keydown', onKeyDown, true);
- return () => window.removeEventListener('keydown', onKeyDown, true);
- }, [recording]);
-
- const save = async () => {
- if (!pending || pending === current) return;
- setSaving(true);
- try {
- const { invoke } = await import('@tauri-apps/api/core');
- const saved = await invoke('set_dictation_shortcut', { accelerator: pending });
- setCurrent(saved);
- setPending('');
- toast.success(t('settings.shortcut_set', { shortcut: saved }));
- } catch (e) {
- // Common cause: the OS or another app already owns the combo. Surface
- // the raw error so the user can pick something else.
- toast.error(t('settings.shortcut_register_failed', { message: e?.message || e }));
- } finally {
- setSaving(false);
- }
- };
-
- const resetDefault = async () => {
- setSaving(true);
- try {
- const { invoke } = await import('@tauri-apps/api/core');
- const saved = await invoke('set_dictation_shortcut', {
- accelerator: 'CmdOrCtrl+Shift+Space',
- });
- setCurrent(saved);
- setPending('');
- toast.success(t('settings.shortcut_reset'));
- } catch (e) {
- toast.error(t('settings.shortcut_reset_failed', { message: e?.message || e }));
- } finally {
- setSaving(false);
- }
- };
-
- return (
-
- {!tauri && (
-
- }} />
-
- )}
-
-
- , 2: }} />}
- control={recording ? t('capture.listening') : (pending || '—')}
- mono
- />
-
-
- { setPending(''); setRecording(true); }}
- disabled={!tauri || saving}
- leading={ }
- >
- {recording ? t('capture.recording') : t('capture.record_shortcut')}
-
-
- {t('capture.save')}
-
-
- {t('capture.reset_default')}
-
-
-
- );
-}
-
-function CredentialsTab({ info }) {
- const { t } = useTranslation();
- const [values, setValues] = useState({});
- const [saving, setSaving] = useState(null);
- const [saved, setSaved] = useState({});
-
- const save = async (key) => {
- const value = (values[key] || '').trim();
- if (!value) return;
- setSaving(key);
- try {
- const { API } = await import('../api/client');
- const res = await fetch(`${API}/system/set-env`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key, value }),
- });
- if (res.ok) {
- toast.success(t('credentials.saved_session', { key }));
- setSaved(prev => ({ ...prev, [key]: true }));
- setValues(prev => ({ ...prev, [key]: '' }));
- } else {
- const d = await res.json().catch(() => ({}));
- toast.error(d.detail || t('credentials.save_failed'));
- }
- } catch (e) {
- toast.error(t('credentials.save_error', { message: e.message }));
- } finally {
- setSaving(null);
- }
- };
-
- return (
-
- {/* Wave 2 AUTH-03 panel — 3-source cascade with Active badge,
- encrypted-at-rest App-source storage, and live whoami status. */}
-
-
- {/* Wave 2.4 — OpenAI-compatible LLM endpoint (Ollama/LM Studio/vLLM). */}
-
-
-
-
- }} />
-
- {CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => (
-
- {t(field.labelKey)}
- {field.key === 'HF_TOKEN' && (
-
- {info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')}
-
- )}
- >
- }
- note={
- <>
- {t(field.helpKey)}
- {field.link && (
- <> { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')} >
- )}
- >
- }
- control={
- <>
- setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
- onKeyDown={e => e.key === 'Enter' && save(field.key)}
- />
- save(field.key)}
- disabled={!(values[field.key] || '').trim()}
- >
- {t('credentials.save')}
-
- >
- }
- />
- ))}
-
-
- );
-}