From f33bdc731d6759b48c1930b75b0b0c15df01b1ea Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Tue, 30 Jun 2026 03:34:51 +0530 Subject: [PATCH] =?UTF-8?q?refactor(settings):=20modularize=20Settings=20p?= =?UTF-8?q?age=20(1969=E2=86=92399=20lines,=20all=20files=20under=20500)?= =?UTF-8?q?=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: mergetest Co-authored-by: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 21 + docs/maintenance-pages-modularization.md | 93 + frontend/eslint.config.js | 3 + frontend/src/components/settings/AboutTab.jsx | 175 ++ .../components/settings/CredentialsTab.jsx | 128 ++ .../src/components/settings/EnginesTab.jsx | 52 + .../src/components/settings/GeneralTab.jsx | 209 +++ .../src/components/settings/HotkeyTab.jsx | 166 ++ frontend/src/components/settings/LogsTab.jsx | 79 + .../src/components/settings/ModelStoreTab.jsx | 439 +++++ .../src/components/settings/PrivacyTab.jsx | 33 + frontend/src/components/settings/Row.jsx | 8 + .../settings/models/ModelsTable.jsx | 82 + .../components/settings/models/RecoBanner.jsx | 73 + .../components/settings/models/columns.jsx | 244 +++ .../src/components/settings/models/format.js | 17 + .../src/components/settings/models/runtime.js | 75 + frontend/src/components/settings/native.js | 16 + frontend/src/pages/Settings.jsx | 1658 +---------------- 19 files changed, 1957 insertions(+), 1614 deletions(-) create mode 100644 docs/maintenance-pages-modularization.md create mode 100644 frontend/src/components/settings/AboutTab.jsx create mode 100644 frontend/src/components/settings/CredentialsTab.jsx create mode 100644 frontend/src/components/settings/EnginesTab.jsx create mode 100644 frontend/src/components/settings/GeneralTab.jsx create mode 100644 frontend/src/components/settings/HotkeyTab.jsx create mode 100644 frontend/src/components/settings/LogsTab.jsx create mode 100644 frontend/src/components/settings/ModelStoreTab.jsx create mode 100644 frontend/src/components/settings/PrivacyTab.jsx create mode 100644 frontend/src/components/settings/Row.jsx create mode 100644 frontend/src/components/settings/models/ModelsTable.jsx create mode 100644 frontend/src/components/settings/models/RecoBanner.jsx create mode 100644 frontend/src/components/settings/models/columns.jsx create mode 100644 frontend/src/components/settings/models/format.js create mode 100644 frontend/src/components/settings/models/runtime.js create mode 100644 frontend/src/components/settings/native.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 469d3ea1..7cbdbc57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -169,6 +169,27 @@ class MyEngineBackend(TTSBackend): --- +## Frontend file structure & size limits + +Frontend code stays modular so an edit loads one small file, not a 1900-line +one. The rules: + +- **Size caps:** **soft 300 lines**, **hard 500 lines** per `.jsx`/`.css` file. + Anything over 500 lines must be split. +- **Pages are thin orchestrators.** A file in `frontend/src/pages/` is just + layout + routing + state wiring that composes feature components — no inline + sub-component over ~50 lines. +- **One component per file.** Co-locate `Foo.jsx` + `Foo.css` + `Foo.test.jsx` + together in a per-page feature folder under `frontend/src/components/` (e.g. + `components/settings/`, `components/dub/`). +- **Shared bits go in a `primitives/` folder** inside the feature folder + (`components/settings/primitives/` is the existing example). +- **Enforced by ESLint `max-lines`** (`max: 500`) — **warn-only for now** so it + never breaks CI, with the goal of upgrading to `error` once the backlog of + oversized files clears. + +--- + ## Commit Messages Write clear, concise messages. The PR title becomes the squash-merge commit. diff --git a/docs/maintenance-pages-modularization.md b/docs/maintenance-pages-modularization.md new file mode 100644 index 00000000..5d44b277 --- /dev/null +++ b/docs/maintenance-pages-modularization.md @@ -0,0 +1,93 @@ +# Maintenance Refactor — `frontend/src/pages` Modularization + +**Status:** Plan (not yet executed) · **Drafted:** 2026-06-30 · **Type:** Pure mechanical refactor, no behavior change + +## Why + +`frontend/src/pages/` has grown a few files large enough that any edit reloads the +whole thing into context and risks unrelated breakage. Editing one Settings panel +should touch a ~150-line file, not a 1969-line one. This both improves +maintainability and cuts token cost per edit. + +The fix is **not** a new architecture — `components/settings/` already proves the +target pattern (13 extracted `*Panel.jsx`, each with co-located `.css`/`.test.jsx`, +plus a shared `primitives/` folder). This refactor **finishes a migration that +stalled**, then locks it in so files can't silently regrow. + +## Current state (measured 2026-06-30) + +| File | Lines | Notes | +|------|------:|-------| +| `pages/Settings.jsx` | 1969 | Still inline: `ModelStoreTab` (~790L), `Settings` orchestrator (~600L), `GeneralTab`, `EnginesTab`, `HotkeyTab`, `CredentialsTab`, plus `Row`/`fmtBytes`/`orgColor` helpers | +| `pages/DubTab.jsx` | 1592 | One mega-component + inline `DubFailureNotice`, `DubPipelineStepper`, `PrepOverlay`, `TranscribeOverlay`, `FooterBtn` | +| `pages/CloneDesignTab.jsx` | 837 | | +| `pages/VoiceGallery.jsx` | 768 | | +| `pages/VoiceProfile.jsx` | 515 | | +| `pages/AudiobookTab.jsx` | 402 | within target after Phase 3 sweep | +| everything else | <340 | within target | + +Already-extracted, do **not** touch (reference pattern): `components/settings/*Panel.jsx`, +`components/settings/primitives/`. + +## The gold standard (proposed) + +1. **Size caps:** soft **300 lines**, hard **500 lines** per `.jsx`/`.css`. Over 500 must split. +2. **Pages are thin orchestrators:** a page = layout + routing + state wiring that + composes feature components. No inline sub-component over ~50 lines. +3. **One component per file**, co-located `Foo.jsx` + `Foo.css` + `Foo.test.jsx`, + grouped in a per-page feature folder: + - `components/settings/` (exists) + - `components/dub/` (new) + - `components/clone/` (new) + - `components/gallery/` (new) +4. **Shared bits → `primitives/`** in the feature folder (settings already has this). +5. **Enforce with ESLint `max-lines`** — **warn-only first** so it never breaks CI + (respects the "keep main green" rule), upgrade to error after the backlog clears. + +## Phases (each = one mergeable, CI-green PR) + +### Phase 0 — Standard + guardrail +- Add the size/structure rule to `CONTRIBUTING.md` (required by the docs-sync rule anyway). +- Add ESLint `max-lines: ['warn', { max: 500, skipBlankLines: true, skipComments: true }]`. +- No code moves. Smallest possible PR; establishes the contract. + +### Phase 1 — `Settings.jsx` (biggest win: 1969 → ~300L) +Extract into `components/settings/`, mirroring existing panel naming: +| Extract | Current lines (approx) | New file | +|---------|------------------------|----------| +| `ModelStoreTab` (+ `Row`, `fmtBytes`, `orgColor`, `MODEL_ROLE_*`) | 229–1021 | `ModelStoreTab.jsx` (likely split further: table vs. matrix vs. row) | +| `GeneralTab` | 80–201 | `GeneralTab.jsx` | +| `EnginesTab` | 1022–1072 | `EnginesTab.jsx` | +| `HotkeyTab` (+ `CREDENTIAL_FIELDS`, `keyEventToAccelerator`) | 1693–1870 | `HotkeyTab.jsx` | +| `CredentialsTab` | 1871–1969 | `CredentialsTab.jsx` | +`Settings.jsx` keeps only: imports, `TAB_DEFS`/`LOG_SOURCE_DEFS`, the `Settings` +default export (tab router + shared state), and `askConfirm`. + +### Phase 2 — `DubTab.jsx` (1592 → orchestrator + `components/dub/`) +Extract `DubFailureNotice`, `DubPipelineStepper`, `PrepOverlay`, +`TranscribeOverlay`, `FooterBtn`, and the large render sub-sections into +`components/dub/`. `DubTab.jsx` retains the pipeline state machine + composition. + +### Phase 3 — `CloneDesignTab`, `VoiceGallery`, `VoiceProfile`, `AudiobookTab` +Same treatment into `components/clone/` and `components/gallery/`. Smaller, lower risk. + +## Constraints honored +- **No behavior change** — pure moves; diff is verifiable by "app renders + identically + existing tests pass." Each panel that has a test keeps it. +- **Keep main green** — ESLint rule is warn-only; each phase is independently CI-green. +- **Docs-sync** — Phase 0 lands the `CONTRIBUTING.md` change in the same PR as the rule. +- **No versioning impact** — frontend-only refactor; no `package.json` version bump, + no lockfile/dep change, no Docker/Tauri/Python surface touched. + +## Verification per phase +1. `bun run build` (or the project's typecheck/lint) passes. +2. Existing `components/settings/*.test.jsx` (and any new co-located tests) pass. +3. Manual smoke: open Settings → every tab renders; open Dub → pipeline renders. +4. `git diff --stat` shows only moves (line counts shift between files, net ~0 logic change). + +## Out of scope (explicitly) +- No redesign of the Settings *UI* itself (the "unorganised" look) — that's a separate + visual-polish task; this refactor only restructures the *code*. Flag if you want + that bundled. +- No conversion of `.jsx` → `.tsx` (pages are currently JS; TS migration is a + different decision). diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4fa125da..023fbfb9 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -24,6 +24,9 @@ export default defineConfig([ }, rules: { 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + // Modularization guardrail (warn-only so CI stays green). See + // CONTRIBUTING.md → "Frontend file structure & size limits". + 'max-lines': ['warn', { max: 500, skipBlankLines: true, skipComments: true }], }, }, ]) diff --git a/frontend/src/components/settings/AboutTab.jsx b/frontend/src/components/settings/AboutTab.jsx new file mode 100644 index 00000000..102a5361 --- /dev/null +++ b/frontend/src/components/settings/AboutTab.jsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { + Info, CheckCircle, AlertCircle, Download, Activity, Copy, ExternalLink, Building2, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { openExternal } from '../../api/external'; +import { resolveAboutVersion } from '../../utils/appVersion'; +import { Segmented, Button, Badge } from '../../ui'; +import { SettingsSection, SettingRow } from './primitives'; +import { useAppStore } from '../../store'; +import { isTauri } from './native'; +import Row from './Row'; + +export default function AboutTab({ + appVersion, + tauriVersion, + info, + hw, + status, + updateChannel, + changeChannel, + checkForUpdates, + updateState, + selfCheck, + selfCheckRunning, + runSelfCheck, + bundleBuilding, + saveDiagnosticBundle, + copyDiagnostics, +}) { + const { t } = useTranslation(); + + return ( + + + + + + + + + {t('about.yes')} + : {t('about.no')}} /> + + + {status?.status || 'unknown'}} /> + + + + + + + + {/* Auto-updater + channel toggle are desktop-only (Tauri). The Docker + web build updates by pulling a new image tag, so hide these rows + there to avoid a non-functional control (issue #249). */} + {isTauri() && ( + <> + + } + /> + + + )} +
+ {isTauri() && ( + + )} + + + + + + +
+ {selfCheck && ( +
+ {selfCheck.checks.map((c) => ( + + + {c.status === 'ok' + ? + : } {t(`about.self_check_${c.status}`)} + + {' '}{c.detail} + {c.hint && — {c.hint}} + + } + /> + ))} +

+ {selfCheck.summary.ok + ? t('about.self_check_healthy') + : t('about.self_check_attention', { count: selfCheck.summary.failures })} +

+
+ )} +
+ ); +} diff --git a/frontend/src/components/settings/CredentialsTab.jsx b/frontend/src/components/settings/CredentialsTab.jsx new file mode 100644 index 00000000..05963eef --- /dev/null +++ b/frontend/src/components/settings/CredentialsTab.jsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import { KeyRound } from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import { Trans, useTranslation } from 'react-i18next'; +import { openExternal } from '../../api/external'; +import { Badge, Button } from '../../ui'; +import { SettingsSection, SettingRow, SettingsInput, Collapsible } from './primitives'; +import ApiKeysPanel from './ApiKeysPanel'; +import LLMEndpointPanel from './LLMEndpointPanel'; + +const CREDENTIAL_FIELDS = [ + { key: 'HF_TOKEN', labelKey: 'credentials.hf_token', placeholderKey: 'hf_xxxxxxxxxxxx', + helpKey: 'credentials.hf_help', link: 'https://huggingface.co/settings/tokens', isPassword: true }, + { key: 'TRANSLATE_API_KEY', labelKey: 'credentials.translate_key', placeholderKey: 'API key', + helpKey: 'credentials.translate_help', isPassword: true }, + { key: 'TRANSLATE_BASE_URL', labelKey: 'credentials.llm_base_url', placeholderKey: 'https://api.openai.com/v1', + helpKey: 'credentials.llm_base_url_help' }, + { key: 'TRANSLATE_MODEL', labelKey: 'credentials.llm_model', placeholderKey: 'gpt-4o', + helpKey: 'credentials.llm_model_help' }, + { key: 'DEEPL_API_KEY', labelKey: 'credentials.deepl_key', placeholderKey: 'DeepL API key', + helpKey: 'credentials.deepl_key', isPassword: true }, + { key: 'DEEPL_BASE_URL', labelKey: 'credentials.deepl_base_url', placeholderKey: 'https://api.deepl.com/v2', + helpKey: 'credentials.deepl_base_url_help' }, + { key: 'MICROSOFT_API_KEY', labelKey: 'credentials.microsoft_key', placeholderKey: 'Microsoft API key', + helpKey: 'credentials.microsoft_key', isPassword: true }, + { key: 'MICROSOFT_BASE_URL', labelKey: 'credentials.microsoft_base_url', placeholderKey: 'https://api.cognitive.microsofttranslator.com', + helpKey: 'credentials.microsoft_base_url_help' }, +]; + +export default function CredentialsTab({ info }) { + const { t } = useTranslation(); + const [values, setValues] = useState({}); + const [saving, setSaving] = useState(null); + const [saved, setSaved] = useState({}); + + const save = async (key) => { + const value = (values[key] || '').trim(); + if (!value) return; + setSaving(key); + try { + const { API } = await import('../../api/client'); + const res = await fetch(`${API}/system/set-env`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }); + if (res.ok) { + toast.success(t('credentials.saved_session', { key })); + setSaved(prev => ({ ...prev, [key]: true })); + setValues(prev => ({ ...prev, [key]: '' })); + } else { + const d = await res.json().catch(() => ({})); + toast.error(d.detail || t('credentials.save_failed')); + } + } catch (e) { + toast.error(t('credentials.save_error', { message: e.message })); + } finally { + setSaving(null); + } + }; + + return ( + + {/* Wave 2 AUTH-03 panel — 3-source cascade with Active badge, + encrypted-at-rest App-source storage, and live whoami status. */} + + + {/* Wave 2.4 — OpenAI-compatible LLM endpoint (Ollama/LM Studio/vLLM). */} + + + +

+ }} /> +

+ {CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => ( + + {t(field.labelKey)} + {field.key === 'HF_TOKEN' && ( + + {info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')} + + )} + + } + note={ + <> + {t(field.helpKey)} + {field.link && ( + <> { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')} + )} + + } + control={ + <> + setValues(prev => ({ ...prev, [field.key]: e.target.value }))} + onKeyDown={e => e.key === 'Enter' && save(field.key)} + /> + + + } + /> + ))} +
+
+ ); +} diff --git a/frontend/src/components/settings/EnginesTab.jsx b/frontend/src/components/settings/EnginesTab.jsx new file mode 100644 index 00000000..ef3792b1 --- /dev/null +++ b/frontend/src/components/settings/EnginesTab.jsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { addBreadcrumb } from '../../utils/breadcrumbs'; +import { selectEngine } from '../../api/engines'; +import { Segmented } from '../../ui'; +import { useAppStore } from '../../store'; +import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix'; + +export default function EnginesTab() { + const { t } = useTranslation(); + const reviewMode = useAppStore(s => s.reviewMode); + const setReviewMode = useAppStore(s => s.setReviewMode); + + // Plan 02-04 / ENGINE-06 — engine selection is wired through the + // matrix component's optional onSelect callback so the matrix doubles + // as a picker. Keeps a single source of truth for the engine list + + // its install / GPU / isolation state. + const onSelect = useCallback(async (family, backendId) => { + try { + addBreadcrumb(`engine:${family}=${backendId}`); + const r = await selectEngine(family, backendId); + toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active })); + } catch (e) { + toast.error(e.message || t('engines.switch_failed')); + } + }, []); + + return ( +
+
+
+ + · + + {reviewMode === 'on' ? t('engines.banners_on') : t('engines.banners_off')} + +
+
+ + +
+ ); +} diff --git a/frontend/src/components/settings/GeneralTab.jsx b/frontend/src/components/settings/GeneralTab.jsx new file mode 100644 index 00000000..f491ed90 --- /dev/null +++ b/frontend/src/components/settings/GeneralTab.jsx @@ -0,0 +1,209 @@ +import React, { useEffect, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { Settings2, Globe, Palette } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import i18n, { LANGUAGES } from '../../i18n'; +import { useSystemInfo, queryKeys } from '../../api/hooks'; +import { Button, Badge, Select } from '../../ui'; +import { SettingsSection, SettingRow, SettingsInput, Collapsible } from './primitives'; +import { useAppStore } from '../../store'; + +export default function GeneralTab() { + const { t } = useTranslation(); + const locale = useAppStore(s => s.locale); + const setLocale = useAppStore(s => s.setLocale); + const theme = useAppStore(s => s.theme); + const setTheme = useAppStore(s => s.setTheme); + const { data: sysInfo } = useSystemInfo(); + const [proxyUrl, setProxyUrl] = useState(''); + const [proxySaved, setProxySaved] = useState(false); + const [proxySaving, setProxySaving] = useState(false); + const [ffmpegPath, setFfmpegPath] = useState(''); + const [ffmpegSaving, setFfmpegSaving] = useState(false); + const queryClient = useQueryClient(); + + // Sync inputs with persisted values from backend on load + useEffect(() => { + if (!proxyUrl && !proxySaved) setProxyUrl(sysInfo?.proxy_url || ''); + }, [sysInfo?.proxy_url]); + + useEffect(() => { + if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || ''); + }, [sysInfo?.ffmpeg_path]); + + const ffmpegOk = sysInfo?.ffmpeg_ok; + const ffmpegCurrent = sysInfo?.ffmpeg_path; + + const saveFfmpeg = async () => { + const value = ffmpegPath.trim(); + setFfmpegSaving(true); + try { + const { API } = await import('../../api/client'); + const r = await fetch(`${API}/system/set-env`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'FFMPEG_PATH', value }), + }); + if (r.ok) { + toast.success(t('settings.ffmpeg_saved')); + setFfmpegPath(''); + queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo }); + } else { + const d = await r.json().catch(() => ({})); + toast.error(d.detail || t('credentials.save_failed')); + } + } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); } + finally { setFfmpegSaving(false); } + }; + + const handleLocaleChange = (e) => { + const id = e.target.value; + setLocale(id); + i18n.changeLanguage(id); + }; + + const saveProxy = async () => { + const value = proxyUrl.trim(); + setProxySaving(true); + try { + const { API } = await import('../../api/client'); + const setEnv = (key, val) => fetch(`${API}/system/set-env`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value: val }), + }); + const r = await setEnv('HTTP_PROXY', value); + if (r.ok) { + await Promise.all([ + setEnv('HTTPS_PROXY', value), + setEnv('ALL_PROXY', value), + setEnv('http_proxy', value), + setEnv('https_proxy', value), + setEnv('all_proxy', value), + ]); + toast.success(t('settings.proxy_saved')); + setProxySaved(true); + queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo }); + } else { + const d = await r.json().catch(() => ({})); + toast.error(d.detail || t('settings.proxy_save_failed')); + } + } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); } + finally { setProxySaving(false); } + }; + + const clearProxy = async () => { + setProxySaving(true); + try { + const { API } = await import('../../api/client'); + const setEnv = (key, val) => fetch(`${API}/system/set-env`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value: val }), + }); + await Promise.all([ + setEnv('HTTP_PROXY', ''), + setEnv('HTTPS_PROXY', ''), + setEnv('ALL_PROXY', ''), + setEnv('http_proxy', ''), + setEnv('https_proxy', ''), + setEnv('all_proxy', ''), + ]); + setProxyUrl(''); + setProxySaved(false); + toast.success(t('settings.proxy_cleared')); + queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo }); + } catch (e) { toast.error(t('settings.clear_failed', { message: e.message })); } + finally { setProxySaving(false); } + }; + + return ( + + + {LANGUAGES.map((l) => ( + + ))} + + } + /> + setTheme(e.target.value)}> + + + + + + + + } + /> + + + + {t('settings.proxy')} + {proxySaved && {t('credentials.saved')}} + + } + note={t('settings.proxy_desc')} + control={ + <> + setProxyUrl(e.target.value)} + onKeyDown={e => e.key === 'Enter' && saveProxy()} + /> + + {proxySaved && ( + + )} + + } + /> + + + {t('settings.ffmpeg')} + + {ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')} + + + } + note={ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')} + control={ + <> + setFfmpegPath(e.target.value)} + onKeyDown={e => e.key === 'Enter' && saveFfmpeg()} + /> + + + } + /> + + + ); +} diff --git a/frontend/src/components/settings/HotkeyTab.jsx b/frontend/src/components/settings/HotkeyTab.jsx new file mode 100644 index 00000000..158c8cdb --- /dev/null +++ b/frontend/src/components/settings/HotkeyTab.jsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react'; +import { Keyboard } from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import { Trans, useTranslation } from 'react-i18next'; +import { Button } from '../../ui'; +import { SettingsSection, SettingRow } from './primitives'; +import { isTauri } from './native'; + +// Convert a KeyboardEvent into a tauri-plugin-global-shortcut accelerator +// string, e.g. "CmdOrCtrl+Shift+Space". Returns null when only modifiers +// are held (the user hasn't picked a "real" key yet). +function keyEventToAccelerator(e) { + const isMacLike = typeof navigator !== 'undefined' + && /Mac|iPad|iPhone|iPod/.test(navigator.platform || ''); + const mods = []; + if (e.metaKey) mods.push(isMacLike ? 'Cmd' : 'Super'); + if (e.ctrlKey) mods.push('Ctrl'); + if (e.altKey) mods.push('Alt'); + if (e.shiftKey) mods.push('Shift'); + + // e.code is the physical key — already in the shape tauri expects for + // Letter/Digit/Function keys ("KeyA", "Digit1", "F5"). Strip the prefix + // so we get "A" / "1" / "F5" which matches the accelerator grammar. + let key = e.code; + if (!key) return null; + if (key.startsWith('Key')) key = key.slice(3); + else if (key.startsWith('Digit')) key = key.slice(5); + // Skip pure modifier keys — we want the user to pick a real trigger. + if (/^(Meta|Control|Alt|Shift|OS)(Left|Right)?$/.test(key)) return null; + + if (mods.length === 0) return null; + return [...mods, key].join('+'); +} + +export default function HotkeyTab() { + const { t } = useTranslation(); + const [current, setCurrent] = useState(''); + const [recording, setRecording] = useState(false); + const [pending, setPending] = useState(''); + const [saving, setSaving] = useState(false); + const tauri = isTauri(); + + // Load the saved shortcut on mount. + useEffect(() => { + if (!tauri) return; + (async () => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const v = await invoke('get_dictation_shortcut'); + setCurrent(v || ''); + } catch (e) { + toast.error(t('settings.shortcut_load_failed', { message: e?.message || e })); + } + })(); + }, [tauri]); + + // While recording, swallow keystrokes globally and convert the next real + // press into an accelerator string. Escape cancels. + useEffect(() => { + if (!recording) return; + const onKeyDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + setRecording(false); + setPending(''); + return; + } + const accel = keyEventToAccelerator(e); + if (accel) { + setPending(accel); + setRecording(false); + } + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, [recording]); + + const save = async () => { + if (!pending || pending === current) return; + setSaving(true); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const saved = await invoke('set_dictation_shortcut', { accelerator: pending }); + setCurrent(saved); + setPending(''); + toast.success(t('settings.shortcut_set', { shortcut: saved })); + } catch (e) { + // Common cause: the OS or another app already owns the combo. Surface + // the raw error so the user can pick something else. + toast.error(t('settings.shortcut_register_failed', { message: e?.message || e })); + } finally { + setSaving(false); + } + }; + + const resetDefault = async () => { + setSaving(true); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const saved = await invoke('set_dictation_shortcut', { + accelerator: 'CmdOrCtrl+Shift+Space', + }); + setCurrent(saved); + setPending(''); + toast.success(t('settings.shortcut_reset')); + } catch (e) { + toast.error(t('settings.shortcut_reset_failed', { message: e?.message || e })); + } finally { + setSaving(false); + } + }; + + return ( + + {!tauri && ( +

+ }} /> +

+ )} + + + , 2: }} />} + control={recording ? t('capture.listening') : (pending || '—')} + mono + /> + +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/settings/LogsTab.jsx b/frontend/src/components/settings/LogsTab.jsx new file mode 100644 index 00000000..f3ee82f0 --- /dev/null +++ b/frontend/src/components/settings/LogsTab.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { FileText, RefreshCw, Trash2, AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Segmented, Button, Badge } from '../../ui'; +import { SettingsSection } from './primitives'; +import ReportBugButton from '../ReportBugButton'; + +const LOG_SOURCE_DEFS = [ + { value: 'backend', key: 'backend' }, + { value: 'frontend', key: 'frontend' }, + { value: 'tauri', key: 'tauri' }, +]; + +export default function LogsTab({ + logSource, + setLogSource, + logs, + logMeta, + loadingLogs, + refreshLogs, + onClearLogs, +}) { + const { t } = useTranslation(); + + return ( + + + + + + } + > + ({ ...d, label: t(`common.${d.key}`) }))} + value={logSource} + onChange={setLogSource} + /> + +
+ {logMeta.path || '—'} + {logSource === 'tauri' && !logMeta.exists && ( + + {t('logs.no_tauri_log')} + + )} +
+
+ {logs.length === 0 + ? + {logSource === 'frontend' + ? t('logs.empty_frontend') + : logSource === 'tauri' + ? t('logs.empty_tauri') + : t('logs.empty_backend')} + + : logs.join('')} +
+
+ ); +} diff --git a/frontend/src/components/settings/ModelStoreTab.jsx b/frontend/src/components/settings/ModelStoreTab.jsx new file mode 100644 index 00000000..fc05db59 --- /dev/null +++ b/frontend/src/components/settings/ModelStoreTab.jsx @@ -0,0 +1,439 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { + Cpu, RefreshCw, KeyRound, +} from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { openExternal } from '../../api/external'; +import { setupDownloadStreamUrl } from '../../api/setup'; +import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../../api/hooks'; +import { Button, Segmented } from '../../ui'; +import { SettingsSection, SettingsInput } from './primitives'; +import { askConfirm } from './native'; +import { fmtBytes } from './models/format'; +import { computeRowRuntime } from './models/runtime'; +import { makeModelColumns } from './models/columns'; +import RecoBanner from './models/RecoBanner'; +import ModelsTable from './models/ModelsTable'; + +const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm']; +const MODEL_ROLE_LABEL = { all: 'All', tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' }; + +/** + * Model store — list every known HF model, show install state, let the + * user install / reinstall / delete individual models. Per-model download + * progress is pulled from the shared /setup/download-stream SSE. + */ +export default function ModelStoreTab({ info, modelBadge }) { + const { t } = useTranslation(); + const modelsQuery = useModels(); + const recoQuery = useRecommendations(); + const data = modelsQuery.data; + const loading = modelsQuery.isLoading; + const reco = recoQuery.data; + const installMutation = useInstallModel(); + const deleteMutation = useDeleteModel(); + + const [busy, setBusy] = useState(new Set()); // repo_ids currently working + // Per-repo active state. Tracks aggregate download across all files of + // a running install so the row can show a determinate progress bar. + // { [repo_id]: { phase, files: { [filename]: { downloaded, total, pct } }, error } } + const [rowState, setRowState] = useState({}); + const [query, setQuery] = useState(''); + const [installingReco, setInstallingReco] = useState(false); + const [activeRole, setActiveRole] = useState(null); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const esRef = React.useRef(null); + const tableBodyRef = React.useRef(null); + // Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } } + const speedRef = React.useRef({}); + // Tick counter — forces re-render every second while a download is active + // so speed/ETA displays update smoothly between SSE events. + const [, setTick] = useState(0); + // Boolean derived from rowState so the interval effect below only re-runs + // when activity starts/stops — not on every SSE progress event (several per + // second during installs), which would clear + recreate the 1s tick forever. + const hasActive = useMemo(() => Object.values(rowState).some(s => + ['install_start', 'active', 'delete_start'].includes(s.phase)), [rowState]); + useEffect(() => { + if (!hasActive) return; + const iv = setInterval(() => setTick(t => t + 1), 1000); + return () => clearInterval(iv); + }, [hasActive]); + + // HF token inline — compact input in the toolbar + const [hfToken, setHfToken] = useState(''); + const [hfSaved, setHfSaved] = useState(false); + const [hfSaving, setHfSaving] = useState(false); + const [hfExpanded, setHfExpanded] = useState(false); + const saveHfToken = async () => { + const value = hfToken.trim(); + if (!value) return; + setHfSaving(true); + try { + const { API } = await import('../../api/client'); + const res = await fetch(`${API}/system/set-env`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'HF_TOKEN', value }), + }); + if (res.ok) { + toast.success(t('models.hf_token_set_toast')); + setHfSaved(true); + setHfToken(''); + setHfExpanded(false); + } else { + const d = await res.json().catch(() => ({})); + toast.error(d.detail || t('models.hf_token_save_failed')); + } + } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); } + finally { setHfSaving(false); } + }; + const hfTokenSet = hfSaved || info?.has_hf_token; + + // Open the progress stream once when the tab mounts; close on unmount. + useEffect(() => { + const es = new EventSource(setupDownloadStreamUrl()); + esRef.current = es; + es.onmessage = (evt) => { + try { + const ev = JSON.parse(evt.data); + if (!ev?.repo_id) return; + setRowState(prev => { + const cur = prev[ev.repo_id] || { phase: 'active', files: {} }; + // Lifecycle events (install_start/install_done/install_error, + // delete_start/delete_done) flip the row's phase without + // touching per-file accounting. + if (ev.phase === 'install_start' || ev.phase === 'delete_start') { + return { ...prev, [ev.repo_id]: { phase: ev.phase, files: {}, error: null } }; + } + // Heartbeat from backend while resolving repo metadata + if (ev.phase === 'resolving') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 } }; + } + if (ev.phase === 'install_retry') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error } }; + } + if (ev.phase === 'install_done') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_done' } }; + } + if (ev.phase === 'delete_done') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'delete_done' } }; + } + if (ev.phase === 'install_error') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } }; + } + if (ev.phase === 'install_cancelled') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_cancelled' } }; + } + // Pre-flight plan (FDL-05): accurate total/cached/remaining BEFORE + // bytes flow. Keep the current phase (usually resolving) — the plan + // is metadata, not a state change. + if (ev.phase === 'install_plan') { + return { ...prev, [ev.repo_id]: { ...cur, plan: { + total_bytes: ev.total_bytes ?? null, + cached_bytes: ev.cached_bytes ?? null, + to_download_bytes: ev.to_download_bytes ?? null, + n_files: ev.n_files ?? null, + n_cached: ev.n_cached ?? null, + } } }; + } + // Overall aggregate (FDL-06): one rolling event that is the source of + // truth for the overall bar / speed / remaining / ETA. + if (ev.phase === 'aggregate') { + return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', agg: { + bytes_done: ev.bytes_done ?? 0, + total_bytes: ev.total_bytes ?? null, + rate: ev.rate ?? 0, + eta_seconds: ev.eta_seconds ?? null, + files_done: ev.files_done ?? 0, + files_total: ev.files_total ?? null, + } } }; + } + // Per-file tqdm events — aggregate across files. + const files = { ...cur.files, [ev.filename]: { + downloaded: ev.downloaded || 0, + total: ev.total || 0, + pct: ev.pct || 0, + phase: ev.phase, + rate: ev.rate || 0, + }}; + return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', files } }; + }); + } catch { /* keepalive / ignore */ } + }; + return () => es.close(); + }, []); + + // When a lifecycle terminator fires, refresh the list so "installed" + // flips server-side info into the row. + useEffect(() => { + const term = Object.entries(rowState).find(([, s]) => + ['install_done', 'delete_done', 'install_error', 'install_cancelled'].includes(s.phase)); + if (!term) return; + const t = setTimeout(() => { + modelsQuery.refetch(); + recoQuery.refetch(); + // Clear stale speed data for this repo. + delete speedRef.current[term[0]]; + // Clear the terminal entry so the row reverts to the authoritative + // `installed` flag from /models without keeping stale progress. + setRowState(prev => { + const next = { ...prev }; + delete next[term[0]]; + return next; + }); + }, 800); + return () => clearTimeout(t); + }, [rowState, modelsQuery, recoQuery]); + + const reload = useCallback(() => { + modelsQuery.refetch(); + recoQuery.refetch(); + }, [modelsQuery, recoQuery]); + + const withBusy = useCallback(async (repoId, fn, successMsg) => { + setBusy(prev => new Set(prev).add(repoId)); + try { + await fn(); + if (successMsg) toast.success(successMsg); + } catch (e) { + toast.error(e.message || String(e)); + } finally { + setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; }); + } + }, []); + + const onInstall = useCallback((repoId) => + withBusy(repoId, () => installMutation.mutateAsync(repoId), t('models.install_started')), + [installMutation, withBusy]); + const onDelete = useCallback(async (repoId) => { + if (!(await askConfirm(t('models.delete_confirm', { repoId }), t('models.delete_confirm_title')))) return; + return withBusy(repoId, () => deleteMutation.mutateAsync(repoId), t('models.deleted', { repoId })); + }, [deleteMutation, withBusy]); + const onReinstall = useCallback(async (repoId) => { + if (!(await askConfirm(t('models.reinstall_confirm', { repoId }), t('models.reinstall_confirm_title')))) return; + await withBusy(repoId, async () => { + await deleteMutation.mutateAsync(repoId); + await installMutation.mutateAsync(repoId); + }, t('models.reinstalling')); + }, [deleteMutation, installMutation, withBusy]); + + const onInstallRecommended = async () => { + if (!reco) return; + const missing = reco.models.filter(m => !m.installed); + if (missing.length === 0) { + toast.success(t('models.recommended_installed')); + return; + } + setInstallingReco(true); + try { + // Parallel install — backend /models/install spawns each download on + // its own asyncio task so ordering doesn't matter. + await Promise.all(missing.map(m => installMutation.mutateAsync(m.repo_id))); + toast.success(t('models.started_downloading', { count: missing.length })); + } catch (e) { + toast.error(t('models.install_failed', { message: e.message || e })); + } finally { + setInstallingReco(false); + } + }; + + const allModels = React.useMemo(() => data?.models || [], [data]); + const groups = allModels.reduce((acc, m) => { + const k = (m.role || 'other').toLowerCase(); + (acc[k] = acc[k] || []).push(m); + return acc; + }, {}); + const roles = Object.keys(groups).sort((a, b) => { + const ai = MODEL_ROLE_ORDER.indexOf(a), bi = MODEL_ROLE_ORDER.indexOf(b); + return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi); + }); + // 'all' is a virtual role — shows every model regardless of category. + const currentRole = activeRole === 'all' ? 'all' + : activeRole && groups[activeRole] ? activeRole + : 'all'; + + const allInstalled = allModels.filter(m => m.installed).length; + + useEffect(() => { + setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]); + }, [currentRole]); + + const getRowRuntime = React.useCallback( + (m) => computeRowRuntime(m, rowState, busy), + [busy, rowState], + ); + + const columns = React.useMemo( + () => makeModelColumns({ t, getRowRuntime, speedRef, MODEL_ROLE_LABEL, onInstall, onDelete, onReinstall }), + [getRowRuntime, onDelete, onInstall, onReinstall, t], + ); + + const table = useReactTable({ + data: allModels, + columns, + getRowId: row => row.repo_id, + state: { + sorting, + globalFilter: query, + columnFilters, + }, + onSortingChange: setSorting, + onGlobalFilterChange: setQuery, + onColumnFiltersChange: setColumnFilters, + globalFilterFn: (row, _columnId, value) => { + const q = String(value || '').trim().toLowerCase(); + if (!q) return true; + const m = row.original; + return [m.repo_id, m.label, m.note, m.role] + .filter(Boolean) + .some(v => String(v).toLowerCase().includes(q)); + }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const tableRows = table.getRowModel().rows; + const rowVirtualizer = useVirtualizer({ + count: tableRows.length, + getScrollElement: () => tableBodyRef.current, + estimateSize: () => 68, + overscan: 8, + }); + + if (loading && !data) { + return ( + +
{t('common.loading')}
+
+ ); + } + if (!data) return null; + + return ( +
+
+
+ {fmtBytes(data.total_installed_bytes)} + · + {data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')} + {info && ·} + {info && {modelBadge}} + {info?.fast_download?.xet_enabled && ( + <> + · + + ⚡ {t('models.fast_download_badge') || 'fast download'} + + + )} +
+
+ {/* Compact HF token inline */} + {!hfTokenSet && !hfExpanded && ( + + )} + {!hfTokenSet && hfExpanded && ( +
+ setHfToken(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') saveHfToken(); if (e.key === 'Escape') setHfExpanded(false); }} + autoFocus + /> + + { e.preventDefault(); openExternal('https://huggingface.co/settings/tokens'); }} + title="Open huggingface.co/settings/tokens" + > + {t('models.get_token')}→ + +
+ )} + {hfTokenSet && ( + + )} + +
+
+ + + +
+ { + const installed = groups[r].filter(m => m.installed).length; + return { + value: r, + label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`, + }; + }), + ]} + /> + setQuery(e.target.value)} + aria-label={t('models.search_label')} + /> +
+ + +
+ ); +} diff --git a/frontend/src/components/settings/PrivacyTab.jsx b/frontend/src/components/settings/PrivacyTab.jsx new file mode 100644 index 00000000..235f1853 --- /dev/null +++ b/frontend/src/components/settings/PrivacyTab.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { ShieldCheck, CheckCircle, AlertCircle } from 'lucide-react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Badge } from '../../ui'; +import { SettingsSection } from './primitives'; +import Row from './Row'; + +export default function PrivacyTab({ info }) { + const { t } = useTranslation(); + + return ( + +

+ }} /> +

+ + + {t('privacy.local_sqlite')}} /> + {t('privacy.translator_online', { provider: info.translate_provider })} + : {t('privacy.translator_offline')} + } + /> + {t('privacy.no_tracking')}} + /> +
+ ); +} diff --git a/frontend/src/components/settings/Row.jsx b/frontend/src/components/settings/Row.jsx new file mode 100644 index 00000000..4b6e8390 --- /dev/null +++ b/frontend/src/components/settings/Row.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { SettingRow } from './primitives'; + +// About/Privacy read-only data rows delegate to the shared SettingRow primitive +// so they pick up the redesigned grid + mono value styling unchanged. +export default function Row({ label, value, mono }) { + return ; +} diff --git a/frontend/src/components/settings/models/ModelsTable.jsx b/frontend/src/components/settings/models/ModelsTable.jsx new file mode 100644 index 00000000..fd3fccf4 --- /dev/null +++ b/frontend/src/components/settings/models/ModelsTable.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { flexRender } from '@tanstack/react-table'; +import { Table } from '../../../ui'; + +/** + * Virtualized model table view. Purely presentational — the table instance, + * virtualizer, and row runtime resolver are all created by the host + * ModelStoreTab and passed in. + */ +export default function ModelsTable({ table, tableRows, rowVirtualizer, tableBodyRef, getRowRuntime, t }) { + return ( + +
+ {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const meta = header.column.columnDef.meta || {}; + const canSort = header.column.getCanSort(); + return ( + + ); + })} + + ))} +
+
+
+ {rowVirtualizer.getVirtualItems().map(virtualRow => { + const row = tableRows[virtualRow.index]; + const m = row.original; + const rt = getRowRuntime(m); + return ( +
+ {row.getVisibleCells().map(cell => { + const meta = cell.column.columnDef.meta || {}; + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); + })} +
+ ); + })} + {tableRows.length === 0 && ( +
{t('models.no_matches')}
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/settings/models/RecoBanner.jsx b/frontend/src/components/settings/models/RecoBanner.jsx new file mode 100644 index 00000000..edd67bea --- /dev/null +++ b/frontend/src/components/settings/models/RecoBanner.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { RefreshCw, CheckCircle } from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import { Button } from '../../../ui'; + +/** + * Recommendation banner — shows the device's recommended model set and lets the + * user kick off the required / all installs. Purely presentational; all state + * and mutations are supplied by the host ModelStoreTab. + */ +export default function RecoBanner({ + reco, + t, + installMutation, + installingReco, + setInstallingReco, + onInstallRecommended, +}) { + if (!reco) return null; + if (reco.all_installed) { + return ( +
+ + {t('models.reco_installed_for', { device: reco.device.label })} + {reco.total_gb} GB +
+ ); + } + return ( +
+
+ {t('models.reco_for', { device: reco.device.label })} +
+ {(() => { + const requiredMissing = reco.models.filter(m => m.required && !m.installed); + const requiredGb = requiredMissing.reduce((s, m) => s + m.size_gb, 0); + if (requiredMissing.length === 0) return null; + return ( + + ); + })()} + +
+
+
+ {reco.models.map(m => ( + + {m.installed ? '✓' : '○'} {m.label} + {m.size_gb} + {m.required && {t('models.req_tag')}} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/settings/models/columns.jsx b/frontend/src/components/settings/models/columns.jsx new file mode 100644 index 00000000..832c4130 --- /dev/null +++ b/frontend/src/components/settings/models/columns.jsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { + RefreshCw, Trash2, ExternalLink, Download, +} from 'lucide-react'; +import { openExternal } from '../../../api/external'; +import { Button, Badge, Progress } from '../../../ui'; +import { fmtBytes, orgColor } from './format'; + +/** + * Build the TanStack column definitions for the model store table. + * + * The column cells close over runtime values, so this is a factory rather than + * a static array: pass in the live callbacks/refs from the host component and + * memoize the result with the same dependency array the inline definition used. + */ +export function makeModelColumns({ + t, + getRowRuntime, + speedRef, + MODEL_ROLE_LABEL, + onInstall, + onDelete, + onReinstall, +}) { + return [ + { + id: 'name', + accessorFn: m => `${m.label || ''} ${m.repo_id || ''}`, + header: t('models.column_model'), + size: 260, + meta: { className: 'models-row__name' }, + cell: ({ row }) => { + const m = row.original; + const rt = getRowRuntime(m); + return ( + <> + + + {m.repo_id.split('/')[0].slice(0, 2).toUpperCase()} + + {m.label} + {m.required && {t('models.required_tag')}} + + + {m.repo_id} + {m.note && · {m.note}} + + {rt.showBar && ( +
+ + + {(() => { + if (rt.isDeleting) return t('models.removing_cached'); + const hasAgg = !!rt.agg; + if (!rt.hasFiles && !hasAgg) { + if (rt.phase === 'resolving') { + const dots = '.'.repeat((rt.rs?.resolvingStep || 0) % 4); + // Once the preflight plan lands we can show the real size + // even before the first byte (FDL-05). + const planBytes = rt.plan?.to_download_bytes; + const planStr = planBytes ? ` · ${fmtBytes(planBytes)} ${t('models.to_download') || 'to download'}` : ''; + return `${t('models.resolving_metadata')}${dots}${planStr}`; + } + if (rt.phase === 'install_retry') { + return t('models.retry_attempt', { attempt: rt.rs?.retryAttempt || '?', error: rt.rs?.error || 'reconnecting' }); + } + return t('models.connecting_hf'); + } + + // Prefer the backend aggregate's windowed rate (FDL-06). + // Fall back to the frontend sampler only until it arrives. + let speed = rt.aggRate ?? 0; + if (!(speed > 0)) { + const sp = speedRef.current[m.repo_id]; + const now = Date.now(); + if (sp && rt.dispDownloaded > 0) { + const dt = (now - sp.lastTime) / 1000; + if (dt >= 1) { + sp.speed = Math.max(0, (rt.dispDownloaded - sp.lastBytes) / dt); + sp.lastBytes = rt.dispDownloaded; + sp.lastTime = now; + } + } else { + speedRef.current[m.repo_id] = { lastBytes: rt.dispDownloaded, lastTime: now, speed: 0 }; + } + speed = rt.backendRate > 0 ? rt.backendRate : (sp?.speed || 0); + } + + // Total unknown and nothing downloaded yet → still resolving + if (rt.dispTotal === 0 && rt.dispDownloaded === 0) { + const activeFile = rt.activeFilename?.split('/').pop(); + return activeFile + ? t('models.resolving_files_active', { count: rt.fileList.length, file: activeFile }) + : t('models.resolving_files', { count: rt.fileList.length }); + } + + // ETA: prefer the backend's, else derive from remaining/speed. + const remaining = Math.max(0, rt.dispTotal - rt.dispDownloaded); + const etaSec = rt.aggEtaSec != null ? rt.aggEtaSec + : (speed > 0 && rt.dispTotal > 0 ? remaining / speed : 0); + const etaStr = etaSec > 0 + ? etaSec < 60 ? `~${Math.ceil(etaSec)}s` + : etaSec < 3600 ? `~${Math.ceil(etaSec / 60)}m` + : `~${(etaSec / 3600).toFixed(1)}h` + : ''; + const dlStr = fmtBytes(rt.dispDownloaded) || '0 B'; + const totalStr = rt.dispTotal > 0 ? fmtBytes(rt.dispTotal) : '…'; + const pctStr = rt.aggPct != null && rt.aggPct > 0 ? `${Math.round(rt.aggPct)}%` : ''; + const speedStr = speed > 0 ? `${fmtBytes(speed)}/s` : ''; + + const parts = [ + `${dlStr} / ${totalStr}`, + pctStr, + speedStr || (rt.dispDownloaded > 0 ? t('models.measuring') : ''), + etaStr, + ].filter(Boolean); + + const extra = []; + if (rt.cachedBytes > 0) extra.push(`${fmtBytes(rt.cachedBytes)} ${t('models.cached') || 'cached'}`); + if (rt.filesTotal > 1) extra.push(t('models.files_progress', { done: rt.filesDone, total: rt.filesTotal })); + if (rt.activeFilename) extra.push(rt.activeFilename.split('/').pop()); + + return extra.length + ? `${parts.join(' · ')} ⸱ ${extra.join(' · ')}` + : parts.join(' · '); + })()} + +
+ )} + {rt.phase === 'install_error' && rt.rs?.error && ( + {t('models.install_error', { error: rt.rs.error })} + )} + + ); + }, + }, + { + id: 'role', + accessorFn: m => (m.role || 'other').toLowerCase(), + header: t('models.column_role'), + size: 58, + filterFn: (row, id, value) => !value || row.getValue(id) === value, + cell: ({ row }) => {MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'}, + }, + { + id: 'size', + accessorFn: m => m.installed ? (m.size_on_disk_bytes || 0) : (m.size_gb || 0) * 1024 ** 3, + header: t('models.column_size'), + size: 68, + meta: { align: 'right', className: 'models-row__size' }, + cell: ({ row }) => { + const m = row.original; + const rt = getRowRuntime(m); + // During active download, show live downloaded / total + if (rt.showBar && rt.hasFiles && rt.totals.total > 0) { + return {fmtBytes(rt.totals.downloaded)}/{fmtBytes(rt.totals.total)}; + } + return m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`; + }, + }, + { + id: 'status', + accessorFn: m => m.installed ? 2 : (m.supported === false ? 0 : 1), + header: t('models.column_status'), + size: 96, + meta: { align: 'center', className: 'models-row__status' }, + cell: ({ row }) => { + const m = row.original; + const rt = getRowRuntime(m); + return rt.isInstalling + ? {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : t('models.downloading')} + : rt.isDeleting + ? {t('models.deleting')} + : rt.rowBusy + ? {t('models.working')} + : m.installed + ? {t('models.installed')} + : rt.unsupported + ? {(m.platforms || []).join(', ')} + : {t('models.not_installed')}; + }, + }, + { + id: 'actions', + header: '', + size: 90, + enableSorting: false, + meta: { align: 'right', className: 'models-row__actions' }, + cell: ({ row }) => { + const m = row.original; + const rt = getRowRuntime(m); + return ( + <> + + {!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && ( + + )} + {m.installed && !rt.rowBusy && !rt.isDeleting && ( + <> + + + + )} + + ); + }, + }, + ]; +} diff --git a/frontend/src/components/settings/models/format.js b/frontend/src/components/settings/models/format.js new file mode 100644 index 00000000..60d4cb7d --- /dev/null +++ b/frontend/src/components/settings/models/format.js @@ -0,0 +1,17 @@ +/** Pure, closure-free formatting helpers for the model store. */ + +export function fmtBytes(n) { + if (n == null || n < 0) return '—'; + if (n === 0) return '0 B'; + if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`; + if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`; + return `${Math.round(n / 1024)} KB`; +} + +/** Deterministic muted HSL color from an org/user name in a repo_id. */ +export function orgColor(repoId) { + const org = (repoId || '').split('/')[0]; + let h = 0; + for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) & 0xffff; + return `hsl(${h % 360}, 35%, 28%)`; +} diff --git a/frontend/src/components/settings/models/runtime.js b/frontend/src/components/settings/models/runtime.js new file mode 100644 index 00000000..86db4ac4 --- /dev/null +++ b/frontend/src/components/settings/models/runtime.js @@ -0,0 +1,75 @@ +/** + * Derive a model row's runtime display state from the live SSE-driven + * `rowState` map and the set of `busy` repo ids. Pure — no React/closure deps + * beyond its arguments — so it can be memoized by the host component. + */ +export function computeRowRuntime(m, rowState, busy) { + const rs = rowState[m.repo_id]; + const rowBusy = busy.has(m.repo_id); + const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error); + const isDeleting = rs?.phase === 'delete_start'; + const phase = rs?.phase; + const fileList = rs?.files ? Object.entries(rs.files) : []; + const totals = fileList.reduce((a, [, f]) => ({ + downloaded: a.downloaded + (f.downloaded || 0), + total: a.total + (f.total || 0), + done: a.done + (f.phase === 'done' ? 1 : 0), + }), { downloaded: 0, total: 0, done: 0 }); + // Sum backend-reported rate from active (non-done) files + const backendRate = fileList + .filter(([, f]) => f.phase !== 'done' && f.rate > 0) + .reduce((s, [, f]) => s + f.rate, 0); + const hasFiles = fileList.length > 0; + const showBar = ['install_start', 'resolving', 'install_retry', 'active', 'delete_start'].includes(phase); + const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0]; + const unsupported = m.supported === false; + + // Overall progress: prefer the backend aggregate (FDL-06) — it sums bytes + // across all parallel files/chunks and samples a windowed rate, which is + // accurate under Xet's parallel fetch. Fall back to per-file summation + + // the frontend speed sampler only until the first aggregate event lands. + const agg = rs?.agg || null; + const plan = rs?.plan || null; + const dispDownloaded = agg ? (agg.bytes_done || 0) : totals.downloaded; + // Denominator: aggregate total → preflight "to download" → per-file totals. + const dispTotal = (agg?.total_bytes ?? plan?.to_download_bytes ?? totals.total) || 0; + // Bar %: prefer byte-fraction, but under Xet the byte bars don't advance + // mid-download (only the file-count bar does), so fall back to the file + // fraction so the bar still moves. Take the max so whichever signal is + // live drives it; complete() flushes both to 100% at the end. + const bytePct = dispTotal > 0 ? (dispDownloaded / dispTotal) * 100 : 0; + const filesTotalForPct = agg?.files_total ?? plan?.n_files ?? 0; + const filePct = filesTotalForPct > 0 ? ((agg?.files_done ?? 0) / filesTotalForPct) * 100 : 0; + const aggPct = (dispTotal > 0 || filesTotalForPct > 0) ? Math.max(bytePct, filePct) : null; + const cachedBytes = plan?.cached_bytes ?? null; + const filesTotal = agg?.files_total ?? plan?.n_files ?? (hasFiles ? fileList.length : null); + const filesDone = agg?.files_done ?? totals.done; + // Backend rate (windowed aggregate) wins over the per-file rate sum. + const aggRate = agg?.rate ?? null; + const aggEtaSec = agg?.eta_seconds ?? null; + + return { + rs, + rowBusy, + isInstalling, + isDeleting, + phase, + fileList, + totals, + hasFiles, + aggPct, + showBar, + activeFilename, + unsupported, + backendRate, + agg, + plan, + dispDownloaded, + dispTotal, + cachedBytes, + filesTotal, + filesDone, + aggRate, + aggEtaSec, + }; +} diff --git a/frontend/src/components/settings/native.js b/frontend/src/components/settings/native.js new file mode 100644 index 00000000..ea48a85d --- /dev/null +++ b/frontend/src/components/settings/native.js @@ -0,0 +1,16 @@ +import { isTauri as _isTauri } from '../../utils/media'; + +/** True when running inside the Tauri webview (vs. vite dev / web preview / tests). */ +export const isTauri = () => _isTauri; + +// Tauri v2's webview disables native window.confirm/alert — they return +// false silently, making Delete/Reinstall buttons appear dead. Route through +// the dialog plugin when running in Tauri, fall back to browser confirm +// elsewhere (vite dev, tests). +export async function askConfirm(message, title = 'Confirm') { + if (isTauri()) { + const { ask } = await import('@tauri-apps/plugin-dialog'); + return ask(message, { title, kind: 'warning' }); + } + return Promise.resolve(window.confirm(message)); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 81bcbb9c..a15bc74b 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,39 +1,22 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { copyText } from "../utils/copyText"; -import { isTauri as _isTauri } from '../utils/media'; import { normalizeChannel } from '../utils/updateChannel'; import { setChannel } from '../utils/channelControl'; import { - flexRender, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { - Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink, - CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound, - Keyboard, Wifi, Palette, Activity, ArrowDownToLine, Settings2, Globe, + Cpu, FileText, Info, ShieldCheck, RefreshCw, + CheckCircle, Plug, KeyRound, + Keyboard, Wifi, Palette, ArrowDownToLine, Settings2, } from 'lucide-react'; import { toast } from 'react-hot-toast'; -import { openExternal } from '../api/external'; import { API } from '../api/client'; -import { addBreadcrumb } from '../utils/breadcrumbs'; -import { Trans, useTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system'; -import i18n, { LANGUAGES } from '../i18n'; -import { useSysinfo, useModelStatus, useSystemInfo, queryKeys } from '../api/hooks'; -import { useQueryClient } from '@tanstack/react-query'; -import { selectEngine } from '../api/engines'; -import { setupDownloadStreamUrl } from '../api/setup'; +import { useSysinfo, useModelStatus, useSystemInfo } from '../api/hooks'; import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer'; import { resolveAboutVersion } from '../utils/appVersion'; -import { Tabs, Segmented, Button, Badge, Table, Progress, Select } from '../ui'; -import { SettingsSection, SettingRow, SettingsInput, Collapsible } from '../components/settings/primitives'; +import { Tabs, Badge } from '../ui'; +import { SettingsSection } from '../components/settings/primitives'; import { useAppStore } from '../store'; -import ApiKeysPanel from '../components/settings/ApiKeysPanel'; -import LLMEndpointPanel from '../components/settings/LLMEndpointPanel'; import PerformancePanel from '../components/settings/PerformancePanel'; import RefinementPanel from '../components/settings/RefinementPanel'; import AecPanel from '../components/settings/AecPanel'; @@ -45,10 +28,17 @@ import SharingPanel from '../components/settings/SharingPanel'; import RemoteBackendPanel from '../components/settings/RemoteBackendPanel'; import MCPBindingsPanel from '../components/settings/MCPBindingsPanel'; import PronunciationPanel from '../components/settings/PronunciationPanel'; -import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix'; import DictationDemo from '../components/DictationDemo'; import UpdatesPanel from '../components/UpdatesPanel'; -import ReportBugButton from '../components/ReportBugButton'; +import GeneralTab from '../components/settings/GeneralTab'; +import ModelStoreTab from '../components/settings/ModelStoreTab'; +import EnginesTab from '../components/settings/EnginesTab'; +import HotkeyTab from '../components/settings/HotkeyTab'; +import CredentialsTab from '../components/settings/CredentialsTab'; +import AboutTab from '../components/settings/AboutTab'; +import PrivacyTab from '../components/settings/PrivacyTab'; +import LogsTab from '../components/settings/LogsTab'; +import { isTauri, askConfirm } from '../components/settings/native'; import './Settings.css'; // Ordered as a logical flow: setup basics first (General/Appearance), then the @@ -68,1095 +58,6 @@ const TAB_DEFS = [ { id: 'privacy', icon: ShieldCheck }, ]; -const LOG_SOURCE_DEFS = [ - { value: 'backend', key: 'backend' }, - { value: 'frontend', key: 'frontend' }, - { value: 'tauri', key: 'tauri' }, -]; - -const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm']; -const MODEL_ROLE_LABEL = { all: 'All', tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' }; - -function GeneralTab() { - const { t } = useTranslation(); - const locale = useAppStore(s => s.locale); - const setLocale = useAppStore(s => s.setLocale); - const theme = useAppStore(s => s.theme); - const setTheme = useAppStore(s => s.setTheme); - const { data: sysInfo } = useSystemInfo(); - const [proxyUrl, setProxyUrl] = useState(''); - const [proxySaved, setProxySaved] = useState(false); - const [proxySaving, setProxySaving] = useState(false); - const [ffmpegPath, setFfmpegPath] = useState(''); - const [ffmpegSaving, setFfmpegSaving] = useState(false); - const queryClient = useQueryClient(); - - // Sync inputs with persisted values from backend on load - useEffect(() => { - if (!proxyUrl && !proxySaved) setProxyUrl(sysInfo?.proxy_url || ''); - }, [sysInfo?.proxy_url]); - - useEffect(() => { - if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || ''); - }, [sysInfo?.ffmpeg_path]); - - const ffmpegOk = sysInfo?.ffmpeg_ok; - const ffmpegCurrent = sysInfo?.ffmpeg_path; - - const saveFfmpeg = async () => { - const value = ffmpegPath.trim(); - setFfmpegSaving(true); - try { - const { API } = await import('../api/client'); - const r = await fetch(`${API}/system/set-env`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key: 'FFMPEG_PATH', value }), - }); - if (r.ok) { - toast.success(t('settings.ffmpeg_saved')); - setFfmpegPath(''); - queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo }); - } else { - const d = await r.json().catch(() => ({})); - toast.error(d.detail || t('credentials.save_failed')); - } - } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); } - finally { setFfmpegSaving(false); } - }; - - const handleLocaleChange = (e) => { - const id = e.target.value; - setLocale(id); - i18n.changeLanguage(id); - }; - - const saveProxy = async () => { - const value = proxyUrl.trim(); - setProxySaving(true); - try { - const { API } = await import('../api/client'); - const setEnv = (key, val) => fetch(`${API}/system/set-env`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value: val }), - }); - const r = await setEnv('HTTP_PROXY', value); - if (r.ok) { - await Promise.all([ - setEnv('HTTPS_PROXY', value), - setEnv('ALL_PROXY', value), - setEnv('http_proxy', value), - setEnv('https_proxy', value), - setEnv('all_proxy', value), - ]); - toast.success(t('settings.proxy_saved')); - setProxySaved(true); - queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo }); - } else { - const d = await r.json().catch(() => ({})); - toast.error(d.detail || t('settings.proxy_save_failed')); - } - } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); } - finally { setProxySaving(false); } - }; - - const clearProxy = async () => { - setProxySaving(true); - try { - const { API } = await import('../api/client'); - const setEnv = (key, val) => fetch(`${API}/system/set-env`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value: val }), - }); - await Promise.all([ - setEnv('HTTP_PROXY', ''), - setEnv('HTTPS_PROXY', ''), - setEnv('ALL_PROXY', ''), - setEnv('http_proxy', ''), - setEnv('https_proxy', ''), - setEnv('all_proxy', ''), - ]); - setProxyUrl(''); - setProxySaved(false); - toast.success(t('settings.proxy_cleared')); - queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo }); - } catch (e) { toast.error(t('settings.clear_failed', { message: e.message })); } - finally { setProxySaving(false); } - }; - - return ( - - - {LANGUAGES.map((l) => ( - - ))} - - } - /> - setTheme(e.target.value)}> - - - - - - - - } - /> - - - - {t('settings.proxy')} - {proxySaved && {t('credentials.saved')}} - - } - note={t('settings.proxy_desc')} - control={ - <> - setProxyUrl(e.target.value)} - onKeyDown={e => e.key === 'Enter' && saveProxy()} - /> - - {proxySaved && ( - - )} - - } - /> - - - {t('settings.ffmpeg')} - - {ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')} - - - } - note={ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')} - control={ - <> - setFfmpegPath(e.target.value)} - onKeyDown={e => e.key === 'Enter' && saveFfmpeg()} - /> - - - } - /> - - - ); -} - -// About/Privacy read-only data rows delegate to the shared SettingRow primitive -// so they pick up the redesigned grid + mono value styling unchanged. -function Row({ label, value, mono }) { - return ; -} - -function fmtBytes(n) { - if (n == null || n < 0) return '—'; - if (n === 0) return '0 B'; - if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`; - if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`; - return `${Math.round(n / 1024)} KB`; -} - -/** Deterministic muted HSL color from an org/user name in a repo_id. */ -function orgColor(repoId) { - const org = (repoId || '').split('/')[0]; - let h = 0; - for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) & 0xffff; - return `hsl(${h % 360}, 35%, 28%)`; -} - -import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../api/hooks'; - -/** - * Model store — list every known HF model, show install state, let the - * user install / reinstall / delete individual models. Per-model download - * progress is pulled from the shared /setup/download-stream SSE. - */ -export function ModelStoreTab({ info, modelBadge }) { - const { t } = useTranslation(); - const modelsQuery = useModels(); - const recoQuery = useRecommendations(); - const data = modelsQuery.data; - const loading = modelsQuery.isLoading; - const reco = recoQuery.data; - const installMutation = useInstallModel(); - const deleteMutation = useDeleteModel(); - - const [busy, setBusy] = useState(new Set()); // repo_ids currently working - // Per-repo active state. Tracks aggregate download across all files of - // a running install so the row can show a determinate progress bar. - // { [repo_id]: { phase, files: { [filename]: { downloaded, total, pct } }, error } } - const [rowState, setRowState] = useState({}); - const [query, setQuery] = useState(''); - const [installingReco, setInstallingReco] = useState(false); - const [activeRole, setActiveRole] = useState(null); - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const esRef = React.useRef(null); - const tableBodyRef = React.useRef(null); - // Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } } - const speedRef = React.useRef({}); - // Tick counter — forces re-render every second while a download is active - // so speed/ETA displays update smoothly between SSE events. - const [, setTick] = useState(0); - // Boolean derived from rowState so the interval effect below only re-runs - // when activity starts/stops — not on every SSE progress event (several per - // second during installs), which would clear + recreate the 1s tick forever. - const hasActive = useMemo(() => Object.values(rowState).some(s => - ['install_start', 'active', 'delete_start'].includes(s.phase)), [rowState]); - useEffect(() => { - if (!hasActive) return; - const iv = setInterval(() => setTick(t => t + 1), 1000); - return () => clearInterval(iv); - }, [hasActive]); - - // HF token inline — compact input in the toolbar - const [hfToken, setHfToken] = useState(''); - const [hfSaved, setHfSaved] = useState(false); - const [hfSaving, setHfSaving] = useState(false); - const [hfExpanded, setHfExpanded] = useState(false); - const saveHfToken = async () => { - const value = hfToken.trim(); - if (!value) return; - setHfSaving(true); - try { - const { API } = await import('../api/client'); - const res = await fetch(`${API}/system/set-env`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key: 'HF_TOKEN', value }), - }); - if (res.ok) { - toast.success(t('models.hf_token_set_toast')); - setHfSaved(true); - setHfToken(''); - setHfExpanded(false); - } else { - const d = await res.json().catch(() => ({})); - toast.error(d.detail || t('models.hf_token_save_failed')); - } - } catch (e) { toast.error(t('settings.save_failed', { message: e.message })); } - finally { setHfSaving(false); } - }; - const hfTokenSet = hfSaved || info?.has_hf_token; - - // Open the progress stream once when the tab mounts; close on unmount. - useEffect(() => { - const es = new EventSource(setupDownloadStreamUrl()); - esRef.current = es; - es.onmessage = (evt) => { - try { - const ev = JSON.parse(evt.data); - if (!ev?.repo_id) return; - setRowState(prev => { - const cur = prev[ev.repo_id] || { phase: 'active', files: {} }; - // Lifecycle events (install_start/install_done/install_error, - // delete_start/delete_done) flip the row's phase without - // touching per-file accounting. - if (ev.phase === 'install_start' || ev.phase === 'delete_start') { - return { ...prev, [ev.repo_id]: { phase: ev.phase, files: {}, error: null } }; - } - // Heartbeat from backend while resolving repo metadata - if (ev.phase === 'resolving') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 } }; - } - if (ev.phase === 'install_retry') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error } }; - } - if (ev.phase === 'install_done') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_done' } }; - } - if (ev.phase === 'delete_done') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'delete_done' } }; - } - if (ev.phase === 'install_error') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } }; - } - if (ev.phase === 'install_cancelled') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_cancelled' } }; - } - // Pre-flight plan (FDL-05): accurate total/cached/remaining BEFORE - // bytes flow. Keep the current phase (usually resolving) — the plan - // is metadata, not a state change. - if (ev.phase === 'install_plan') { - return { ...prev, [ev.repo_id]: { ...cur, plan: { - total_bytes: ev.total_bytes ?? null, - cached_bytes: ev.cached_bytes ?? null, - to_download_bytes: ev.to_download_bytes ?? null, - n_files: ev.n_files ?? null, - n_cached: ev.n_cached ?? null, - } } }; - } - // Overall aggregate (FDL-06): one rolling event that is the source of - // truth for the overall bar / speed / remaining / ETA. - if (ev.phase === 'aggregate') { - return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', agg: { - bytes_done: ev.bytes_done ?? 0, - total_bytes: ev.total_bytes ?? null, - rate: ev.rate ?? 0, - eta_seconds: ev.eta_seconds ?? null, - files_done: ev.files_done ?? 0, - files_total: ev.files_total ?? null, - } } }; - } - // Per-file tqdm events — aggregate across files. - const files = { ...cur.files, [ev.filename]: { - downloaded: ev.downloaded || 0, - total: ev.total || 0, - pct: ev.pct || 0, - phase: ev.phase, - rate: ev.rate || 0, - }}; - return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', files } }; - }); - } catch { /* keepalive / ignore */ } - }; - return () => es.close(); - }, []); - - // When a lifecycle terminator fires, refresh the list so "installed" - // flips server-side info into the row. - useEffect(() => { - const term = Object.entries(rowState).find(([, s]) => - ['install_done', 'delete_done', 'install_error', 'install_cancelled'].includes(s.phase)); - if (!term) return; - const t = setTimeout(() => { - modelsQuery.refetch(); - recoQuery.refetch(); - // Clear stale speed data for this repo. - delete speedRef.current[term[0]]; - // Clear the terminal entry so the row reverts to the authoritative - // `installed` flag from /models without keeping stale progress. - setRowState(prev => { - const next = { ...prev }; - delete next[term[0]]; - return next; - }); - }, 800); - return () => clearTimeout(t); - }, [rowState, modelsQuery, recoQuery]); - - const reload = useCallback(() => { - modelsQuery.refetch(); - recoQuery.refetch(); - }, [modelsQuery, recoQuery]); - - const withBusy = useCallback(async (repoId, fn, successMsg) => { - setBusy(prev => new Set(prev).add(repoId)); - try { - await fn(); - if (successMsg) toast.success(successMsg); - } catch (e) { - toast.error(e.message || String(e)); - } finally { - setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; }); - } - }, []); - - const onInstall = useCallback((repoId) => - withBusy(repoId, () => installMutation.mutateAsync(repoId), t('models.install_started')), - [installMutation, withBusy]); - const onDelete = useCallback(async (repoId) => { - if (!(await askConfirm(t('models.delete_confirm', { repoId }), t('models.delete_confirm_title')))) return; - return withBusy(repoId, () => deleteMutation.mutateAsync(repoId), t('models.deleted', { repoId })); - }, [deleteMutation, withBusy]); - const onReinstall = useCallback(async (repoId) => { - if (!(await askConfirm(t('models.reinstall_confirm', { repoId }), t('models.reinstall_confirm_title')))) return; - await withBusy(repoId, async () => { - await deleteMutation.mutateAsync(repoId); - await installMutation.mutateAsync(repoId); - }, t('models.reinstalling')); - }, [deleteMutation, installMutation, withBusy]); - - const onInstallRecommended = async () => { - if (!reco) return; - const missing = reco.models.filter(m => !m.installed); - if (missing.length === 0) { - toast.success(t('models.recommended_installed')); - return; - } - setInstallingReco(true); - try { - // Parallel install — backend /models/install spawns each download on - // its own asyncio task so ordering doesn't matter. - await Promise.all(missing.map(m => installMutation.mutateAsync(m.repo_id))); - toast.success(t('models.started_downloading', { count: missing.length })); - } catch (e) { - toast.error(t('models.install_failed', { message: e.message || e })); - } finally { - setInstallingReco(false); - } - }; - - const allModels = React.useMemo(() => data?.models || [], [data]); - const groups = allModels.reduce((acc, m) => { - const k = (m.role || 'other').toLowerCase(); - (acc[k] = acc[k] || []).push(m); - return acc; - }, {}); - const roles = Object.keys(groups).sort((a, b) => { - const ai = MODEL_ROLE_ORDER.indexOf(a), bi = MODEL_ROLE_ORDER.indexOf(b); - return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi); - }); - // 'all' is a virtual role — shows every model regardless of category. - const currentRole = activeRole === 'all' ? 'all' - : activeRole && groups[activeRole] ? activeRole - : 'all'; - - const allInstalled = allModels.filter(m => m.installed).length; - - useEffect(() => { - setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]); - }, [currentRole]); - - const getRowRuntime = React.useCallback((m) => { - const rs = rowState[m.repo_id]; - const rowBusy = busy.has(m.repo_id); - const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error); - const isDeleting = rs?.phase === 'delete_start'; - const phase = rs?.phase; - const fileList = rs?.files ? Object.entries(rs.files) : []; - const totals = fileList.reduce((a, [, f]) => ({ - downloaded: a.downloaded + (f.downloaded || 0), - total: a.total + (f.total || 0), - done: a.done + (f.phase === 'done' ? 1 : 0), - }), { downloaded: 0, total: 0, done: 0 }); - // Sum backend-reported rate from active (non-done) files - const backendRate = fileList - .filter(([, f]) => f.phase !== 'done' && f.rate > 0) - .reduce((s, [, f]) => s + f.rate, 0); - const hasFiles = fileList.length > 0; - const showBar = ['install_start', 'resolving', 'install_retry', 'active', 'delete_start'].includes(phase); - const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0]; - const unsupported = m.supported === false; - - // Overall progress: prefer the backend aggregate (FDL-06) — it sums bytes - // across all parallel files/chunks and samples a windowed rate, which is - // accurate under Xet's parallel fetch. Fall back to per-file summation + - // the frontend speed sampler only until the first aggregate event lands. - const agg = rs?.agg || null; - const plan = rs?.plan || null; - const dispDownloaded = agg ? (agg.bytes_done || 0) : totals.downloaded; - // Denominator: aggregate total → preflight "to download" → per-file totals. - const dispTotal = (agg?.total_bytes ?? plan?.to_download_bytes ?? totals.total) || 0; - // Bar %: prefer byte-fraction, but under Xet the byte bars don't advance - // mid-download (only the file-count bar does), so fall back to the file - // fraction so the bar still moves. Take the max so whichever signal is - // live drives it; complete() flushes both to 100% at the end. - const bytePct = dispTotal > 0 ? (dispDownloaded / dispTotal) * 100 : 0; - const filesTotalForPct = agg?.files_total ?? plan?.n_files ?? 0; - const filePct = filesTotalForPct > 0 ? ((agg?.files_done ?? 0) / filesTotalForPct) * 100 : 0; - const aggPct = (dispTotal > 0 || filesTotalForPct > 0) ? Math.max(bytePct, filePct) : null; - const cachedBytes = plan?.cached_bytes ?? null; - const filesTotal = agg?.files_total ?? plan?.n_files ?? (hasFiles ? fileList.length : null); - const filesDone = agg?.files_done ?? totals.done; - // Backend rate (windowed aggregate) wins over the per-file rate sum. - const aggRate = agg?.rate ?? null; - const aggEtaSec = agg?.eta_seconds ?? null; - - return { - rs, - rowBusy, - isInstalling, - isDeleting, - phase, - fileList, - totals, - hasFiles, - aggPct, - showBar, - activeFilename, - unsupported, - backendRate, - agg, - plan, - dispDownloaded, - dispTotal, - cachedBytes, - filesTotal, - filesDone, - aggRate, - aggEtaSec, - }; - }, [busy, rowState]); - - const columns = React.useMemo(() => [ - { - id: 'name', - accessorFn: m => `${m.label || ''} ${m.repo_id || ''}`, - header: t('models.column_model'), - size: 260, - meta: { className: 'models-row__name' }, - cell: ({ row }) => { - const m = row.original; - const rt = getRowRuntime(m); - return ( - <> - - - {m.repo_id.split('/')[0].slice(0, 2).toUpperCase()} - - {m.label} - {m.required && {t('models.required_tag')}} - - - {m.repo_id} - {m.note && · {m.note}} - - {rt.showBar && ( -
- - - {(() => { - if (rt.isDeleting) return t('models.removing_cached'); - const hasAgg = !!rt.agg; - if (!rt.hasFiles && !hasAgg) { - if (rt.phase === 'resolving') { - const dots = '.'.repeat((rt.rs?.resolvingStep || 0) % 4); - // Once the preflight plan lands we can show the real size - // even before the first byte (FDL-05). - const planBytes = rt.plan?.to_download_bytes; - const planStr = planBytes ? ` · ${fmtBytes(planBytes)} ${t('models.to_download') || 'to download'}` : ''; - return `${t('models.resolving_metadata')}${dots}${planStr}`; - } - if (rt.phase === 'install_retry') { - return t('models.retry_attempt', { attempt: rt.rs?.retryAttempt || '?', error: rt.rs?.error || 'reconnecting' }); - } - return t('models.connecting_hf'); - } - - // Prefer the backend aggregate's windowed rate (FDL-06). - // Fall back to the frontend sampler only until it arrives. - let speed = rt.aggRate ?? 0; - if (!(speed > 0)) { - const sp = speedRef.current[m.repo_id]; - const now = Date.now(); - if (sp && rt.dispDownloaded > 0) { - const dt = (now - sp.lastTime) / 1000; - if (dt >= 1) { - sp.speed = Math.max(0, (rt.dispDownloaded - sp.lastBytes) / dt); - sp.lastBytes = rt.dispDownloaded; - sp.lastTime = now; - } - } else { - speedRef.current[m.repo_id] = { lastBytes: rt.dispDownloaded, lastTime: now, speed: 0 }; - } - speed = rt.backendRate > 0 ? rt.backendRate : (sp?.speed || 0); - } - - // Total unknown and nothing downloaded yet → still resolving - if (rt.dispTotal === 0 && rt.dispDownloaded === 0) { - const activeFile = rt.activeFilename?.split('/').pop(); - return activeFile - ? t('models.resolving_files_active', { count: rt.fileList.length, file: activeFile }) - : t('models.resolving_files', { count: rt.fileList.length }); - } - - // ETA: prefer the backend's, else derive from remaining/speed. - const remaining = Math.max(0, rt.dispTotal - rt.dispDownloaded); - const etaSec = rt.aggEtaSec != null ? rt.aggEtaSec - : (speed > 0 && rt.dispTotal > 0 ? remaining / speed : 0); - const etaStr = etaSec > 0 - ? etaSec < 60 ? `~${Math.ceil(etaSec)}s` - : etaSec < 3600 ? `~${Math.ceil(etaSec / 60)}m` - : `~${(etaSec / 3600).toFixed(1)}h` - : ''; - const dlStr = fmtBytes(rt.dispDownloaded) || '0 B'; - const totalStr = rt.dispTotal > 0 ? fmtBytes(rt.dispTotal) : '…'; - const pctStr = rt.aggPct != null && rt.aggPct > 0 ? `${Math.round(rt.aggPct)}%` : ''; - const speedStr = speed > 0 ? `${fmtBytes(speed)}/s` : ''; - - const parts = [ - `${dlStr} / ${totalStr}`, - pctStr, - speedStr || (rt.dispDownloaded > 0 ? t('models.measuring') : ''), - etaStr, - ].filter(Boolean); - - const extra = []; - if (rt.cachedBytes > 0) extra.push(`${fmtBytes(rt.cachedBytes)} ${t('models.cached') || 'cached'}`); - if (rt.filesTotal > 1) extra.push(t('models.files_progress', { done: rt.filesDone, total: rt.filesTotal })); - if (rt.activeFilename) extra.push(rt.activeFilename.split('/').pop()); - - return extra.length - ? `${parts.join(' · ')} ⸱ ${extra.join(' · ')}` - : parts.join(' · '); - })()} - -
- )} - {rt.phase === 'install_error' && rt.rs?.error && ( - {t('models.install_error', { error: rt.rs.error })} - )} - - ); - }, - }, - { - id: 'role', - accessorFn: m => (m.role || 'other').toLowerCase(), - header: t('models.column_role'), - size: 58, - filterFn: (row, id, value) => !value || row.getValue(id) === value, - cell: ({ row }) => {MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'}, - }, - { - id: 'size', - accessorFn: m => m.installed ? (m.size_on_disk_bytes || 0) : (m.size_gb || 0) * 1024 ** 3, - header: t('models.column_size'), - size: 68, - meta: { align: 'right', className: 'models-row__size' }, - cell: ({ row }) => { - const m = row.original; - const rt = getRowRuntime(m); - // During active download, show live downloaded / total - if (rt.showBar && rt.hasFiles && rt.totals.total > 0) { - return {fmtBytes(rt.totals.downloaded)}/{fmtBytes(rt.totals.total)}; - } - return m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`; - }, - }, - { - id: 'status', - accessorFn: m => m.installed ? 2 : (m.supported === false ? 0 : 1), - header: t('models.column_status'), - size: 96, - meta: { align: 'center', className: 'models-row__status' }, - cell: ({ row }) => { - const m = row.original; - const rt = getRowRuntime(m); - return rt.isInstalling - ? {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : t('models.downloading')} - : rt.isDeleting - ? {t('models.deleting')} - : rt.rowBusy - ? {t('models.working')} - : m.installed - ? {t('models.installed')} - : rt.unsupported - ? {(m.platforms || []).join(', ')} - : {t('models.not_installed')}; - }, - }, - { - id: 'actions', - header: '', - size: 90, - enableSorting: false, - meta: { align: 'right', className: 'models-row__actions' }, - cell: ({ row }) => { - const m = row.original; - const rt = getRowRuntime(m); - return ( - <> - - {!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && ( - - )} - {m.installed && !rt.rowBusy && !rt.isDeleting && ( - <> - - - - )} - - ); - }, - }, - ], [getRowRuntime, onDelete, onInstall, onReinstall, t]); - - const table = useReactTable({ - data: allModels, - columns, - getRowId: row => row.repo_id, - state: { - sorting, - globalFilter: query, - columnFilters, - }, - onSortingChange: setSorting, - onGlobalFilterChange: setQuery, - onColumnFiltersChange: setColumnFilters, - globalFilterFn: (row, _columnId, value) => { - const q = String(value || '').trim().toLowerCase(); - if (!q) return true; - const m = row.original; - return [m.repo_id, m.label, m.note, m.role] - .filter(Boolean) - .some(v => String(v).toLowerCase().includes(q)); - }, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - - const tableRows = table.getRowModel().rows; - const rowVirtualizer = useVirtualizer({ - count: tableRows.length, - getScrollElement: () => tableBodyRef.current, - estimateSize: () => 68, - overscan: 8, - }); - - if (loading && !data) { - return ( - -
{t('common.loading')}
-
- ); - } - if (!data) return null; - - return ( -
-
-
- {fmtBytes(data.total_installed_bytes)} - · - {data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')} - {info && ·} - {info && {modelBadge}} - {info?.fast_download?.xet_enabled && ( - <> - · - - ⚡ {t('models.fast_download_badge') || 'fast download'} - - - )} -
-
- {/* Compact HF token inline */} - {!hfTokenSet && !hfExpanded && ( - - )} - {!hfTokenSet && hfExpanded && ( -
- setHfToken(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') saveHfToken(); if (e.key === 'Escape') setHfExpanded(false); }} - autoFocus - /> - - { e.preventDefault(); openExternal('https://huggingface.co/settings/tokens'); }} - title="Open huggingface.co/settings/tokens" - > - {t('models.get_token')}→ - -
- )} - {hfTokenSet && ( - - )} - -
-
- - {reco && reco.all_installed && ( -
- - {t('models.reco_installed_for', { device: reco.device.label })} - {reco.total_gb} GB -
- )} - {reco && !reco.all_installed && ( -
-
- {t('models.reco_for', { device: reco.device.label })} -
- {(() => { - const requiredMissing = reco.models.filter(m => m.required && !m.installed); - const requiredGb = requiredMissing.reduce((s, m) => s + m.size_gb, 0); - if (requiredMissing.length === 0) return null; - return ( - - ); - })()} - -
-
-
- {reco.models.map(m => ( - - {m.installed ? '✓' : '○'} {m.label} - {m.size_gb} - {m.required && {t('models.req_tag')}} - - ))} -
-
- )} - -
- { - const installed = groups[r].filter(m => m.installed).length; - return { - value: r, - label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`, - }; - }), - ]} - /> - setQuery(e.target.value)} - aria-label={t('models.search_label')} - /> -
- - -
- {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => { - const meta = header.column.columnDef.meta || {}; - const canSort = header.column.getCanSort(); - return ( - - ); - })} - - ))} -
-
-
- {rowVirtualizer.getVirtualItems().map(virtualRow => { - const row = tableRows[virtualRow.index]; - const m = row.original; - const rt = getRowRuntime(m); - return ( -
- {row.getVisibleCells().map(cell => { - const meta = cell.column.columnDef.meta || {}; - return ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ); - })} -
- ); - })} - {tableRows.length === 0 && ( -
{t('models.no_matches')}
- )} -
-
-
-
- ); -} - - -export function EnginesTab() { - const { t } = useTranslation(); - const reviewMode = useAppStore(s => s.reviewMode); - const setReviewMode = useAppStore(s => s.setReviewMode); - - // Plan 02-04 / ENGINE-06 — engine selection is wired through the - // matrix component's optional onSelect callback so the matrix doubles - // as a picker. Keeps a single source of truth for the engine list + - // its install / GPU / isolation state. - const onSelect = useCallback(async (family, backendId) => { - try { - addBreadcrumb(`engine:${family}=${backendId}`); - const r = await selectEngine(family, backendId); - toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active })); - } catch (e) { - toast.error(e.message || t('engines.switch_failed')); - } - }, []); - - return ( -
-
-
- - · - - {reviewMode === 'on' ? t('engines.banners_on') : t('engines.banners_off')} - -
-
- - -
- ); -} - - -const isTauri = () => _isTauri; - -// Tauri v2's webview disables native window.confirm/alert — they return -// false silently, making Delete/Reinstall buttons appear dead. Route through -// the dialog plugin when running in Tauri, fall back to browser confirm -// elsewhere (vite dev, tests). -async function askConfirm(message, title = 'Confirm') { - if (isTauri()) { - const { ask } = await import('@tauri-apps/plugin-dialog'); - return ask(message, { title, kind: 'warning' }); - } - return Promise.resolve(window.confirm(message)); -} - export default function Settings() { const { t } = useTranslation(); // One-shot deep-link: a caller (e.g. the footer version badge → Updates) can @@ -1453,58 +354,15 @@ export default function Settings() { {activeTab === 'credentials' && } {activeTab === 'logs' && ( - - - - - - } - > - ({ ...d, label: t(`common.${d.key}`) }))} - value={logSource} - onChange={setLogSource} - /> - -
- {logMeta.path || '—'} - {logSource === 'tauri' && !logMeta.exists && ( - - {t('logs.no_tauri_log')} - - )} -
-
- {logs.length === 0 - ? - {logSource === 'frontend' - ? t('logs.empty_frontend') - : logSource === 'tauri' - ? t('logs.empty_tauri') - : t('logs.empty_backend')} - - : logs.join('')} -
-
+ )} {activeTab === 'updates' && ( @@ -1514,456 +372,28 @@ export default function Settings() { )} {activeTab === 'about' && ( - - - - - - - - - {t('about.yes')} - : {t('about.no')}} /> - - - {status?.status || 'unknown'}} /> - - - - - - - - {/* Auto-updater + channel toggle are desktop-only (Tauri). The Docker - web build updates by pulling a new image tag, so hide these rows - there to avoid a non-functional control (issue #249). */} - {isTauri() && ( - <> - - } - /> - - - )} -
- {isTauri() && ( - - )} - - - - - - -
- {selfCheck && ( -
- {selfCheck.checks.map((c) => ( - - - {c.status === 'ok' - ? - : } {t(`about.self_check_${c.status}`)} - - {' '}{c.detail} - {c.hint && — {c.hint}} - - } - /> - ))} -

- {selfCheck.summary.ok - ? t('about.self_check_healthy') - : t('about.self_check_attention', { count: selfCheck.summary.failures })} -

-
- )} -
+ )} - {activeTab === 'privacy' && ( - -

- }} /> -

- - - {t('privacy.local_sqlite')}} /> - {t('privacy.translator_online', { provider: info.translate_provider })} - : {t('privacy.translator_offline')} - } - /> - {t('privacy.no_tracking')}} - /> -
- )} + {activeTab === 'privacy' && } ); } -// ── Credentials Tab ─────────────────────────────────────────────────────── - -// Flat field list rendered by CredentialsTab. HF_TOKEN is handled by -// (3-source cascade) and filtered out of this loop; the -// rest are session/persisted env keys set via /system/set-env. labelKey/ -// helpKey are i18n keys; placeholderKey holds a literal example value -// (URL / token shape) rendered as-is, matching the original design. -const CREDENTIAL_FIELDS = [ - { key: 'HF_TOKEN', labelKey: 'credentials.hf_token', placeholderKey: 'hf_xxxxxxxxxxxx', - helpKey: 'credentials.hf_help', link: 'https://huggingface.co/settings/tokens', isPassword: true }, - { key: 'TRANSLATE_API_KEY', labelKey: 'credentials.translate_key', placeholderKey: 'API key', - helpKey: 'credentials.translate_help', isPassword: true }, - { key: 'TRANSLATE_BASE_URL', labelKey: 'credentials.llm_base_url', placeholderKey: 'https://api.openai.com/v1', - helpKey: 'credentials.llm_base_url_help' }, - { key: 'TRANSLATE_MODEL', labelKey: 'credentials.llm_model', placeholderKey: 'gpt-4o', - helpKey: 'credentials.llm_model_help' }, - { key: 'DEEPL_API_KEY', labelKey: 'credentials.deepl_key', placeholderKey: 'DeepL API key', - helpKey: 'credentials.deepl_key', isPassword: true }, - { key: 'DEEPL_BASE_URL', labelKey: 'credentials.deepl_base_url', placeholderKey: 'https://api.deepl.com/v2', - helpKey: 'credentials.deepl_base_url_help' }, - { key: 'MICROSOFT_API_KEY', labelKey: 'credentials.microsoft_key', placeholderKey: 'Microsoft API key', - helpKey: 'credentials.microsoft_key', isPassword: true }, - { key: 'MICROSOFT_BASE_URL', labelKey: 'credentials.microsoft_base_url', placeholderKey: 'https://api.cognitive.microsofttranslator.com', - helpKey: 'credentials.microsoft_base_url_help' }, -]; - -// Convert a KeyboardEvent into a tauri-plugin-global-shortcut accelerator -// string, e.g. "CmdOrCtrl+Shift+Space". Returns null when only modifiers -// are held (the user hasn't picked a "real" key yet). -function keyEventToAccelerator(e) { - const isMacLike = typeof navigator !== 'undefined' - && /Mac|iPad|iPhone|iPod/.test(navigator.platform || ''); - const mods = []; - if (e.metaKey) mods.push(isMacLike ? 'Cmd' : 'Super'); - if (e.ctrlKey) mods.push('Ctrl'); - if (e.altKey) mods.push('Alt'); - if (e.shiftKey) mods.push('Shift'); - - // e.code is the physical key — already in the shape tauri expects for - // Letter/Digit/Function keys ("KeyA", "Digit1", "F5"). Strip the prefix - // so we get "A" / "1" / "F5" which matches the accelerator grammar. - let key = e.code; - if (!key) return null; - if (key.startsWith('Key')) key = key.slice(3); - else if (key.startsWith('Digit')) key = key.slice(5); - // Skip pure modifier keys — we want the user to pick a real trigger. - if (/^(Meta|Control|Alt|Shift|OS)(Left|Right)?$/.test(key)) return null; - - if (mods.length === 0) return null; - return [...mods, key].join('+'); -} - -function HotkeyTab() { - const { t } = useTranslation(); - const [current, setCurrent] = useState(''); - const [recording, setRecording] = useState(false); - const [pending, setPending] = useState(''); - const [saving, setSaving] = useState(false); - const tauri = isTauri(); - - // Load the saved shortcut on mount. - useEffect(() => { - if (!tauri) return; - (async () => { - try { - const { invoke } = await import('@tauri-apps/api/core'); - const v = await invoke('get_dictation_shortcut'); - setCurrent(v || ''); - } catch (e) { - toast.error(t('settings.shortcut_load_failed', { message: e?.message || e })); - } - })(); - }, [tauri]); - - // While recording, swallow keystrokes globally and convert the next real - // press into an accelerator string. Escape cancels. - useEffect(() => { - if (!recording) return; - const onKeyDown = (e) => { - e.preventDefault(); - e.stopPropagation(); - if (e.key === 'Escape') { - setRecording(false); - setPending(''); - return; - } - const accel = keyEventToAccelerator(e); - if (accel) { - setPending(accel); - setRecording(false); - } - }; - window.addEventListener('keydown', onKeyDown, true); - return () => window.removeEventListener('keydown', onKeyDown, true); - }, [recording]); - - const save = async () => { - if (!pending || pending === current) return; - setSaving(true); - try { - const { invoke } = await import('@tauri-apps/api/core'); - const saved = await invoke('set_dictation_shortcut', { accelerator: pending }); - setCurrent(saved); - setPending(''); - toast.success(t('settings.shortcut_set', { shortcut: saved })); - } catch (e) { - // Common cause: the OS or another app already owns the combo. Surface - // the raw error so the user can pick something else. - toast.error(t('settings.shortcut_register_failed', { message: e?.message || e })); - } finally { - setSaving(false); - } - }; - - const resetDefault = async () => { - setSaving(true); - try { - const { invoke } = await import('@tauri-apps/api/core'); - const saved = await invoke('set_dictation_shortcut', { - accelerator: 'CmdOrCtrl+Shift+Space', - }); - setCurrent(saved); - setPending(''); - toast.success(t('settings.shortcut_reset')); - } catch (e) { - toast.error(t('settings.shortcut_reset_failed', { message: e?.message || e })); - } finally { - setSaving(false); - } - }; - - return ( - - {!tauri && ( -

- }} /> -

- )} - - - , 2: }} />} - control={recording ? t('capture.listening') : (pending || '—')} - mono - /> - -
- - - -
-
- ); -} - -function CredentialsTab({ info }) { - const { t } = useTranslation(); - const [values, setValues] = useState({}); - const [saving, setSaving] = useState(null); - const [saved, setSaved] = useState({}); - - const save = async (key) => { - const value = (values[key] || '').trim(); - if (!value) return; - setSaving(key); - try { - const { API } = await import('../api/client'); - const res = await fetch(`${API}/system/set-env`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value }), - }); - if (res.ok) { - toast.success(t('credentials.saved_session', { key })); - setSaved(prev => ({ ...prev, [key]: true })); - setValues(prev => ({ ...prev, [key]: '' })); - } else { - const d = await res.json().catch(() => ({})); - toast.error(d.detail || t('credentials.save_failed')); - } - } catch (e) { - toast.error(t('credentials.save_error', { message: e.message })); - } finally { - setSaving(null); - } - }; - - return ( - - {/* Wave 2 AUTH-03 panel — 3-source cascade with Active badge, - encrypted-at-rest App-source storage, and live whoami status. */} - - - {/* Wave 2.4 — OpenAI-compatible LLM endpoint (Ollama/LM Studio/vLLM). */} - - - -

- }} /> -

- {CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => ( - - {t(field.labelKey)} - {field.key === 'HF_TOKEN' && ( - - {info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')} - - )} - - } - note={ - <> - {t(field.helpKey)} - {field.link && ( - <> { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')} - )} - - } - control={ - <> - setValues(prev => ({ ...prev, [field.key]: e.target.value }))} - onKeyDown={e => e.key === 'Enter' && save(field.key)} - /> - - - } - /> - ))} -
-
- ); -}