refactor(settings): modularize Settings page (1969→399 lines, all files under 500) (#758)

* refactor(settings): extract Settings.jsx tabs into components/settings (1969→602 lines)

Settings.jsx had grown to 1969 lines — every edit reloaded the whole file
into context and risked unrelated breakage. This finishes the migration the
existing components/settings/*Panel.jsx pattern started: the page is now a
thin orchestrator and each heavy tab lives in its own file.

Extracted (logic byte-for-byte identical; only import paths adjusted + the
shared isTauri/askConfirm moved to components/settings/native.js):
- GeneralTab, ModelStoreTab, EnginesTab, HotkeyTab, CredentialsTab
- native.js — shared isTauri() wrapper + askConfirm() Tauri-dialog helper

Also establishes the standard so files can't silently regrow:
- CONTRIBUTING.md: frontend file-structure & size limits (soft 300 / hard 500)
- eslint.config.js: warn-only max-lines:500 guardrail (CI stays green)
- docs/maintenance-pages-modularization.md: the phased refactor plan

Verified: vite build passes (all imports resolve); 18/18 settings tests pass;
no new lint errors introduced (the pruned imports were the only regressions).

Follow-ups (tracked in the plan doc): ModelStoreTab.jsx is 836 lines and
Settings.jsx 602 — both still over the 500 cap (warn-only); split next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(settings): split ModelStoreTab + Settings.jsx under the 500-line cap

Follow-up to the tab extraction: bring the two remaining over-cap files into
compliance with the new standard. Pure-mechanical, no behavior change.

Settings.jsx 602 → 399:
- Extract AboutTab, PrivacyTab, LogsTab into components/settings/
- Move the shared Row helper to components/settings/Row.jsx
- LogsTab keeps its state in Settings() (lower-risk); About/Privacy take props

ModelStoreTab.jsx 836 → 439, split into components/settings/models/:
- format.js (fmtBytes/orgColor), runtime.js (computeRowRuntime)
- columns.jsx exposes makeModelColumns(...) — a factory so the TanStack cell
  closures keep working; called with the same useMemo dep array as before
- ModelsTable.jsx (virtualized table view), RecoBanner.jsx

Every settings file is now under 500 lines. Verified: vite build passes;
18/18 settings tests pass; no new lint errors (the 4 remaining in Settings.jsx
are pre-existing — refreshInfo no-op, a catch(e), two set-state-in-effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-30 03:34:51 +05:30
committed by GitHub
co-authored by Claude Opus 4.8 mergetest
parent e2f02327c3
commit f33bdc731d
19 changed files with 1957 additions and 1614 deletions
+21
View File
@@ -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.
+93
View File
@@ -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_*`) | 2291021 | `ModelStoreTab.jsx` (likely split further: table vs. matrix vs. row) |
| `GeneralTab` | 80201 | `GeneralTab.jsx` |
| `EnginesTab` | 10221072 | `EnginesTab.jsx` |
| `HotkeyTab` (+ `CREDENTIAL_FIELDS`, `keyEventToAccelerator`) | 16931870 | `HotkeyTab.jsx` |
| `CredentialsTab` | 18711969 | `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).
+3
View File
@@ -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 }],
},
},
])
@@ -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 (
<SettingsSection icon={Info} title={t('settings.about')}>
<Row label={t('about.app')} value="OmniVoice Studio" />
<Row label={t('about.version')} value={resolveAboutVersion(appVersion, info)} mono />
<Row label={t('about.tauri_runtime')} value={tauriVersion || (isTauri() ? '—' : t('about.web_preview'))} mono />
<Row label={t('about.platform')} value={info?.platform || '—'} />
<Row label={t('about.architecture')} value={info?.arch || '—'} mono />
<Row label={t('about.python')} value={info?.python || '—'} mono />
<Row label={t('about.compute_device')} value={info?.device || '—'} mono />
<Row label={t('about.gpu_active')} value={hw?.gpu_active
? <Badge tone="success"><CheckCircle size={11} /> {t('about.yes')}</Badge>
: <Badge tone="neutral">{t('about.no')}</Badge>} />
<Row label={t('about.ram')} value={hw ? `${hw.ram?.toFixed(2)} / ${hw.total_ram?.toFixed(2)} GB` : '—'} mono />
<Row label={t('about.vram')} value={hw ? `${hw.vram?.toFixed(2)} GB` : '—'} mono />
<Row label={t('about.backend')} value={<Badge tone={status?.status === 'ready' ? 'success' : status?.status === 'loading' ? 'warn' : 'neutral'}>{status?.status || 'unknown'}</Badge>} />
<Row label={t('about.active_model')} value={status?.repo_id || info?.model_checkpoint || '—'} mono />
<Row label={t('about.asr_model')} value={info?.asr_model || '—'} mono />
<Row label={t('about.translator')} value={info?.translate_provider || '—'} />
<Row label={t('about.hf_token')} value={info?.has_hf_token ? t('about.yes') : t('about.no')} />
<Row label={t('about.data_dir')} value={info?.data_dir || '—'} mono />
<Row label={t('about.outputs')} value={info?.outputs_dir || '—'} mono />
<Row label={t('about.crash_log')} value={info?.crash_log_path || '—'} mono />
{/* 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() && (
<>
<SettingRow
title={t('about.update_channel')}
hint={updateChannel === 'preview' ? t('about.channel_preview_hint') : undefined}
control={
<Segmented
size="xs"
value={updateChannel}
onChange={changeChannel}
items={[
{ value: 'stable', label: t('about.channel_stable') },
{ value: 'preview', label: t('about.channel_preview') },
]}
/>
}
/>
<Row
label={t('about.update_endpoint')}
value={updateChannel === 'preview'
? 'releases/download/preview/latest.json'
: 'releases/latest/download/latest.json'}
mono
/>
</>
)}
<div className="settings-link-row">
{isTauri() && (
<Button
variant="primary"
size="md"
leading={<Download size={12} />}
onClick={checkForUpdates}
loading={updateState === 'checking' || updateState === 'downloading'}
>
{updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
</Button>
)}
<Button
variant="subtle"
size="md"
leading={!selfCheckRunning && <Activity size={12} />}
onClick={runSelfCheck}
loading={selfCheckRunning}
>
{t('about.self_check')}
</Button>
<Button
variant="subtle"
size="md"
leading={!bundleBuilding && <Download size={12} />}
onClick={saveDiagnosticBundle}
loading={bundleBuilding}
>
{t('about.save_bundle')}
</Button>
<Button
variant="subtle"
size="md"
leading={<Copy size={12} />}
onClick={copyDiagnostics}
>
{t('about.copy_diagnostics')}
</Button>
<Button
variant="subtle"
size="md"
leading={<ExternalLink size={12} />}
onClick={() => openExternal('https://github.com/k2-fsa/OmniVoice')}
>
{t('about.github')}
</Button>
<Button
variant="subtle"
size="md"
leading={<ExternalLink size={12} />}
onClick={() => openExternal('https://huggingface.co/k2-fsa/OmniVoice')}
>
{t('about.model_card')}
</Button>
<Button
variant="subtle"
size="md"
leading={<Building2 size={12} />}
onClick={() => { useAppStore.getState().setMode?.('enterprise'); }}
>
{t('about.commercial_license')}
</Button>
</div>
{selfCheck && (
<div className="settings-selfcheck">
{selfCheck.checks.map((c) => (
<Row
key={c.id}
label={c.label}
value={
<span>
<Badge tone={c.status === 'ok' ? 'success' : c.status === 'warn' ? 'warn' : 'danger'}>
{c.status === 'ok'
? <CheckCircle size={11} />
: <AlertCircle size={11} />} {t(`about.self_check_${c.status}`)}
</Badge>
{' '}{c.detail}
{c.hint && <span className="settings-muted"> {c.hint}</span>}
</span>
}
/>
))}
<p className="settings-muted">
{selfCheck.summary.ok
? t('about.self_check_healthy')
: t('about.self_check_attention', { count: selfCheck.summary.failures })}
</p>
</div>
)}
</SettingsSection>
);
}
@@ -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 (
<SettingsSection
icon={KeyRound}
title={t('settings.credentials')}
description={t('settings.credentials_desc')}
>
{/* Wave 2 AUTH-03 panel — 3-source cascade with Active badge,
encrypted-at-rest App-source storage, and live whoami status. */}
<ApiKeysPanel />
{/* Wave 2.4 — OpenAI-compatible LLM endpoint (Ollama/LM Studio/vLLM). */}
<LLMEndpointPanel />
<Collapsible title={t('settings.credentials_more')} icon={KeyRound}>
<p className="settings-prose">
<Trans i18nKey="credentials.desc" components={{ 1: <strong /> }} />
</p>
{CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => (
<SettingRow
key={field.key}
align="start"
className="st-row--stack"
title={
<>
{t(field.labelKey)}
{field.key === 'HF_TOKEN' && (
<Badge tone={info?.has_hf_token || saved.HF_TOKEN ? 'success' : 'warn'} size="xs">
{info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')}
</Badge>
)}
</>
}
note={
<>
{t(field.helpKey)}
{field.link && (
<> <a href="#" onClick={e => { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')}</a></>
)}
</>
}
control={
<>
<SettingsInput
type={field.isPassword ? 'password' : 'text'}
mono
placeholder={field.placeholderKey}
value={values[field.key] || ''}
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && save(field.key)}
/>
<Button
size="sm"
variant="subtle"
loading={saving === field.key}
onClick={() => save(field.key)}
disabled={!(values[field.key] || '').trim()}
>
{t('credentials.save')}
</Button>
</>
}
/>
))}
</Collapsible>
</SettingsSection>
);
}
@@ -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 (
<section className="st-section">
<div className="models-toolbar">
<div className="models-toolbar__stats">
<Segmented
size="xs"
value={reviewMode}
onChange={setReviewMode}
items={[
{ value: 'on', label: t('engines.review_on') },
{ value: 'off', label: t('engines.review_off') },
]}
/>
<span className="models-toolbar__sep">·</span>
<span>
{reviewMode === 'on' ? t('engines.banners_on') : t('engines.banners_off')}
</span>
</div>
</div>
<EngineCompatibilityMatrix family="tts" onSelect={onSelect} />
</section>
);
}
@@ -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 (
<SettingsSection icon={Settings2} title={t('settings.general')}>
<SettingRow
icon={Globe}
title={t('settings.language')}
control={
<Select size="sm" value={locale} onChange={handleLocaleChange}>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</Select>
}
/>
<SettingRow
icon={Palette}
title={t('settings.theme')}
control={
<Select size="sm" value={theme} onChange={e => setTheme(e.target.value)}>
<option value="gruvbox">Gruvbox</option>
<option value="midnight">Midnight</option>
<option value="nord">Nord</option>
<option value="solarized">Solarized</option>
<option value="rose-pine">Rose Pine</option>
<option value="catppuccin">Catppuccin</option>
</Select>
}
/>
<Collapsible title={t('settings.advanced')} icon={Settings2}>
<SettingRow
align="start"
className="st-row--stack"
title={
<>
{t('settings.proxy')}
{proxySaved && <Badge tone="success" size="xs">{t('credentials.saved')}</Badge>}
</>
}
note={t('settings.proxy_desc')}
control={
<>
<SettingsInput
placeholder="http://127.0.0.1:7890 or socks5://127.0.0.1:7890"
value={proxyUrl}
onChange={e => setProxyUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveProxy()}
/>
<Button size="sm" variant="subtle" onClick={saveProxy} loading={proxySaving} disabled={!proxyUrl.trim()}>
{t('credentials.save')}
</Button>
{proxySaved && (
<Button size="sm" variant="ghost" onClick={clearProxy} loading={proxySaving}>
{t('settings.proxy_clear')}
</Button>
)}
</>
}
/>
<SettingRow
align="start"
className="st-row--stack"
title={
<>
{t('settings.ffmpeg')}
<Badge tone={ffmpegOk ? 'success' : 'warn'} size="xs">
{ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
</Badge>
</>
}
note={ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')}
control={
<>
<SettingsInput
placeholder="D:\ffmpeg\bin\ffmpeg.exe"
value={ffmpegPath}
onChange={e => setFfmpegPath(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveFfmpeg()}
/>
<Button size="sm" variant="subtle" onClick={saveFfmpeg} loading={ffmpegSaving} disabled={!ffmpegPath.trim()}>
{t('credentials.save')}
</Button>
</>
}
/>
</Collapsible>
</SettingsSection>
);
}
@@ -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 (
<SettingsSection
icon={Keyboard}
title={t('settings.shortcut')}
>
{!tauri && (
<p className="settings-prose">
<Trans i18nKey="capture.desc" components={{ 1: <kbd /> }} />
</p>
)}
<SettingRow
title={t('capture.active_shortcut')}
control={current || '—'}
mono
/>
<SettingRow
title={recording ? t('capture.press_key') : t('capture.new_shortcut')}
hint={<Trans i18nKey="capture.desc_detail" components={{ 1: <code />, 2: <code /> }} />}
control={recording ? t('capture.listening') : (pending || '—')}
mono
/>
<div className="settings-actions-row">
<Button
size="sm"
variant="subtle"
onClick={() => { setPending(''); setRecording(true); }}
disabled={!tauri || saving}
leading={<Keyboard size={12} />}
>
{recording ? t('capture.recording') : t('capture.record_shortcut')}
</Button>
<Button
size="sm"
onClick={save}
disabled={!tauri || !pending || pending === current}
loading={saving}
>
{t('capture.save')}
</Button>
<Button
size="sm"
variant="subtle"
onClick={resetDefault}
disabled={!tauri || saving}
>
{t('capture.reset_default')}
</Button>
</div>
</SettingsSection>
);
}
@@ -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 (
<SettingsSection
icon={FileText}
title={t('settings.logs')}
actions={
<>
<ReportBugButton />
<Button
variant="subtle"
size="sm"
onClick={refreshLogs}
loading={loadingLogs}
leading={!loadingLogs && <RefreshCw size={11} />}
>
{t('common.refresh')}
</Button>
<Button
variant="danger"
size="sm"
onClick={onClearLogs}
leading={<Trash2 size={11} />}
>
{t('common.clear')}
</Button>
</>
}
>
<Segmented
items={LOG_SOURCE_DEFS.map(d => ({ ...d, label: t(`common.${d.key}`) }))}
value={logSource}
onChange={setLogSource}
/>
<div className="settings-log-meta">
<span>{logMeta.path || '—'}</span>
{logSource === 'tauri' && !logMeta.exists && (
<Badge tone="warn">
<AlertCircle size={11} /> {t('logs.no_tauri_log')}
</Badge>
)}
</div>
<div className="settings-log">
{logs.length === 0
? <span className="settings-log__empty">
{logSource === 'frontend'
? t('logs.empty_frontend')
: logSource === 'tauri'
? t('logs.empty_tauri')
: t('logs.empty_backend')}
</span>
: logs.join('')}
</div>
</SettingsSection>
);
}
@@ -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 (
<SettingsSection icon={Cpu} title={t('settings.models')}>
<div className="settings-muted">{t('common.loading')}</div>
</SettingsSection>
);
}
if (!data) return null;
return (
<section className="st-section">
<div className="models-toolbar">
<div className="models-toolbar__stats">
<span><strong>{fmtBytes(data.total_installed_bytes)}</strong></span>
<span className="models-toolbar__sep">·</span>
<span className="models-toolbar__cache" title={data.hf_cache_dir}><code>{data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')}</code></span>
{info && <span className="models-toolbar__sep">·</span>}
{info && <span>{modelBadge}</span>}
{info?.fast_download?.xet_enabled && (
<>
<span className="models-toolbar__sep">·</span>
<span
className="models-toolbar__fast"
title={t('models.fast_download_title', {
version: info.fast_download.xet_version || 'Xet',
}) || `Fast downloads via Xet ${info.fast_download.xet_version || ''} — parallel chunked transfer`}
>
{t('models.fast_download_badge') || 'fast download'}
</span>
</>
)}
</div>
<div className="models-toolbar__actions">
{/* Compact HF token inline */}
{!hfTokenSet && !hfExpanded && (
<button
className="models-toolbar__hf-btn"
onClick={() => setHfExpanded(true)}
title={t('models.hf_set_title')}
>
<KeyRound size={11} /> {t('models.hf_token_btn')}
</button>
)}
{!hfTokenSet && hfExpanded && (
<div className="models-toolbar__hf-row">
<input
type="password"
className="models-toolbar__hf-input"
placeholder="hf_xxxxxxxxxxxx"
value={hfToken}
onChange={e => setHfToken(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveHfToken(); if (e.key === 'Escape') setHfExpanded(false); }}
autoFocus
/>
<Button size="sm" variant="subtle" onClick={saveHfToken} disabled={hfSaving || !hfToken.trim()} loading={hfSaving}>
{t('common.save')}
</Button>
<a
href="#"
className="models-toolbar__hf-link"
onClick={e => { e.preventDefault(); openExternal('https://huggingface.co/settings/tokens'); }}
title="Open huggingface.co/settings/tokens"
>
{t('models.get_token')}
</a>
</div>
)}
{hfTokenSet && (
<span className="models-toolbar__hf-ok"><KeyRound size={10} /> </span>
)}
<Button variant="subtle" size="sm" onClick={reload} loading={loading} leading={<RefreshCw size={11} />}>
{t('common.refresh')}
</Button>
</div>
</div>
<RecoBanner
reco={reco}
t={t}
installMutation={installMutation}
installingReco={installingReco}
setInstallingReco={setInstallingReco}
onInstallRecommended={onInstallRecommended}
/>
<div className="models-controls">
<Segmented
size="sm"
value={currentRole}
onChange={setActiveRole}
className="models-roletabs"
items={[
{
value: 'all',
label: `All ${allInstalled}/${allModels.length}`,
},
...roles.map(r => {
const installed = groups[r].filter(m => m.installed).length;
return {
value: r,
label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
};
}),
]}
/>
<SettingsInput
type="search"
className="models-search"
placeholder={t('models.search_placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
aria-label={t('models.search_label')}
/>
</div>
<ModelsTable
table={table}
tableRows={tableRows}
rowVirtualizer={rowVirtualizer}
tableBodyRef={tableBodyRef}
getRowRuntime={getRowRuntime}
t={t}
/>
</section>
);
}
@@ -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 (
<SettingsSection icon={ShieldCheck} title={t('settings.privacy')}>
<p className="settings-prose">
<Trans i18nKey="privacy.desc" components={{ 1: <strong /> }} />
</p>
<Row label={t('privacy.uploads_at')} value={info?.data_dir ? `${info.data_dir}/` : '—'} mono />
<Row label={t('privacy.outputs_at')} value={info?.outputs_dir || '—'} mono />
<Row label={t('privacy.gen_history')} value={<Badge tone="neutral">{t('privacy.local_sqlite')}</Badge>} />
<Row
label={t('privacy.network_calls')}
value={
info?.translate_provider && ['google', 'deepl', 'mymemory', 'microsoft', 'openai'].includes(info.translate_provider)
? <Badge tone="warn"><AlertCircle size={11} /> {t('privacy.translator_online', { provider: info.translate_provider })}</Badge>
: <Badge tone="success"><CheckCircle size={11} /> {t('privacy.translator_offline')}</Badge>
}
/>
<Row
label={t('privacy.model_telemetry')}
value={<Badge tone="success"><CheckCircle size={11} /> {t('privacy.no_tracking')}</Badge>}
/>
</SettingsSection>
);
}
+8
View File
@@ -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 <SettingRow title={label} control={value} mono={mono} />;
}
@@ -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 className="models-table">
<div className="ui-table-header models-table__header">
{table.getHeaderGroups().map(headerGroup => (
<React.Fragment key={headerGroup.id}>
{headerGroup.headers.map(header => {
const meta = header.column.columnDef.meta || {};
const canSort = header.column.getCanSort();
return (
<button
key={header.id}
type="button"
className={[
'ui-table-header__cell',
`ui-table-header__cell--align-${meta.align || 'left'}`,
canSort ? 'models-table__sort' : 'models-table__sort--off',
].join(' ')}
style={{ width: header.column.columnDef.size, flex: header.column.id === 'name' ? '1 1 auto' : '0 0 auto' }}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
disabled={!canSort}
title={canSort ? t('models.sort_by', { column: String(header.column.columnDef.header || '') }) : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && <span className="models-table__sortmark"></span>}
{header.column.getIsSorted() === 'desc' && <span className="models-table__sortmark"></span>}
</button>
);
})}
</React.Fragment>
))}
</div>
<div ref={tableBodyRef} className="models-table__body">
<div className="models-table__virtual" style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = tableRows[virtualRow.index];
const m = row.original;
const rt = getRowRuntime(m);
return (
<div
key={row.id}
className={`models-row ${m.installed ? 'is-ok' : 'is-off'}${rt.unsupported ? ' is-unsupported' : ''}`}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{row.getVisibleCells().map(cell => {
const meta = cell.column.columnDef.meta || {};
return (
<div
key={cell.id}
className={`models-row__cell ${meta.className || ''}`}
style={{
width: cell.column.columnDef.size,
flex: cell.column.id === 'name' ? '1 1 auto' : '0 0 auto',
textAlign: meta.align || undefined,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
);
})}
{tableRows.length === 0 && (
<div className="models-table__empty">{t('models.no_matches')}</div>
)}
</div>
</div>
</Table>
);
}
@@ -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 (
<div className="reco-banner reco-banner--ok">
<CheckCircle size={12} color="#8ec07c" />
<span className="flex-1">{t('models.reco_installed_for', { device: reco.device.label })}</span>
<span className="reco-banner__gb">{reco.total_gb} GB</span>
</div>
);
}
return (
<div className="reco-banner reco-banner--pending">
<div className="reco-banner__top">
<span className="reco-banner__title">{t('models.reco_for', { device: reco.device.label })}</span>
<div className="reco-banner__btns">
{(() => {
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 (
<Button
variant="primary"
size="sm"
onClick={async () => {
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 ? <RefreshCw size={12} className="spinner" /> : null}
>
{installingReco ? t('models.starting') : t('models.required_size', { size: requiredGb.toFixed(1) })}
</Button>
);
})()}
<Button variant="subtle" size="sm" onClick={onInstallRecommended} disabled={installingReco}>
{t('models.all_size', { size: reco.download_gb_remaining })}
</Button>
</div>
</div>
<div className="reco-banner__grid">
{reco.models.map(m => (
<span key={m.repo_id} className={`reco-banner__model ${m.installed ? 'reco-banner__model--ok' : ''}`}>
{m.installed ? '✓' : '○'} {m.label}
<span className="reco-banner__model-size">{m.size_gb}</span>
{m.required && <span className="reco-banner__req">{t('models.req_tag')}</span>}
</span>
))}
</div>
</div>
);
}
@@ -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 (
<>
<span className="models-row__title">
<span
className="models-row__avatar"
style={{ background: orgColor(m.repo_id) }}
title={m.repo_id.split('/')[0]}
>
{m.repo_id.split('/')[0].slice(0, 2).toUpperCase()}
</span>
{m.label}
{m.required && <span className="models-row__tag">{t('models.required_tag')}</span>}
</span>
<span className="models-row__repo">
<code>{m.repo_id}</code>
{m.note && <span className="models-row__note"> · {m.note}</span>}
</span>
{rt.showBar && (
<div className="models-row__progressline">
<Progress
value={rt.aggPct}
tone={rt.isDeleting ? 'warn' : 'brand'}
size="xs"
/>
<span className="models-row__progresstext">
{(() => {
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(' · ');
})()}
</span>
</div>
)}
{rt.phase === 'install_error' && rt.rs?.error && (
<span className="models-row__error">{t('models.install_error', { error: rt.rs.error })}</span>
)}
</>
);
},
},
{
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 }) => <span className="models-row__role">{MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'}</span>,
},
{
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 <span className="models-row__size-live">{fmtBytes(rt.totals.downloaded)}<span className="models-row__size-sep">/</span>{fmtBytes(rt.totals.total)}</span>;
}
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
? <Badge tone="warn" size="xs"><Download size={10} /> {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : t('models.downloading')}</Badge>
: rt.isDeleting
? <Badge tone="warn" size="xs"><Trash2 size={10} /> {t('models.deleting')}</Badge>
: rt.rowBusy
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> {t('models.working')}</Badge>
: m.installed
? <Badge tone="success" size="xs">{t('models.installed')}</Badge>
: rt.unsupported
? <Badge tone="neutral" size="xs">{(m.platforms || []).join(', ')}</Badge>
: <Badge tone="neutral" size="xs">{t('models.not_installed')}</Badge>;
},
},
{
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 (
<>
<Button
variant="icon" iconSize="sm"
onClick={() => openExternal(`https://huggingface.co/${m.repo_id}`)}
title={t('models.view_on_hf')}
aria-label={t('models.view_on_hf')}
>
<ExternalLink size={11} />
</Button>
{!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && (
<Button
variant="subtle" size="sm"
onClick={() => onInstall(m.repo_id)}
leading={<Download size={11} />}
>
{t('models.install_btn')}
</Button>
)}
{m.installed && !rt.rowBusy && !rt.isDeleting && (
<>
<Button
variant="icon" iconSize="sm"
onClick={() => onReinstall(m.repo_id)}
title={t('models.reinstall_btn')}
aria-label={t('models.reinstall_btn')}
>
<RefreshCw size={11} />
</Button>
<Button
variant="icon" iconSize="sm"
onClick={() => onDelete(m.repo_id)}
title={t('models.delete_btn')}
aria-label={t('models.delete_btn')}
>
<Trash2 size={11} />
</Button>
</>
)}
</>
);
},
},
];
}
@@ -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%)`;
}
@@ -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,
};
}
@@ -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));
}
File diff suppressed because it is too large Load Diff