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:
co-authored by
Claude Opus 4.8
mergetest
parent
e2f02327c3
commit
f33bdc731d
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
+44
-1614
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user