feat: first-run setup screen — install mode, storage choice + minimum-space gate, mirrors, compute (#286)

* feat(setup): first-run install gate — nothing installs until the user confirms a plan

New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead
of auto-installing. complete_setup validates the user's InstallPlan and only
then starts the existing bootstrap:

- install modes: installed (platform dirs) / portable (one folder next to
  the exe / AppImage, config.json travels with it)
- user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache
  (OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior
- minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by
  filesystem so dirs sharing a disk sum their requirements; install refused
  when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom)
- custom mirrors (PyPI index, HF endpoint, python-build-standalone) take
  precedence over region presets in the venv/sync/backend env wiring
- ROCm torch variant selectable via config (env var still wins)
- existing installs migrate silently: venv present → setup_complete=true,
  no questions re-asked; dev trees skip the gate entirely

19 unit tests (disk probing, space grouping, mirror validation, legacy
config compat).

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

* feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute

FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded;
regular launches pay nothing). One screen, defaults all work:

- language picker first (rest re-renders translated), 21 locales shipped
- Installed / Portable mode cards (portable disabled with reason when the
  exe-adjacent folder isn't writable)
- storage rows with live per-path free-space probes (debounced
  check_install_target), 'needs ~X / Y free' readouts, folder pickers
- client mirrors the Rust per-volume space gate: Start installation is
  disabled with an explicit reason until every volume fits
- compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs
- complete_setup errors surface inline; on success the normal bootstrap
  progress UI takes over on the next status poll

Verified on a wiped machine: gate parks (no spawn, no downloads), screen
renders, 450 GB ≥ 17 GB requirement → Start enabled.

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

* feat(setup): studio-console redesign of the first-run screen

The setup screen now reads as powering on studio hardware rather than a web
form — true to a voice studio, and self-sufficient offline (every font and
asset is bundled; a first run may be on a restricted network):

- breathing waveform masthead (CSS-only, deterministic speech-cadence
  silhouette, staggered per-bar delays)
- Source Serif 4 display headline + engraved IBM Plex Mono panel labels +
  Inter body — the three faces the app already ships
- rack-unit panels with corner screws, engraved title rules, serial plate
  (OVS · vX.Y.Z)
- disk space as segmented LED capacity meters: lit = what the install
  consumes, alarm-blink red on insufficient volumes
- mode cards with indicator LEDs; 'armed' Start button — LED lights and a
  halo pulses only once every volume passes the space gate
- atmosphere: corner accent glows + SVG film grain; staggered rise-in
  choreography on load
- all motion transform/opacity only; prefers-reduced-motion holds every
  frame still; theme-token derived colors; focus-visible rings throughout

No logic changes: same IPC calls, same i18n keys, same space-gate math.

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

* feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards

Three pieces of feedback addressed:

- width: the console is now a 1240px two-column deck (storage rail left,
  decision rail right) that uses desktop real estate; collapses to one
  column under 980px and stacks fully under 620px
- no outer chassis box: panels float directly on the atmospheric backdrop,
  each carrying its own rack-unit treatment
- Compute and Update channel split into separate cards with real
  information: get_setup_state now detects hardware (nvidia-smi → CUDA
  name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS,
  CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute
  card shows a live 'Detected: …' readout, badges the option that matches
  the machine, and pre-selects ROCm on AMD boxes; both cards use LED
  radio options with full descriptions (6 new i18n keys × 21 locales)

Also pins playwright-core as an explicit devDep — bun did not materialize
it through @playwright/test, breaking programmatic browser use.

20/20 Rust tests · vite build · CJK guard green. Verified live (gate
engaged, responsive single-column) and at 1600×1000 via mocked-IPC
browser shot (two-column deck).

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

* feat(setup): move network (region + mirrors) into the masthead with language

Language and download region are the two 'where am I' choices — they now
sit together top-right of the masthead, with the custom-mirrors disclosure
tucked beneath the subtitle. The Network panel is gone, leaving a balanced
deck: Install mode + Storage left, Compute + Update channel right.

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

* feat(setup): strip the boxes — fills and rules carry the structure

One design rule now: borders only where state demands them. Panels lose
their boxes entirely (engraved mono title + rule separates sections);
option cards, storage rows, selects/inputs, the hw readout, the version
plate and the ghost buttons are all flat fills; active options glow with
an accent tint + LED; blocked rows and errors use a red tint + 2px inset
edge bar instead of a border. The badge chip is fill-only too.

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

* feat(setup): quiet pass — every element earns its visual weight

- waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an
  ambient signature instead of a billboard
- storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free');
  the LED meter now appears only when it carries information (install
  would consume >35% of free space, or the volume is blocked) — at 449 GB
  free a bar was a meaningless sliver
- Change… buttons go text-quiet (transparent until hover)
- custom-mirrors disclosure right-aligns under the region select it
  extends, instead of floating under the subtitle
- version plate moves to the footer next to the disk total — the masthead
  keeps only title, subtitle, and the two locale/region selects

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

* feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash

The install matrix is OS family × distro × arch × GPU vendor, and the
setup screen now both shows it and only offers choices valid for it:

- HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on
  Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected
  line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM',
  exactly what bug reports cite
- SetupState gains os; the ROCm option renders on Linux only (wheels
  don't exist elsewhere) and complete_setup clamps rocm→auto on
  non-Linux as the server-side backstop
- nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on
  the first screen a user ever sees
- Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix
  cells resolve through the same base constructor

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

* feat(setup): unify the whole first-run journey under the studio-console system

Setup → Installing → Model wizard now read as one continuous experience:
the same atmosphere, whisper waveform masthead, serif/mono type, LED
language and quiet fills across all three acts.

- Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey
  meter (completed steps + live byte progress), LED step rail (done=green,
  active=pulsing accent, pending=dim), engraved ACTIVITY panel with the
  quiet mono log (collapse/copy as text-quiet actions), failure act with
  red-tint error + hints + armed Retry. All logic untouched: stage poll,
  event subscription + backfill, dedupe, hints, region/language selects.
- Model wizard (SetupWizard): same masthead with the step rail as engraved
  mono LED steps top-right, welcome cards as option-card surfaces,
  preflight as LED check rows (pass/warn/fail), frs nav buttons with armed
  primaries, embedded Model Store / Engines / Dictation panels scroll
  inside the act. Old 556-line stylesheet replaced by ~60 lines of glue;
  BootstrapSplash.css reduced to a resolving stub.
- FirstRunSetup.css is now the journey's shared design system (step rails,
  log panel, banners, hints, wizard chrome, check rows appended).
- 2 new strings (Installing / Activity) translated across all 21 locales.

Validated end-to-end on this machine: setup screen → Start installation →
real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard.

20/20 Rust tests · vite build · CJK guard green · installing act verified
via mocked-IPC screenshot at stage=installing_deps.

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

* feat(setup): --setup re-entry flag + make the install-plan screen un-stealable

The setup stage is first-run-only by design (completed installs skip it),
but it must be reachable on demand and must actually win the mount when
engaged. Three fixes:

- 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any
  launch — checked before the attach-to-healthy-backend shortcut, so a
  running backend can't skip past it
- App routing: awaiting_setup now outranks everything (a live backend
  answering /setup/status used to route straight to the model wizard);
  the wizard additionally requires stage === 'ready' so it can't mount
  during the initial stage race
- useBootstrapStage: a transient IPC miss no longer permanently declares
  'ready' (which killed the poll loop and silently skipped the setup /
  progress screens) — it retries up to 5 ticks before conceding

Plus journey-wide titlebar clearance (content never sits under the GTK
headerbar / macOS traffic lights / Windows controls) and drag-region
mastheads on all three acts.

Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend
answering /setup/status renders the setup screen, not the wizard.

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

* style(setup): remove backdrop decoration — flat surface, state-only emphasis

The corner accent glows and SVG film grain rendered as visible banding /
noise artifacts on many panels — both gone; the journey now sits on a
clean flat chrome background. Also swept the remaining decorative bloom:
the active option card drops its glow shadow (flat accent tint + LED carry
the state), and the armed Start button loses its pulsing halo (the lit LED
already signals actionable). Remaining shadows are functional micro-detail
only: 6px LED glows, meter track inset, red edge bars.

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

* feat(setup): journey rail + verbosity diet — clean, smooth, elegant

The setup page is now visibly stage 1 of the install flow: a quiet
breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the
waveform and the headline on both the setup and installing acts, LEDs
marking done/active/pending — one continuous story across the journey.

Verbosity halved without hiding information:
- option descriptions unfold (260ms ease) only on the selected card; the
  page shows exactly one explanation per group, collapsed cards keep the
  text as a tooltip
- storage rows drop their always-on caption (label + path + readout +
  Change… on one line; caption lives in the row tooltip)

The whole page now fits a laptop window without scrolling.

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

* feat(setup): merge Models + Engines into one wizard act

Two tabs weren't necessary: models are the required gate, engines the
optional extras — now two stacked panels in a single 'Models & engines'
step (label reuses the journey-rail key, translated in 21 locales).
Wizard shrinks to 4 steps: Welcome → System check → Models & engines →
Dictation. Continue still gates on models_ready only; engines stay
optional. Welcome cards updated to the 3 remaining acts; static cards
keep their descriptions visible (the active-only fold is for radios).

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

* fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready

The models-needed probe started at mount with a ~30s retry ceiling. On a
first run, mount happens at the setup page — by the time the user reads
it and the multi-minute install finishes, the attempts were long burned,
so setupChecked landed as 'no wizard needed' and the studio rendered with
zero models on disk. The probe is now keyed on bootstrapStage and runs
when it hits 'ready' — the first moment a backend exists to answer.
Normal launches (backend up quickly) behave exactly as before.

Caught by running the full journey three times end-to-end: rounds 2–3
skipped Models & engines after install; with the fix the wizard mounts
with models_ready=false (Whisper large-v3 listed missing).

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

* feat(setup): drop the Welcome step — wizard opens on System check

The welcome act had nothing left to say: the journey rail names the
stages, the setup page already oriented the user, and the cards repeated
both. The wizard is now three steps — System check (auto-runs on mount) →
Models & engines → Try dictation — landing the user directly on live
preflight results instead of a page about the pages to come.

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

* feat(setup): true unified library — models + engines as ONE list

'Merge them' meant one list, not two panels stacked — fair criticism.
The wizard's Models & engines act is now a purpose-built WizardLibrary:
every installable is a row of the same grammar (LED · name · chip ·
size · action):

- required models lead (REQUIRED chip, Download action, live SSE
  progress bar + percent, green LED when installed) — they gate continue
- TTS engines follow (ENGINE chip): active engine glows accent,
  available ones offer one-click Use (selectEngine), heavy installs
  defer honestly to Settings ('install later in Settings' + reason
  tooltip)
- the optional-model tail folds behind 'Show N optional models'

The full management surface (search, HF token, deletes, sorting) stays
in Settings — a first run needs a checklist, not a store. 9 new strings
× 21 locales. Verified against the live backend via the browser harness:
required/installed/engine/active/Use/defer states all render in one list.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-07 20:50:16 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent abae6e290a
commit 43884e54a6
38 changed files with 4590 additions and 1293 deletions
+2 -1
View File
@@ -15,7 +15,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.3.0",
"version": "0.3.5",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -67,6 +67,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright-core": "1.60.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
+1
View File
@@ -69,6 +69,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright-core": "1.60.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
+12 -1
View File
@@ -1297,6 +1297,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -2882,6 +2892,7 @@ version = "0.3.5"
dependencies = [
"dirs-next",
"enigo",
"fs4",
"libc",
"log",
"reqwest",
@@ -3391,7 +3402,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
+3
View File
@@ -46,6 +46,9 @@ reqwest = { version = "0.13", features = ["json"] }
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
# hf_cache_scan: walk HF cache directory 3-5× faster than Python
walkdir = "2"
# First-run setup screen: per-path free-disk-space probe (statvfs /
# GetDiskFreeSpaceExW) for the minimum-storage install gate
fs4 = "0.13"
# Cross-platform home/config directories for pill autostart registration
dirs-next = "2"
+20 -4
View File
@@ -239,12 +239,28 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
env.push(("HF_HUB_DISABLE_SYMLINKS_WARNING".into(), "1".into()));
env.push(("HF_HUB_DISABLE_SYMLINKS".into(), "1".into()));
}
// HF endpoint precedence: process env (power user) > setup-screen custom
// mirror > region preset.
let cfg = load_config(app);
if let Ok(hf_ep) = std::env::var("HF_ENDPOINT") {
env.push(("HF_ENDPOINT".into(), hf_ep));
} else {
let cfg = load_config(app);
if cfg.region == "china" {
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
} else if let Some(hf_mirror) = cfg.mirrors.hf_endpoint.as_deref() {
env.push(("HF_ENDPOINT".into(), hf_mirror.into()));
} else if cfg.region == "china" {
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
}
// Storage layout chosen on the setup screen. Unset (None) means platform
// default — we deliberately don't set the env vars then, so legacy
// installs keep byte-identical behavior. Process env still wins so a
// power user can relocate per-launch.
if std::env::var("OMNIVOICE_DATA_DIR").is_err() {
if let Some(data_dir) = crate::setup::resolved_data_dir(app) {
env.push(("OMNIVOICE_DATA_DIR".into(), data_dir.to_string_lossy().into()));
}
}
if std::env::var("OMNIVOICE_CACHE_DIR").is_err() {
if let Some(models_dir) = crate::setup::resolved_models_dir(app) {
env.push(("OMNIVOICE_CACHE_DIR".into(), models_dir.to_string_lossy().into()));
}
}
let app_data = app.path().app_local_data_dir().unwrap_or_default();
+57 -31
View File
@@ -19,6 +19,11 @@ use crate::{BackendState, backend_port};
#[derive(Clone, Serialize, Debug)]
#[serde(tag = "stage", rename_all = "snake_case")]
pub enum BootstrapStage {
/// First run with nothing installed: parked on the setup screen waiting
/// for the user to confirm an install plan (mode, storage, mirrors).
/// Nothing downloads or installs in this stage — `complete_setup` is the
/// only way out of it.
AwaitingSetup,
/// Working out whether we need to bootstrap at all.
Checking,
/// Fetching the standalone `uv` binary from astral-sh/uv releases.
@@ -196,12 +201,12 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
#[tauri::command]
pub fn clean_and_retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
if let Ok(data_dir) = app.path().app_local_data_dir() {
let project_dir = data_dir.join("project");
if project_dir.is_dir() {
log::info!("Clean retry: removing {}", project_dir.display());
let _ = fs::remove_dir_all(&project_dir);
}
// env_root honors the setup-screen choice (portable / custom env dir), so
// clean-retry removes the venv the bootstrap actually uses.
let project_dir = crate::setup::env_root(&app).join("project");
if project_dir.is_dir() {
log::info!("Clean retry: removing {}", project_dir.display());
let _ = fs::remove_dir_all(&project_dir);
}
// Kill any zombie backend still occupying the port from the deleted
// project dir, otherwise bootstrap will "attach" to the stale process.
@@ -320,11 +325,14 @@ fn rocm_torch_reinstall_args(rocm_index_url: &str) -> Vec<String> {
]
}
/// Whether the user opted into the AMD ROCm torch build via
/// OMNIVOICE_TORCH_VARIANT=rocm. Default (unset/other) → false (CUDA/CPU path
/// unchanged). Returns the ROCm wheel index to use when enabled.
fn rocm_opt_in() -> Option<String> {
let variant = std::env::var("OMNIVOICE_TORCH_VARIANT").ok()?;
/// Whether the user opted into the AMD ROCm torch build via the
/// OMNIVOICE_TORCH_VARIANT env var (power users, takes precedence) or the
/// setup screen's Compute choice persisted in config (`configured_variant`).
/// Default (unset/"auto") → None (CUDA/CPU path unchanged). Returns the ROCm
/// wheel index to use when enabled.
fn rocm_opt_in(configured_variant: &str) -> Option<String> {
let variant = std::env::var("OMNIVOICE_TORCH_VARIANT")
.unwrap_or_else(|_| configured_variant.to_string());
if !variant.eq_ignore_ascii_case("rocm") {
return None;
}
@@ -355,7 +363,9 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
}
}
let app_data = app.path().app_local_data_dir().ok()?;
// Root chosen on the setup screen: app_local_data_dir by default, the
// exe-adjacent folder in portable mode, or a user-picked custom dir.
let app_data = crate::setup::env_root(app);
let project_dir = app_data.join("project");
let venv_dir = project_dir.join(".venv");
let venv_py = venv_python_path(&venv_dir);
@@ -585,17 +595,26 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
set_stage(p, BootstrapStage::CreatingVenv);
}
// plan-03 (#130): mirror cascade + system-Python fallback so first-run
// survives a GitHub-blocked network. Try in order: (1) default GitHub host,
// survives a GitHub-blocked network. Try in order: (0) the user's custom
// mirror from the setup screen, when set, (1) default GitHub host,
// (2) gh-proxy mirror, (3) system Python (only if >= 3.11) — each with
// longer timeouts/retries. Stop at the first that succeeds.
let mut venv_attempts: Vec<(&str, Vec<&str>, Vec<(&str, &str)>)> = vec![
("default", vec!["venv", "--python", "3.11", "--managed-python"], vec![]),
(
"gh-proxy mirror",
let user_cfg = crate::config::load_config(app);
let custom_mirrors = user_cfg.mirrors.clone();
let mut venv_attempts: Vec<(&str, Vec<&str>, Vec<(&str, String)>)> = Vec::new();
if let Some(custom_py_mirror) = custom_mirrors.python_downloads.clone() {
venv_attempts.push((
"custom mirror (setup screen)",
vec!["venv", "--python", "3.11", "--managed-python"],
vec![("UV_PYTHON_INSTALL_MIRROR", PY_INSTALL_MIRROR)],
),
];
vec![("UV_PYTHON_INSTALL_MIRROR", custom_py_mirror)],
));
}
venv_attempts.push(("default", vec!["venv", "--python", "3.11", "--managed-python"], vec![]));
venv_attempts.push((
"gh-proxy mirror",
vec!["venv", "--python", "3.11", "--managed-python"],
vec![("UV_PYTHON_INSTALL_MIRROR", PY_INSTALL_MIRROR.to_string())],
));
// Always try the system Python as the LAST resort (mirrors blocked too).
// No `--python 3.11` pin and no pre-gate: uv's own interpreter discovery is
// the authority — with `only-system` + the project's `requires-python =
@@ -606,7 +625,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
venv_attempts.push((
"system-python",
vec!["venv"],
vec![("UV_PYTHON_PREFERENCE", "only-system")],
vec![("UV_PYTHON_PREFERENCE", "only-system".to_string())],
));
let mut venv_ok = false;
@@ -614,7 +633,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
let mut venv_cmd = Command::new(&uv_path);
scrub_python_env(&mut venv_cmd); // #144: don't inherit AppImage's bundled Python
apply_uv_http_env(&mut venv_cmd);
for &(k, v) in envs {
for (k, v) in envs {
venv_cmd.env(k, v);
}
venv_cmd.args(args.iter()).current_dir(&project_dir);
@@ -647,8 +666,10 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
.args(["sync", "--no-dev", "--verbose"])
.current_dir(&project_dir);
}
let effective_region = get_effective_region(app);
if effective_region == "china" {
// PyPI index precedence: explicit setup-screen mirror > region preset.
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
sync_cmd.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
sync_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
let sync_status = run_streaming(app, "installing_deps", &mut sync_cmd);
@@ -705,8 +726,8 @@ docs/install/troubleshooting.md).",
// OMNIVOICE_TORCH_VARIANT=rocm, reinstall torch/torchaudio from the ROCm
// wheel index. Non-fatal: a failure keeps the working CUDA/CPU build rather
// than breaking first-run. Default (unset) leaves everything unchanged.
if let Some(rocm_url) = rocm_opt_in() {
log::info!("OMNIVOICE_TORCH_VARIANT=rocm → reinstalling torch from {}", rocm_url);
if let Some(rocm_url) = rocm_opt_in(&user_cfg.torch_variant) {
log::info!("ROCm torch variant selected → reinstalling torch from {}", rocm_url);
let mut rocm_cmd = Command::new(&uv_path);
scrub_python_env(&mut rocm_cmd); // #144: don't inherit AppImage's bundled Python
apply_uv_http_env(&mut rocm_cmd);
@@ -775,21 +796,26 @@ mod tests {
}
#[test]
fn rocm_opt_in_gates_strictly_on_the_env_var() {
fn rocm_opt_in_gates_on_env_var_or_config() {
// This test owns OMNIVOICE_TORCH_VARIANT / _INDEX for its duration; no
// other test reads them.
std::env::remove_var("OMNIVOICE_TORCH_VARIANT");
std::env::remove_var("OMNIVOICE_TORCH_INDEX");
assert!(rocm_opt_in().is_none(), "unset → no ROCm (default CUDA/CPU path)");
assert!(rocm_opt_in("auto").is_none(), "unset+auto → no ROCm (default CUDA/CPU path)");
assert_eq!(
rocm_opt_in("rocm").as_deref(),
Some(ROCM_TORCH_INDEX),
"setup-screen config alone opts in"
);
std::env::set_var("OMNIVOICE_TORCH_VARIANT", "cuda");
assert!(rocm_opt_in().is_none(), "non-rocm value → no ROCm");
assert!(rocm_opt_in("rocm").is_none(), "env var wins over config (explicit non-rocm)");
std::env::set_var("OMNIVOICE_TORCH_VARIANT", "ROCm");
assert_eq!(rocm_opt_in().as_deref(), Some(ROCM_TORCH_INDEX), "case-insensitive opt-in → default index");
assert_eq!(rocm_opt_in("auto").as_deref(), Some(ROCM_TORCH_INDEX), "case-insensitive env opt-in → default index");
std::env::set_var("OMNIVOICE_TORCH_INDEX", "https://example.test/rocm6.3");
assert_eq!(rocm_opt_in().as_deref(), Some("https://example.test/rocm6.3"), "index override honored");
assert_eq!(rocm_opt_in("auto").as_deref(), Some("https://example.test/rocm6.3"), "index override honored");
std::env::remove_var("OMNIVOICE_TORCH_VARIANT");
std::env::remove_var("OMNIVOICE_TORCH_INDEX");
+124 -6
View File
@@ -43,12 +43,64 @@ pub struct AppConfig {
/// stable on every launch.
#[serde(default = "default_update_channel")]
pub update_channel: String,
/// True once the user has confirmed the first-run setup screen (or an
/// existing pre-setup-screen install was detected and silently migrated).
/// While false on a machine with no venv, the bootstrap parks in
/// `AwaitingSetup` and nothing downloads or installs.
#[serde(default)]
pub setup_complete: bool,
/// "installed" (platform dirs, default) | "portable" (everything lives in
/// `OmniVoiceStudio-Data/` next to the executable / AppImage).
#[serde(default = "default_install_mode")]
pub install_mode: String,
/// Custom root for the managed Python env (`<dir>/project/.venv`).
/// None → `app_local_data_dir()` (legacy behavior, byte-identical).
#[serde(default)]
pub env_dir: Option<String>,
/// Custom backend data dir (voices/projects/db) → OMNIVOICE_DATA_DIR.
/// None → backend platform default (env var not set at all).
#[serde(default)]
pub data_dir: Option<String>,
/// Custom model-cache dir → OMNIVOICE_CACHE_DIR (backend maps to HF_HOME,
/// HF_HUB_CACHE, TORCH_HOME). None → library defaults.
#[serde(default)]
pub models_dir: Option<String>,
/// UI locale chosen on the setup screen, mirrored here so the Rust side
/// (tray menus, dialogs) can localize in the future. The webview keeps its
/// own copy in localStorage; this field is informational.
#[serde(default)]
pub locale: Option<String>,
/// "auto" (CUDA/MPS/CPU autodetect, default) | "rocm" (AMD wheel reinstall
/// after sync). Env var OMNIVOICE_TORCH_VARIANT still wins for power users.
#[serde(default = "default_torch_variant")]
pub torch_variant: String,
/// Explicit mirror URLs that take precedence over region presets.
#[serde(default)]
pub mirrors: MirrorOverrides,
}
/// Per-source mirror overrides from the setup screen's Advanced section.
/// Each empty/None field falls back to the region preset for that source.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MirrorOverrides {
/// PyPI simple-index URL → UV_INDEX_URL during `uv sync`.
#[serde(default)]
pub pypi_index: Option<String>,
/// Hugging Face endpoint → HF_ENDPOINT for the backend process.
#[serde(default)]
pub hf_endpoint: Option<String>,
/// python-build-standalone release base → UV_PYTHON_INSTALL_MIRROR.
#[serde(default)]
pub python_downloads: Option<String>,
}
pub fn default_region() -> String { "auto".into() }
pub fn default_dictation_shortcut() -> String { "CmdOrCtrl+Shift+Space".into() }
pub fn default_launch_as_widget() -> bool { false }
pub fn default_update_channel() -> String { "stable".into() }
pub fn default_install_mode() -> String { "installed".into() }
pub fn default_torch_variant() -> String { "auto".into() }
impl Default for AppConfig {
fn default() -> Self {
@@ -57,12 +109,30 @@ impl Default for AppConfig {
dictation_shortcut: default_dictation_shortcut(),
launch_as_widget: default_launch_as_widget(),
update_channel: default_update_channel(),
setup_complete: false,
install_mode: default_install_mode(),
env_dir: None,
data_dir: None,
models_dir: None,
locale: None,
torch_variant: default_torch_variant(),
mirrors: MirrorOverrides::default(),
}
}
}
/// A `config.json` inside the exe-adjacent portable folder marks (and wins
/// over) the standard location — so a portable install keeps working when the
/// folder is moved to another machine/disk, with zero state left behind.
fn portable_config_file() -> Option<PathBuf> {
crate::setup::portable_base()
.map(|b| b.join("config.json"))
.filter(|p| p.is_file())
}
pub fn config_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json"))
portable_config_file()
.or_else(|| app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json")))
}
pub fn load_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> AppConfig {
@@ -87,18 +157,26 @@ pub fn load_config_pre_app() -> AppConfig {
const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
fn config_path_pre_app() -> Option<PathBuf> {
dirs_next::data_local_dir().map(|d| d.join(BUNDLE_IDENTIFIER).join("config.json"))
portable_config_file()
.or_else(|| dirs_next::data_local_dir().map(|d| d.join(BUNDLE_IDENTIFIER).join("config.json")))
}
pub fn save_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cfg: &AppConfig) {
if let Some(p) = config_path(app) {
if let Some(parent) = p.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&p, serde_json::to_string_pretty(cfg).unwrap_or_default());
let _ = save_config_at(&p, cfg);
}
}
/// Write the config to an explicit path (used by `complete_setup` to seed the
/// portable folder before `config_path` starts resolving to it).
pub fn save_config_at(path: &PathBuf, cfg: &AppConfig) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let body = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
fs::write(path, body).map_err(|e| format!("write {}: {e}", path.display()))
}
// ── Region helpers ────────────────────────────────────────────────────────
pub const VALID_REGIONS: &[&str] = &["auto", "global", "china", "russia", "restricted"];
@@ -164,6 +242,46 @@ pub fn set_region(app: tauri::AppHandle, region: String) -> String {
r.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// A config.json written by any pre-setup-screen build must keep parsing
/// with all new fields at safe defaults — this is what makes the setup
/// gate invisible to existing installs.
#[test]
fn legacy_config_parses_with_safe_defaults() {
let legacy = r#"{"region":"china","dictation_shortcut":"CmdOrCtrl+Shift+Space","launch_as_widget":false,"update_channel":"preview"}"#;
let cfg: AppConfig = serde_json::from_str(legacy).expect("legacy config must parse");
assert_eq!(cfg.region, "china");
assert_eq!(cfg.update_channel, "preview");
assert!(!cfg.setup_complete, "legacy installs must default to setup_complete=false (venv detection migrates them)");
assert_eq!(cfg.install_mode, "installed");
assert_eq!(cfg.env_dir, None);
assert_eq!(cfg.data_dir, None);
assert_eq!(cfg.models_dir, None);
assert_eq!(cfg.torch_variant, "auto");
assert!(cfg.mirrors.pypi_index.is_none());
assert!(cfg.mirrors.hf_endpoint.is_none());
assert!(cfg.mirrors.python_downloads.is_none());
}
#[test]
fn config_roundtrips_new_fields() {
let mut cfg = AppConfig::default();
cfg.setup_complete = true;
cfg.install_mode = "portable".into();
cfg.models_dir = Some("/mnt/big/models".into());
cfg.mirrors.hf_endpoint = Some("https://hf-mirror.com".into());
let json = serde_json::to_string(&cfg).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert!(back.setup_complete);
assert_eq!(back.install_mode, "portable");
assert_eq!(back.models_dir.as_deref(), Some("/mnt/big/models"));
assert_eq!(back.mirrors.hf_endpoint.as_deref(), Some("https://hf-mirror.com"));
}
}
#[tauri::command]
pub fn get_update_channel(app: tauri::AppHandle) -> String {
load_config(&app).update_channel
+22
View File
@@ -8,6 +8,7 @@
//! commands Tauri IPC commands (sysinfo, logs, HF cache, paste, tray, dictation)
pub mod config;
pub mod setup;
pub mod bootstrap;
pub mod tools;
pub mod backend;
@@ -95,6 +96,9 @@ pub fn run() {
bootstrap::get_bootstrap_logs,
bootstrap::retry_bootstrap,
bootstrap::clean_and_retry_bootstrap,
setup::get_setup_state,
setup::check_install_target,
setup::complete_setup,
config::get_region,
config::set_region,
config::get_update_channel,
@@ -511,6 +515,14 @@ pub fn run() {
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
// `--setup` re-opens the install-plan screen on demand — it
// must win over the attach-to-healthy-backend shortcut, or a
// running backend would skip straight past it.
if std::env::args().any(|a| a == "--setup") {
log::info!("--setup flag — opening the setup screen");
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
return;
}
if backend::backend_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend — attaching",
@@ -527,6 +539,16 @@ pub fn run() {
backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
// First-run gate: never auto-install. With nothing on disk to
// attach to, park on the setup screen and wait for the user to
// confirm an install plan — `complete_setup` restarts the
// bootstrap from there. Existing installs (venv present) are
// detected inside is_first_run and migrate straight through.
if setup::is_first_run(&app_handle) {
log::info!("First run — awaiting setup screen confirmation before installing");
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
return;
}
let child = backend::spawn_backend(&app_handle, Some(&stage_handle));
if let Ok(mut guard) = app_handle.state::<BackendState>().process.lock() {
*guard = child;
+767
View File
@@ -0,0 +1,767 @@
//! First-run install setup: the pre-bootstrap configuration surface.
//!
//! Nothing downloads or installs until the user confirms an [`InstallPlan`]
//! via `complete_setup`. The module is split into:
//! - requirements: minimum-disk constants (measured, with headroom)
//! - disk: per-path free-space / writability probing
//! - paths: portable-base + platform default dir resolution
//! - plan: InstallPlan validation + application
//! - commands: the three Tauri IPC entry points
//!
//! Resolution helpers (`env_root`, `resolved_data_dir`, `resolved_models_dir`)
//! are consumed by `bootstrap.rs` / `backend.rs` so the chosen layout is the
//! single source of truth for every later spawn.
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::Manager;
use crate::bootstrap::{set_stage, BootstrapStage, BootstrapState};
use crate::config::{self, MirrorOverrides};
// ── Requirements ──────────────────────────────────────────────────────────
pub const GIB: u64 = 1024 * 1024 * 1024;
/// Python environment (venv + torch/whisperx/demucs wheels). Measured at
/// 7.8 GiB on Linux x64 CUDA (v0.3.5); rounded up for pip build temp files.
pub const REQUIRED_ENV_BYTES: u64 = 9 * GIB;
/// Default model set (TTS checkpoint + whisper + demucs in the HF cache).
/// Measured at 6.1 GiB after a full clone+dub session; headroom for revisions.
pub const REQUIRED_MODELS_BYTES: u64 = 7 * GIB;
/// Voice data, generation outputs, SQLite DB. Grows with use; 1 GiB floor so
/// a first session never hits a full disk mid-render.
pub const REQUIRED_DATA_BYTES: u64 = GIB;
/// Folder created next to the executable / AppImage in portable mode. The
/// whole install (env + models + voices + config) lives inside it, so moving
/// `app + this folder` together relocates the install.
pub const PORTABLE_DIR_NAME: &str = "OmniVoiceStudio-Data";
// ── Disk probing ──────────────────────────────────────────────────────────
mod disk {
use super::*;
/// The chosen directory usually doesn't exist yet — walk up to the
/// nearest ancestor that does, since that's where space/permissions live.
pub fn nearest_existing(path: &Path) -> PathBuf {
let mut cur = path.to_path_buf();
while !cur.exists() {
match cur.parent() {
Some(p) => cur = p.to_path_buf(),
None => break,
}
}
cur
}
pub fn available_bytes(path: &Path) -> Option<u64> {
fs4::available_space(nearest_existing(path)).ok()
}
/// Stable identity of the filesystem holding `path`, so requirements for
/// dirs that share a disk are summed before comparing against free space.
#[cfg(unix)]
pub fn fs_key(path: &Path) -> Option<String> {
use std::os::unix::fs::MetadataExt;
fs::metadata(nearest_existing(path)).ok().map(|m| format!("dev:{}", m.dev()))
}
#[cfg(not(unix))]
pub fn fs_key(path: &Path) -> Option<String> {
// Windows: the drive prefix (`C:\`) identifies the volume.
nearest_existing(path)
.components()
.next()
.map(|c| format!("vol:{}", c.as_os_str().to_string_lossy().to_uppercase()))
}
/// Probe writability of the nearest existing ancestor with a real write —
/// permission bits lie (ACLs, read-only mounts, translocation), a temp
/// file doesn't. Never creates the target dir itself; that only happens
/// on `complete_setup`.
pub fn writable(path: &Path) -> bool {
let base = nearest_existing(path);
if !base.is_dir() {
return false;
}
let probe = base.join(format!(".omnivoice-write-test-{}", std::process::id()));
match fs::write(&probe, b"ok") {
Ok(()) => {
let _ = fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
}
// ── Path resolution ───────────────────────────────────────────────────────
/// Directory that would hold a portable install: next to the executable —
/// or next to the `.AppImage` file on Linux (the mounted exe path is an
/// ephemeral squashfs mount, useless as an anchor).
pub fn portable_base() -> Option<PathBuf> {
if let Ok(appimage) = std::env::var("APPIMAGE") {
return Path::new(&appimage).parent().map(|p| p.join(PORTABLE_DIR_NAME));
}
let exe = std::env::current_exe().ok()?;
let mut anchor = exe.parent()?.to_path_buf();
// macOS: step out of `Foo.app/Contents/MacOS` so the data folder sits
// beside the .app bundle, not inside it (inside breaks code signing).
if let Some(app_bundle) = anchor
.ancestors()
.find(|a| a.extension().map(|e| e == "app").unwrap_or(false))
{
anchor = app_bundle.parent()?.to_path_buf();
}
Some(anchor.join(PORTABLE_DIR_NAME))
}
/// Mirror of `backend/core/config.py::get_app_data_dir()` platform defaults —
/// shown in the UI so the user sees concrete paths, never "(default)".
pub fn default_data_dir() -> PathBuf {
#[cfg(target_os = "macos")]
{
dirs_next::home_dir().unwrap_or_default().join("Library/Application Support/OmniVoice")
}
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.map(PathBuf::from)
.unwrap_or_default()
.join("OmniVoice")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
dirs_next::home_dir().unwrap_or_default().join(".omnivoice")
}
}
/// Default HF model cache (mirrors huggingface_hub + the backend's Windows
/// MAX_PATH redirect in `backend/core/config.py`).
pub fn default_models_dir() -> PathBuf {
if let Ok(hf_home) = std::env::var("HF_HOME") {
return PathBuf::from(hf_home);
}
#[cfg(target_os = "windows")]
{
std::env::var("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_default()
.join("OmniVoice")
.join("hf_cache")
}
#[cfg(not(target_os = "windows"))]
{
dirs_next::home_dir().unwrap_or_default().join(".cache/huggingface")
}
}
/// Root that holds the managed Python project (`<root>/project/.venv`).
/// Single source of truth for bootstrap + clean-retry + backend spawn.
pub fn env_root<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
let cfg = config::load_config(app);
if cfg.install_mode == "portable" {
if let Some(base) = portable_base() {
return base.join("env");
}
}
if let Some(dir) = cfg.env_dir.as_deref().filter(|s| !s.is_empty()) {
return PathBuf::from(dir);
}
app.path().app_local_data_dir().unwrap_or_default()
}
/// User-chosen backend data dir (voices/projects/db) → `OMNIVOICE_DATA_DIR`.
/// `None` = backend platform default; we deliberately don't set the env var
/// then, so legacy installs keep byte-identical behavior.
pub fn resolved_data_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
let cfg = config::load_config(app);
if cfg.install_mode == "portable" {
return portable_base().map(|b| b.join("data"));
}
cfg.data_dir.as_deref().filter(|s| !s.is_empty()).map(PathBuf::from)
}
/// User-chosen model cache dir → `OMNIVOICE_CACHE_DIR` (backend maps it to
/// HF_HOME / HF_HUB_CACHE / TORCH_HOME). Same `None` = default contract.
pub fn resolved_models_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
let cfg = config::load_config(app);
if cfg.install_mode == "portable" {
return portable_base().map(|b| b.join("data").join("models"));
}
cfg.models_dir.as_deref().filter(|s| !s.is_empty()).map(PathBuf::from)
}
// ── First-run detection ───────────────────────────────────────────────────
/// True only when there is nothing to attach to and the user has never
/// completed (or implicitly owned) an install:
/// - `setup_complete` in config → returning user
/// - dev tree with a `.venv` → contributor running from source
/// - existing bootstrapped venv → pre-setup-screen install: migrate
/// silently (mark complete) instead of re-asking questions whose answers
/// are already on disk.
pub fn is_first_run<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
let cfg = config::load_config(app);
if cfg.setup_complete {
return false;
}
if let Some(dev_root) = crate::bootstrap::find_dev_project_root() {
if crate::bootstrap::venv_python_path(&dev_root.join(".venv")).is_file() {
return false;
}
}
let existing_venv = crate::bootstrap::venv_python_path(&env_root(app).join("project").join(".venv"));
if existing_venv.is_file() {
let mut cfg = cfg;
cfg.setup_complete = true;
config::save_config(app, &cfg);
return false;
}
true
}
// ── IPC payloads ──────────────────────────────────────────────────────────
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupState {
pub first_run: bool,
/// "linux" | "macos" | "windows" — lets the UI hide platform-specific
/// opt-ins (e.g. the Linux-only ROCm torch variant) per the
/// cross-platform parity rule: identical defaults everywhere,
/// platform-only choices never shown where they can't work.
pub os: &'static str,
pub defaults: SetupDefaults,
pub portable: PortableSupport,
pub requirements: Requirements,
pub hardware: HardwareInfo,
}
/// What the machine offers, shown on the Compute card so the accelerator
/// choice is informed rather than a guess. Detection is best-effort and
/// must never block setup: every probe degrades to None/CPU.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HardwareInfo {
/// Marketing name when detectable ("NVIDIA GeForce RTX 4070 …").
pub gpu: Option<String>,
/// "cuda" | "rocm" | "mps" | "cpu" — which torch path this maps to.
pub kind: String,
/// Human OS name: distro PRETTY_NAME on Linux ("CachyOS", "Ubuntu 24.04"),
/// "macOS" / "Windows" elsewhere. The install matrix (OS family × distro
/// × arch × GPU vendor) is what users file bug reports with — show it.
pub os_name: String,
/// "x86_64" | "aarch64" | … — Apple Silicon vs Intel mac, ARM Linux
/// (Asahi/Jetson) vs x64 all behave differently for wheels.
pub arch: &'static str,
pub cpu_cores: usize,
pub ram_gb: f64,
}
/// Distro-aware OS label. Linux reads /etc/os-release PRETTY_NAME (falls
/// back to NAME, then "Linux"); macOS/Windows are just themselves.
fn os_pretty_name() -> String {
#[cfg(target_os = "linux")]
{
if let Ok(body) = fs::read_to_string("/etc/os-release") {
for key in ["PRETTY_NAME=", "NAME="] {
if let Some(line) = body.lines().find(|l| l.starts_with(key)) {
let v = line[key.len()..].trim().trim_matches('"');
if !v.is_empty() {
return v.to_string();
}
}
}
}
"Linux".to_string()
}
#[cfg(target_os = "macos")]
{
"macOS".to_string()
}
#[cfg(target_os = "windows")]
{
"Windows".to_string()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
std::env::consts::OS.to_string()
}
}
fn detect_hardware() -> HardwareInfo {
use std::process::Command;
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
let ram_gb = {
let mut sys = sysinfo::System::new();
sys.refresh_memory();
(sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0) * 10.0).round() / 10.0
};
let os_name = os_pretty_name();
let arch = std::env::consts::ARCH;
let base = move |gpu: Option<String>, kind: &str| HardwareInfo {
gpu,
kind: kind.into(),
os_name: os_name.clone(),
arch,
cpu_cores: cores,
ram_gb,
};
// NVIDIA: nvidia-smi ships with the driver on Linux + Windows.
let mut smi = Command::new("nvidia-smi");
smi.args(["--query-gpu=name", "--format=csv,noheader"]);
// Windows: a GUI app spawning a console binary flashes a cmd window —
// on the very first screen a user ever sees. CREATE_NO_WINDOW stops it.
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
smi.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
if let Ok(out) = smi.output() {
if out.status.success() {
if let Some(name) = String::from_utf8_lossy(&out.stdout).lines().next() {
let name = name.trim();
if !name.is_empty() {
return base(Some(name.to_string()), "cuda");
}
}
}
}
// Apple Silicon → MPS.
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
return base(Some("Apple Silicon".into()), "mps");
}
// AMD on Linux: a DRM card with vendor 0x1002 → ROCm candidate. No
// marketing name without lspci, so stay generic.
#[cfg(target_os = "linux")]
{
if let Ok(entries) = fs::read_dir("/sys/class/drm") {
for e in entries.flatten() {
let vendor = e.path().join("device").join("vendor");
if let Ok(v) = fs::read_to_string(&vendor) {
if v.trim() == "0x1002" {
return base(Some("AMD GPU".into()), "rocm");
}
}
}
}
}
base(None, "cpu")
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupDefaults {
pub install_mode: String,
pub env_dir: String,
pub data_dir: String,
pub models_dir: String,
pub region: String,
pub update_channel: String,
pub torch_variant: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PortableSupport {
pub available: bool,
pub base_dir: Option<String>,
/// Machine-readable reason when unavailable: "not_writable" | "no_anchor".
pub reason: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Requirements {
pub env_bytes: u64,
pub models_bytes: u64,
pub data_bytes: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetCheck {
pub path: String,
pub exists: bool,
pub writable: bool,
pub free_bytes: Option<u64>,
pub fs_key: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPlan {
pub install_mode: String,
#[serde(default)]
pub env_dir: Option<String>,
#[serde(default)]
pub data_dir: Option<String>,
#[serde(default)]
pub models_dir: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub locale: Option<String>,
#[serde(default)]
pub update_channel: Option<String>,
#[serde(default)]
pub torch_variant: Option<String>,
#[serde(default)]
pub mirrors: Option<MirrorOverrides>,
}
// ── Plan validation + application ─────────────────────────────────────────
fn none_if_default(chosen: &Option<String>, default: &Path) -> Option<String> {
chosen
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.filter(|s| Path::new(s) != default)
.map(str::to_string)
}
fn valid_mirror(url: &Option<String>) -> Result<Option<String>, String> {
match url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(u) if u.starts_with("http://") || u.starts_with("https://") => Ok(Some(u.to_string())),
Some(u) => Err(format!("Mirror URL must start with http(s):// — got: {u}")),
}
}
/// (target dir, bytes required there) for the chosen layout.
fn space_targets(plan: &InstallPlan, env_default: &Path) -> Vec<(PathBuf, u64)> {
if plan.install_mode == "portable" {
// Everything shares one folder → one combined requirement.
let base = portable_base().unwrap_or_default();
return vec![(base, REQUIRED_ENV_BYTES + REQUIRED_MODELS_BYTES + REQUIRED_DATA_BYTES)];
}
let dir_of = |s: &Option<String>, d: &Path| {
s.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| d.to_path_buf())
};
vec![
(dir_of(&plan.env_dir, env_default), REQUIRED_ENV_BYTES),
(dir_of(&plan.data_dir, &default_data_dir()), REQUIRED_DATA_BYTES),
(dir_of(&plan.models_dir, &default_models_dir()), REQUIRED_MODELS_BYTES),
]
}
/// Authoritative install gate: group targets by filesystem, sum what each
/// volume must hold, and refuse the plan when any volume falls short. The UI
/// runs the same math for live feedback; this is the backstop that actually
/// "won't let install".
fn check_space(targets: &[(PathBuf, u64)]) -> Result<(), String> {
use std::collections::HashMap;
let mut by_fs: HashMap<String, (PathBuf, u64)> = HashMap::new();
for (dir, need) in targets {
let key = disk::fs_key(dir).unwrap_or_else(|| dir.to_string_lossy().into_owned());
let entry = by_fs.entry(key).or_insert_with(|| (dir.clone(), 0));
entry.1 += need;
}
for (dir, need) in by_fs.values() {
let free = disk::available_bytes(dir)
.ok_or_else(|| format!("Could not determine free space for {}", dir.display()))?;
if free < *need {
return Err(format!(
"Not enough free space on the disk holding {}: needs ~{:.1} GB, only {:.1} GB available.",
dir.display(),
*need as f64 / GIB as f64,
free as f64 / GIB as f64,
));
}
}
Ok(())
}
fn check_writable(targets: &[(PathBuf, u64)]) -> Result<(), String> {
for (dir, _) in targets {
if !disk::writable(dir) {
return Err(format!("Directory is not writable: {}", dir.display()));
}
}
Ok(())
}
// ── Tauri commands ────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_setup_state(app: tauri::AppHandle) -> SetupState {
let cfg = config::load_config(&app);
let env_default = app.path().app_local_data_dir().unwrap_or_default();
let portable = match portable_base() {
Some(base) if disk::writable(&base) => PortableSupport {
available: true,
base_dir: Some(base.to_string_lossy().into_owned()),
reason: None,
},
Some(base) => PortableSupport {
available: false,
base_dir: Some(base.to_string_lossy().into_owned()),
reason: Some("not_writable".into()),
},
None => PortableSupport { available: false, base_dir: None, reason: Some("no_anchor".into()) },
};
SetupState {
first_run: is_first_run(&app),
os: std::env::consts::OS,
defaults: SetupDefaults {
install_mode: cfg.install_mode,
env_dir: env_default.to_string_lossy().into_owned(),
data_dir: default_data_dir().to_string_lossy().into_owned(),
models_dir: default_models_dir().to_string_lossy().into_owned(),
region: cfg.region,
update_channel: cfg.update_channel,
torch_variant: cfg.torch_variant,
},
portable,
requirements: Requirements {
env_bytes: REQUIRED_ENV_BYTES,
models_bytes: REQUIRED_MODELS_BYTES,
data_bytes: REQUIRED_DATA_BYTES,
},
hardware: detect_hardware(),
}
}
#[tauri::command]
pub fn check_install_target(path: String) -> TargetCheck {
let p = PathBuf::from(path.trim());
TargetCheck {
exists: p.exists(),
writable: disk::writable(&p),
free_bytes: disk::available_bytes(&p),
fs_key: disk::fs_key(&p),
path: p.to_string_lossy().into_owned(),
}
}
/// Validate the plan, persist it, then start the (until now deliberately
/// parked) bootstrap. Any `Err` keeps the app in `AwaitingSetup` with the
/// message surfaced on the setup screen — nothing was installed.
#[tauri::command]
pub fn complete_setup(
app: tauri::AppHandle,
state: tauri::State<'_, BootstrapState>,
plan: InstallPlan,
) -> Result<(), String> {
if !matches!(plan.install_mode.as_str(), "installed" | "portable") {
return Err(format!("Unknown install mode: {}", plan.install_mode));
}
if plan.install_mode == "portable" && portable_base().map(|b| disk::writable(&b)) != Some(true) {
return Err("Portable mode is unavailable: the folder next to the app is not writable.".into());
}
let mirrors = match &plan.mirrors {
None => MirrorOverrides::default(),
Some(m) => MirrorOverrides {
pypi_index: valid_mirror(&m.pypi_index)?,
hf_endpoint: valid_mirror(&m.hf_endpoint)?,
python_downloads: valid_mirror(&m.python_downloads)?,
},
};
let env_default = app.path().app_local_data_dir().unwrap_or_default();
let targets = space_targets(&plan, &env_default);
check_writable(&targets)?;
check_space(&targets)?;
let mut cfg = config::load_config(&app);
cfg.setup_complete = true;
cfg.install_mode = plan.install_mode.clone();
cfg.env_dir = none_if_default(&plan.env_dir, &env_default);
cfg.data_dir = none_if_default(&plan.data_dir, &default_data_dir());
cfg.models_dir = none_if_default(&plan.models_dir, &default_models_dir());
cfg.mirrors = mirrors;
if let Some(region) = plan.region.as_deref().filter(|r| config::VALID_REGIONS.contains(r)) {
cfg.region = region.to_string();
}
if let Some(channel) = plan.update_channel.as_deref().filter(|c| config::VALID_CHANNELS.contains(c)) {
cfg.update_channel = channel.to_string();
}
if let Some(variant) = plan.torch_variant.as_deref().filter(|v| ["auto", "rocm"].contains(v)) {
// ROCm wheels exist for Linux only — clamp anywhere else so a stray
// payload can't configure an install that has no wheels to pull.
cfg.torch_variant = if variant == "rocm" && !cfg!(target_os = "linux") {
"auto".to_string()
} else {
variant.to_string()
};
}
cfg.locale = plan.locale.clone().filter(|l| !l.is_empty());
if plan.install_mode == "portable" {
// Create the portable folder and seed config.json INSIDE it first, so
// `config_path` resolves portable from here on and the whole install
// (env + data + config) travels as one folder.
let base = portable_base().ok_or("Portable anchor disappeared")?;
fs::create_dir_all(&base).map_err(|e| format!("Could not create {}: {e}", base.display()))?;
config::save_config_at(&base.join("config.json"), &cfg)?;
} else {
for (dir, _) in &targets {
fs::create_dir_all(dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
}
}
config::save_config(&app, &cfg);
log::info!(
"Setup complete (mode={}, env={}, data={}, models={}) — starting bootstrap",
cfg.install_mode,
cfg.env_dir.as_deref().unwrap_or("<default>"),
cfg.data_dir.as_deref().unwrap_or("<default>"),
cfg.models_dir.as_deref().unwrap_or("<default>"),
);
set_stage(&state.stage, BootstrapStage::Checking);
crate::bootstrap::retry_bootstrap(app, state);
Ok(())
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_existing_walks_up_to_a_real_dir() {
let tmp = std::env::temp_dir();
let ghost = tmp.join("omnivoice-no-such-dir").join("deeper").join("still-deeper");
let found = disk::nearest_existing(&ghost);
assert!(found.exists(), "must resolve to an existing ancestor");
assert!(ghost.starts_with(&found));
}
#[test]
fn available_bytes_reports_space_for_temp_dir() {
let free = disk::available_bytes(&std::env::temp_dir());
assert!(free.is_some(), "temp dir must report free space");
assert!(free.unwrap() > 0);
}
#[test]
fn fs_key_is_stable_and_groups_same_volume() {
let tmp = std::env::temp_dir();
let a = disk::fs_key(&tmp);
let b = disk::fs_key(&tmp.join("does-not-exist-yet"));
assert!(a.is_some());
assert_eq!(a, b, "child of the same volume must share the fs key");
}
#[test]
fn writable_accepts_temp_and_rejects_nonsense() {
assert!(disk::writable(&std::env::temp_dir().join("new-subdir-not-created")));
#[cfg(unix)]
assert!(
!disk::writable(Path::new("/proc/omnivoice-definitely-not-writable")),
"procfs is not writable"
);
}
#[test]
fn space_targets_portable_collapses_to_one_combined_requirement() {
let plan = InstallPlan {
install_mode: "portable".into(),
env_dir: None, data_dir: None, models_dir: None,
region: None, locale: None, update_channel: None,
torch_variant: None, mirrors: None,
};
let targets = space_targets(&plan, Path::new("/unused"));
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].1, REQUIRED_ENV_BYTES + REQUIRED_MODELS_BYTES + REQUIRED_DATA_BYTES);
}
#[test]
fn space_targets_installed_checks_each_location() {
let plan = InstallPlan {
install_mode: "installed".into(),
env_dir: Some("/x/env".into()),
data_dir: Some("/y/data".into()),
models_dir: None, // default
region: None, locale: None, update_channel: None,
torch_variant: None, mirrors: None,
};
let targets = space_targets(&plan, Path::new("/default-env"));
assert_eq!(targets.len(), 3);
assert_eq!(targets[0], (PathBuf::from("/x/env"), REQUIRED_ENV_BYTES));
assert_eq!(targets[1], (PathBuf::from("/y/data"), REQUIRED_DATA_BYTES));
assert_eq!(targets[2].1, REQUIRED_MODELS_BYTES);
assert_eq!(targets[2].0, default_models_dir());
}
#[test]
fn check_space_sums_requirements_sharing_a_volume() {
// Both targets resolve to the temp-dir volume; an absurd combined
// requirement must fail even when each alone might pass.
let tmp = std::env::temp_dir();
let huge = 1024 * 1024 * GIB; // 1 EiB — no consumer disk has this
let res = check_space(&[(tmp.clone(), huge), (tmp.join("sub"), huge)]);
assert!(res.is_err(), "1 EiB×2 on one volume must be rejected");
let msg = res.unwrap_err();
assert!(msg.contains("Not enough free space"), "msg: {msg}");
}
#[test]
fn check_space_accepts_tiny_requirements() {
assert!(check_space(&[(std::env::temp_dir(), 1)]).is_ok());
}
#[test]
fn mirror_validation_requires_http_scheme() {
assert_eq!(valid_mirror(&None).unwrap(), None);
assert_eq!(valid_mirror(&Some(" ".into())).unwrap(), None);
assert_eq!(
valid_mirror(&Some("https://hf-mirror.com".into())).unwrap().as_deref(),
Some("https://hf-mirror.com")
);
assert!(valid_mirror(&Some("ftp://nope".into())).is_err());
assert!(valid_mirror(&Some("hf-mirror.com".into())).is_err());
}
#[test]
fn none_if_default_strips_defaults_and_blanks() {
let d = Path::new("/default/dir");
assert_eq!(none_if_default(&None, d), None);
assert_eq!(none_if_default(&Some("".into()), d), None);
assert_eq!(none_if_default(&Some("/default/dir".into()), d), None);
assert_eq!(none_if_default(&Some("/custom".into()), d), Some("/custom".into()));
}
#[test]
fn detect_hardware_never_panics_and_reports_the_full_matrix() {
let hw = detect_hardware();
assert!(["cuda", "rocm", "mps", "cpu"].contains(&hw.kind.as_str()), "kind: {}", hw.kind);
assert!(hw.ram_gb >= 0.0);
assert!(!hw.os_name.is_empty(), "os_name must always resolve (distro or OS family)");
assert!(!hw.arch.is_empty(), "arch must always resolve");
#[cfg(target_arch = "x86_64")]
assert_eq!(hw.arch, "x86_64");
}
#[test]
fn requirements_match_measured_reality() {
// Guard against accidental edits: env ≥ measured 7.8 GiB, models ≥
// measured 6.1 GiB — shrinking below measurements would let installs
// start that are guaranteed to die mid-download.
assert!(REQUIRED_ENV_BYTES >= 8 * GIB);
assert!(REQUIRED_MODELS_BYTES >= 7 * GIB);
assert!(REQUIRED_DATA_BYTES >= GIB / 2);
}
}
+24 -3
View File
@@ -365,6 +365,13 @@ function App() {
const [setupNeeded, setSetupNeeded] = useState(false);
const [setupChecked, setSetupChecked] = useState(false);
useEffect(() => {
// Gate the probe on the bootstrap being 'ready' — before that there is
// no backend to answer. Probing from mount burned the 30-attempt ceiling
// during the setup/installing acts (minutes long on a first run), so the
// wizard was silently skipped straight into the studio once the install
// finished. Keyed on bootstrapStage: the probe (re)runs the moment the
// backend becomes reachable.
if (bootstrapStage !== 'ready') return undefined;
let cancelled = false;
(async () => {
const { setupStatus } = await import('./api/setup');
@@ -383,7 +390,7 @@ function App() {
if (!cancelled) setSetupChecked(true);
})();
return () => { cancelled = true; };
}, []);
}, [bootstrapStage]);
// ── Tauri auto-updater ──
// On boot, ask GitHub Releases if a newer build is available. If yes,
@@ -765,6 +772,17 @@ function App() {
};
// Install-plan screen outranks everything — both on a true first run and
// when explicitly requested via `--setup`. Without this, a live backend
// answering /setup/status would route straight to the model wizard and the
// awaiting_setup stage would never get to render.
if (bootstrapStage === 'awaiting_setup') {
return (
<div style={{ zoom: uiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
</div>
);
}
// First-run gate: if /setup/status says models aren't on disk yet, render
// the wizard instead of the main studio. Dismisses itself once the user
// completes the download (or clicks "Skip" if they want to limp along).
@@ -781,10 +799,13 @@ function App() {
</div>
);
}
if (setupNeeded) {
if (setupNeeded && bootstrapStage === 'ready') {
// Render outside the `app-container` grid so the wizard spans the full
// viewport instead of getting squeezed into whatever grid cell the
// studio layout reserves for the main content column.
// studio layout reserves for the main content column. Gated on the
// bootstrap being 'ready': while the stage is still settling (checking /
// awaiting_setup racing the first poll), the wizard must not steal the
// mount from the install-plan screen.
return (
<div
className="app-wizard-wrap"
+4 -364
View File
@@ -1,364 +1,4 @@
.bootstrap-splash {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: var(--chrome-bg, #141414);
color: var(--chrome-fg, #eee);
font-family: 'Inter Variable', 'Inter', system-ui, sans-serif;
z-index: 9999;
padding: 2rem;
}
.bootstrap-splash__card {
width: 100%;
max-width: 560px;
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent);
border-radius: 14px;
padding: 2rem;
text-align: left;
}
.bootstrap-splash__title-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.bootstrap-splash__card h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.bootstrap-splash__version {
font-size: 0.72rem;
opacity: 0.45;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
.bootstrap-splash__region {
margin-left: auto;
display: flex;
align-items: center;
}
.bootstrap-splash__region-select {
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
color: inherit;
font: inherit;
font-size: 0.72rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
padding-right: 1.4rem;
transition: all 0.15s ease;
}
.bootstrap-splash__region-select:hover {
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
}
.bootstrap-splash__region-select:focus {
outline: 1px solid var(--chrome-accent, #8ec07c);
outline-offset: 1px;
}
.bootstrap-splash__region-select option {
background: #1a1a1a;
color: #eee;
}
.bootstrap-splash__lang {
display: flex;
align-items: center;
}
.bootstrap-splash__lang-select {
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
color: inherit;
font: inherit;
font-size: 0.72rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
padding-right: 1.4rem;
transition: all 0.15s ease;
}
.bootstrap-splash__lang-select:hover {
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
}
.bootstrap-splash__lang-select:focus {
outline: 1px solid var(--chrome-accent, #8ec07c);
outline-offset: 1px;
}
.bootstrap-splash__lang-select option {
background: #1a1a1a;
color: #eee;
}
.bootstrap-splash__suggestion {
display: flex;
align-items: center;
justify-content: space-between;
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-accent, #8ec07c) 20%, transparent);
color: #eee;
padding: 0.45rem 0.75rem;
border-radius: 8px;
font-size: 0.78rem;
margin-top: 0.25rem;
margin-bottom: 1.25rem;
animation: bootstrapSplashSlideDown 0.2s ease-out;
}
.bootstrap-splash__suggestion-actions {
display: flex;
gap: 0.4rem;
}
.bootstrap-splash__suggestion-actions button {
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 20%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-accent, #8ec07c) 35%, transparent);
color: #eee;
padding: 0.15rem 0.45rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.72rem;
transition: all 0.15s ease;
}
.bootstrap-splash__suggestion-actions button:hover {
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 35%, transparent);
}
@keyframes bootstrapSplashSlideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.bootstrap-splash__status {
margin: 0 0 1.25rem;
font-size: 0.95rem;
opacity: 0.85;
}
.bootstrap-splash__bar {
height: 4px;
width: 100%;
border-radius: 3px;
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
overflow: hidden;
margin-bottom: 1.25rem;
}
.bootstrap-splash__bar-fill {
height: 100%;
background: var(--chrome-accent, #8ec07c);
transition: width 0.4s ease;
}
.bootstrap-splash__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.85rem;
}
.bootstrap-splash__steps li {
padding-left: 1.5rem;
position: relative;
opacity: 0.5;
}
.bootstrap-splash__steps li::before {
content: '○';
position: absolute;
left: 0;
top: 0;
}
.bootstrap-splash__steps li.done {
opacity: 0.6;
}
.bootstrap-splash__steps li.done::before {
content: '✓';
color: var(--chrome-accent, #8ec07c);
}
.bootstrap-splash__steps li.active {
opacity: 1;
font-weight: 500;
}
.bootstrap-splash__steps li.active::before {
content: '●';
color: var(--chrome-accent, #8ec07c);
animation: bootstrap-pulse 1.4s ease-in-out infinite;
}
@keyframes bootstrap-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.bootstrap-splash__error {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.8rem;
background: color-mix(in srgb, #ef4444 12%, transparent);
border: 1px solid color-mix(in srgb, #ef4444 35%, transparent);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 250px;
overflow-y: auto;
user-select: text;
}
.bootstrap-splash__sub-progress {
margin: -0.5rem 0 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bootstrap-splash__sub-bar {
height: 3px;
width: 100%;
border-radius: 2px;
background: color-mix(in srgb, var(--chrome-fg, #eee) 6%, transparent);
overflow: hidden;
}
.bootstrap-splash__sub-bar-fill {
height: 100%;
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 70%, transparent);
transition: width 0.2s ease;
}
.bootstrap-splash__sub-label {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.72rem;
opacity: 0.65;
}
.bootstrap-splash__log-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
}
.bootstrap-splash__log-toggle {
background: none;
border: none;
color: inherit;
opacity: 0.65;
font: inherit;
font-size: 0.78rem;
padding: 0.25rem 0;
cursor: pointer;
text-align: left;
}
.bootstrap-splash__log-toggle:hover { opacity: 1; }
.bootstrap-splash__log-count {
flex: 1;
opacity: 0.45;
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.bootstrap-splash__logs {
margin: 0.5rem 0 0;
max-height: 280px;
min-height: 100px;
overflow-y: auto;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.72rem;
line-height: 1.45;
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
border-radius: 8px;
padding: 0.6rem 0.75rem;
white-space: pre-wrap;
word-break: break-word;
opacity: 0.85;
user-select: text;
}
.bootstrap-splash__copy-btn {
margin-top: 0.5rem;
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
color: inherit;
font: inherit;
font-size: 0.75rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.bootstrap-splash__copy-btn:hover {
background: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
}
/* ── Error hints + retry actions ── */
.bootstrap-splash__hints {
margin-top: 0.75rem;
font-size: 0.8rem;
opacity: 0.9;
line-height: 1.5;
}
.bootstrap-splash__hints strong { display: block; margin-bottom: 0.35rem; }
.bootstrap-splash__hints ul {
margin: 0; padding-left: 1.25rem;
display: flex; flex-direction: column; gap: 0.25rem;
}
.bootstrap-splash__hints li { opacity: 0.85; }
.bootstrap-splash__actions {
display: flex; gap: 0.5rem; margin-top: 1rem;
}
.bootstrap-splash__retry-btn {
flex: 1;
padding: 0.5rem 1rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 15%, transparent);
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 15%, transparent);
color: var(--chrome-fg, #eee);
font: inherit; font-size: 0.82rem; font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.bootstrap-splash__retry-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 25%, transparent);
}
.bootstrap-splash__retry-btn:disabled { opacity: 0.5; cursor: wait; }
.bootstrap-splash__retry-btn--danger {
background: color-mix(in srgb, #ef4444 12%, transparent);
border-color: color-mix(in srgb, #ef4444 30%, transparent);
}
.bootstrap-splash__retry-btn--danger:hover:not(:disabled) {
background: color-mix(in srgb, #ef4444 22%, transparent);
}
/* The bootstrap splash now renders entirely in the shared first-run design
* system (frs-* classes in FirstRunSetup.css) so that setup → install →
* model wizard reads as one continuous experience. This file intentionally
* carries no rules; it remains so stale imports keep resolving. */
+200 -106
View File
@@ -1,5 +1,5 @@
/**
* First-run bootstrap splash.
* First-run bootstrap splash — the "installing" act of the first-run journey.
*
* Two data sources drive this UI:
* 1. `bootstrap_status` Tauri command (polled every 1 s) — coarse stage.
@@ -7,14 +7,24 @@
* from `uv sync`, ffmpeg byte counts, etc. The log panel shows the
* last N lines so users can see *something* happening during the 510
* min dependency install.
*
* Visual language: the same "studio console" system as FirstRunSetup
* (frs-* classes from FirstRunSetup.css) — whisper waveform masthead,
* engraved mono section titles, LED step rail, segmented progress meter —
* so setup → install → model wizard reads as one continuous experience.
*/
import { useEffect, useRef, useState } from 'react';
import { Suspense, lazy, useEffect, useRef, useState } from 'react';
import { copyText } from "../utils/copyText";
import './FirstRunSetup.css';
import './BootstrapSplash.css';
import { useTranslation } from 'react-i18next';
import i18n, { LANGUAGES } from '../i18n';
import { useAppStore } from '../store';
// First-run only: keep the setup screen out of the main bundle so every
// regular launch pays nothing for it.
const FirstRunSetup = lazy(() => import('./FirstRunSetup'));
const getSystemLanguage = () => {
if (typeof navigator === 'undefined') return 'en';
const navLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en';
@@ -77,6 +87,26 @@ function formatBytes(n) {
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
/** Whisper waveform — same speech-cadence silhouette as the setup screen. */
function Waveform({ bars = 96 }) {
const heights = Array.from({ length: bars }, (_, i) => {
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
);
return 0.18 + v * 0.82;
});
return (
<div className="frs-wave" aria-hidden="true">
{heights.map((h, i) => (
<span key={i} className="frs-wave__bar" style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }} />
))}
</div>
);
}
export function BootstrapSplash({ stage, message }) {
const { t } = useTranslation();
const locale = useAppStore(s => s.locale);
@@ -224,7 +254,6 @@ export function BootstrapSplash({ stage, message }) {
}, [logs, logsOpen]);
// Auto-expand logs on failure so users can see + copy the full output.
// Also expand on failure (in case user collapsed manually).
useEffect(() => {
if (isFailed) setLogsOpen(true);
}, [isFailed]);
@@ -244,134 +273,187 @@ export function BootstrapSplash({ stage, message }) {
const stageProgress = progress && progress.stage === stage ? progress : null;
const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null;
// Overall journey progress: completed steps + byte-progress within the
// current step when the backend reports it.
const overallPct = Math.min(
100,
((stepIndex + (pctFromBytes != null ? pctFromBytes / 100 : 0.4)) / STEPS.length) * 100,
);
// First run with nothing installed: Rust parks in `awaiting_setup` and the
// install-plan screen takes over. complete_setup advances the stage, and
// the regular progress UI below resumes automatically on the next poll.
// (Checked after every hook above so the setup → install transition keeps
// the hook order stable.)
if (stage === 'awaiting_setup') {
return (
<Suspense fallback={<div className="frs"><div className="frs__atmo" /></div>}>
<FirstRunSetup />
</Suspense>
);
}
return (
<div className="bootstrap-splash">
<div className="bootstrap-splash__card">
<div className="bootstrap-splash__title-row">
<h1>{t('bootstrap.title', 'OmniVoice Studio')}</h1>
<span className="bootstrap-splash__version">v{APP_VERSION}</span>
<div className="bootstrap-splash__region">
<select
className="bootstrap-splash__region-select"
value={region}
onChange={(e) => handleRegionChange(e.target.value)}
>
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
<option value="global">🌐 {t('bootstrap.region_global')}</option>
<option value="china">🇨🇳 {t('bootstrap.region_china')}</option>
<option value="russia">🇷🇺 {t('bootstrap.region_russia')}</option>
<option value="restricted">🌍 {t('bootstrap.region_restricted')}</option>
</select>
<div className="frs">
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__deck frs__deck--focus">
{/* ── Masthead: same identity as the setup screen ───────────────── */}
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
<Waveform />
{/* Journey rail: act 2 of the install flow (setup already done). */}
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('bootstrap.title', 'OmniVoice Studio')}>
<span className="frs-wstep is-done">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_setup', 'Setup')}
</span>
<span className="frs-wstep is-active">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.installing_title', 'Installing')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_models', 'Models & engines')}
</span>
</nav>
<div className="frs__mast-row">
<div className="frs__mast-text">
<h1 className="frs__title">{t('bootstrap.title', 'OmniVoice Studio')}</h1>
<p className="frs__subtitle">{label}</p>
</div>
<div className="frs__mast-meta">
<div className="frs__mast-selects">
<select
className="frs-select frs-select--lang"
value={locale}
onChange={(e) => handleLocaleChange(e.target.value)}
aria-label={t('firstrun.language', 'Language')}
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
<select
className="frs-select frs-select--lang"
value={region}
onChange={(e) => handleRegionChange(e.target.value)}
aria-label={t('firstrun.region_label', 'Download region')}
>
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
<option value="global">🌐 {t('bootstrap.region_global')}</option>
<option value="china">🇨🇳 {t('bootstrap.region_china')}</option>
<option value="russia">🇷🇺 {t('bootstrap.region_russia')}</option>
<option value="restricted">🌍 {t('bootstrap.region_restricted')}</option>
</select>
</div>
</div>
</div>
<div className="bootstrap-splash__lang" style={{ marginLeft: '0.5rem' }}>
<select
className="bootstrap-splash__lang-select"
value={locale}
onChange={(e) => handleLocaleChange(e.target.value)}
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
</div>
</div>
</header>
{showSuggestion && (
<div className="bootstrap-splash__suggestion">
<div className="frs-banner frs-rise" style={{ '--rise': 1 }}>
<span>🌐 {t('bootstrap.suggest_lang', { lang: LANGUAGES.find(l => l.code === systemLang)?.label || systemLang })}</span>
<div className="bootstrap-splash__suggestion-actions">
<button onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
<button onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
<div className="frs-banner__actions">
<button type="button" className="frs-btn frs-btn--quiet" onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
<button type="button" className="frs-btn frs-btn--quiet" onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
</div>
</div>
)}
<p className="bootstrap-splash__status">{label}</p>
{isFailed ? (
<>
<pre className="bootstrap-splash__error">{message || t('bootstrap.unknown_error')}</pre>
<div className="bootstrap-splash__hints">
<strong>💡 {t('bootstrap.what_to_try', 'What to try:')}</strong>
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
<h2 className="frs-panel__title">{t('bootstrap.failed', 'Setup failed')}</h2>
<pre className="frs__error">{message || t('bootstrap.unknown_error')}</pre>
<div className="frs-hints">
<span className="frs-hints__label">💡 {t('bootstrap.what_to_try', 'What to try:')}</span>
<ul>
{detectHints(message, logs).map((h, i) => <li key={i}>{h}</li>)}
</ul>
</div>
<div className="bootstrap-splash__actions">
<button className="bootstrap-splash__retry-btn" onClick={handleRetry} disabled={retrying}>
{retrying ? '⏳ ' + t('bootstrap.retrying', 'Retrying…') : '🔄 ' + t('bootstrap.retry', 'Retry')}
</button>
<button className="bootstrap-splash__retry-btn bootstrap-splash__retry-btn--danger" onClick={handleCleanRetry} disabled={retrying}>
<div className="frs-banner__actions frs-banner__actions--end">
<button
type="button"
className="frs-btn frs-btn--quiet"
onClick={handleCleanRetry}
disabled={retrying}
>
🧹 {t('bootstrap.clean_retry', 'Clean & Retry')}
</button>
<button
type="button"
className={`frs-btn frs-btn--primary ${retrying ? '' : 'is-armed'}`}
onClick={handleRetry}
disabled={retrying}
>
<span className="frs-btn__led" aria-hidden="true" />
{retrying ? t('bootstrap.retrying', 'Retrying…') : t('bootstrap.retry', 'Retry')}
</button>
</div>
</>
</section>
) : (
<>
<div className="bootstrap-splash__bar">
<div
className="bootstrap-splash__bar-fill"
style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }}
/>
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
<h2 className="frs-panel__title">{t('firstrun.installing_title', 'Installing')}</h2>
{/* Overall journey meter — the same LED segments as the setup
screen's disk gate, now measuring progress instead of space. */}
<div className="frs-meter frs-meter--progress" role="progressbar" aria-valuenow={Math.round(overallPct)} aria-valuemin={0} aria-valuemax={100}>
<span className="frs-meter__fill" style={{ width: `${overallPct}%` }} />
</div>
{stageProgress && (
<div className="bootstrap-splash__sub-progress">
<div className="bootstrap-splash__sub-bar">
<div
className="bootstrap-splash__sub-bar-fill"
style={{ width: `${pctFromBytes ?? 0}%` }}
/>
</div>
<span className="bootstrap-splash__sub-label">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
</span>
</div>
)}
<ol className="bootstrap-splash__steps">
<ol className="frs-steps">
{STEPS.map((s, i) => (
<li
key={s}
className={
i < stepIndex ? 'done' :
i === stepIndex ? 'active' :
'pending'
}
className={[
'frs-step',
i < stepIndex ? 'is-done' : i === stepIndex ? 'is-active' : 'is-pending',
].join(' ')}
>
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
<span className="frs-step__led" aria-hidden="true" />
<span className="frs-step__label">{t(`bootstrap.${s}`, STAGE_LABEL[s])}</span>
{i === stepIndex && stageProgress && (
<span className="frs-step__bytes">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0 ? ` / ${formatBytes(stageProgress.bytes_total)}` : ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
</span>
)}
</li>
))}
</ol>
</>
)}
{/* Live log panel — always visible so users see what's happening */}
<div className="bootstrap-splash__log-header">
<button
type="button"
className="bootstrap-splash__log-toggle"
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
</button>
<span className="bootstrap-splash__log-count">
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
</span>
<button
type="button"
className="bootstrap-splash__copy-btn"
onClick={handleCopyLogs}
>
{copied ? '✓ ' + t('bootstrap.copied', 'Copied!') : '📋 ' + t('bootstrap.copy', 'Copy')}
</button>
</div>
{logsOpen && (
<pre className="bootstrap-splash__logs" ref={logRef}>
{logs.length === 0
? t('bootstrap.waiting_output', 'Waiting for output…')
: logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
</pre>
</section>
)}
{/* ── Live log — always reachable, quiet by design ───────────────── */}
<section className="frs-panel frs-rise" style={{ '--rise': 2 }}>
<h2 className="frs-panel__title">
{t('firstrun.activity_title', 'Activity')}
<span className="frs-log__meta">
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
</span>
</h2>
<div className="frs-log__bar">
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setLogsOpen(v => !v)}>
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
</button>
<button type="button" className="frs-btn frs-btn--quiet" onClick={handleCopyLogs}>
{copied ? '✓ ' + t('bootstrap.copied', 'Copied!') : '📋 ' + t('bootstrap.copy', 'Copy')}
</button>
</div>
{logsOpen && (
<pre className="frs-log" ref={logRef}>
{logs.length === 0
? t('bootstrap.waiting_output', 'Waiting for output…')
: logs.map((l) => `[${l.stage}] ${l.line}`).join('\n')}
</pre>
)}
</section>
<footer className="frs__foot frs-rise" style={{ '--rise': 3 }}>
<div className="frs__foot-row">
<span className="frs__totals">
<span className="frs__plate">OVS&thinsp;·&thinsp;v{APP_VERSION}</span>
</span>
</div>
</footer>
</div>
</div>
);
@@ -392,6 +474,7 @@ export function useBootstrapStage(pollMs = 1000) {
let cancelled = false;
let timer = null;
let misses = 0;
const invoke = async () => {
try {
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
@@ -408,13 +491,24 @@ export function useBootstrapStage(pollMs = 1000) {
try {
const res = await tauriInvoke('bootstrap_status');
if (cancelled) return;
misses = 0;
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
setState({ stage: res.stage || 'ready', message: res.message || null });
if (res.stage !== 'ready' && res.stage !== 'failed') {
timer = setTimeout(tick, pollMs);
}
} catch {
setState({ stage: 'ready', message: null });
// A transient IPC hiccup (e.g. the very first poll racing webview
// init) must NOT permanently declare 'ready' — that kills the poll
// loop and silently skips the awaiting_setup / progress screens.
// Retry a few times before conceding.
misses += 1;
if (cancelled) return;
if (misses < 5) {
timer = setTimeout(tick, pollMs);
} else {
setState({ stage: 'ready', message: null });
}
}
};
tick();
+928
View File
@@ -0,0 +1,928 @@
/* First-run install setup — "studio console".
*
* Desktop-first: a wide deck of rack-unit panels floating directly on an
* atmospheric backdrop (no outer chassis box). Engraved mono labels, serif
* masthead, a breathing waveform, LED capacity meters for the disk gate,
* LED option cards, and an "armed" install button.
*
* Constraints honored:
* - every font/asset is bundled (first runs may be on restricted networks)
* - all motion is transform/opacity only and respects reduced-motion
* - colors derive from the app's chrome tokens so themes stay coherent
*/
.frs {
--frs-accent: var(--chrome-accent, #e8a3b4);
--frs-ok: var(--chrome-severity-ok, #98971a);
--frs-err: var(--chrome-severity-err, #d4554a);
--frs-ink: var(--chrome-fg, #ece6dd);
--frs-bg: var(--chrome-bg, #121013);
--frs-line: color-mix(in srgb, var(--frs-ink) 11%, transparent);
--frs-line-strong: color-mix(in srgb, var(--frs-ink) 20%, transparent);
position: fixed;
inset: 0;
display: flex;
justify-content: center;
background: var(--frs-bg);
color: var(--frs-ink);
font-family: var(--font-sans, 'Inter Variable', system-ui, sans-serif);
z-index: 9999;
/* Extra top clearance: the native titlebar (GTK headerbar / macOS
traffic lights / Windows controls) overlays the top of the window —
content must start below it, never under it. */
padding: 3.4rem 2.5rem 2rem;
overflow-y: auto;
}
/* No backdrop decoration: the journey sits on a clean flat surface — corner
glows and grain read as banding/noise artifacts on many panels. The empty
.frs__atmo element is kept harmless for layout stability. */
.frs__atmo { display: none; }
/* ── Deck: the whole console, borderless, wide ─────────────────────────── */
.frs__deck {
position: relative;
width: 100%;
max-width: 1240px;
margin: auto 0; /* vertical centering when content is short */
display: flex;
flex-direction: column;
gap: 1.1rem;
min-width: 0;
}
.frs__loading {
margin: auto;
font-size: 0.85rem;
opacity: 0.7;
}
/* ── Entry choreography: everything rises in, staggered ────────────────── */
.frs-rise {
animation: frs-rise 640ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--rise, 0) * 80ms);
}
@keyframes frs-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Masthead ──────────────────────────────────────────────────────────── */
.frs__mast {
padding-bottom: 0.4rem;
}
.frs__mast-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-top: 1rem;
}
.frs__title {
margin: 0;
font-family: var(--font-serif, 'Source Serif 4 Variable', Georgia, serif);
font-size: clamp(1.7rem, 3.2vw, 2.3rem);
font-weight: 620;
letter-spacing: -0.014em;
line-height: 1.08;
}
.frs__subtitle {
margin: 0.5rem 0 0;
font-size: 0.8rem;
line-height: 1.5;
opacity: 0.62;
max-width: 62ch;
}
.frs__mast-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.45rem;
flex-shrink: 0;
}
.frs__mast-selects {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Mirrors hang under the region select they extend; the fields open as a
right-aligned column so the masthead stays balanced. */
.frs__advanced--mast { text-align: right; }
.frs__advanced--mast .frs__mirror-fields {
grid-template-columns: 1fr;
min-width: 320px;
text-align: left;
}
/* Serial: quiet engraved model/version text, no badge box. */
.frs__plate {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.62rem;
letter-spacing: 0.14em;
opacity: 0.45;
font-variant-numeric: tabular-nums;
user-select: none;
white-space: nowrap;
}
/* ── Waveform: a voice, breathing across the full width ────────────────── */
/* Whisper-quiet: a thin breathing trace, not a billboard. */
.frs-wave {
display: flex;
align-items: center;
gap: 4px;
height: 22px;
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
}
.frs-wave__bar {
flex: 1 0 auto;
width: 2px;
height: 100%;
border-radius: 1px;
background: color-mix(in srgb, var(--frs-accent) 70%, transparent);
transform: scaleY(var(--h, 0.4));
transform-origin: center;
opacity: 0.45;
animation: frs-breathe 2.8s ease-in-out infinite alternate;
animation-delay: var(--d, 0ms);
}
@keyframes frs-breathe {
from { transform: scaleY(calc(var(--h, 0.4) * 0.55)); opacity: 0.22; }
to { transform: scaleY(var(--h, 0.4)); opacity: 0.5; }
}
/* ── Wide grid: storage rail (left, 7fr) + decision rail (right, 5fr) ──── */
.frs__grid {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: 1.1rem;
align-items: start;
}
.frs__col {
display: flex;
flex-direction: column;
gap: 1.1rem;
min-width: 0;
}
/* ── Sections: no boxes — an engraved title rule and whitespace carry the
structure. Borders appear only where state demands them. ─────────────── */
.frs-panel {
position: relative;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.frs-panel__title {
margin: 0;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.22em;
opacity: 0.55;
display: flex;
align-items: center;
gap: 0.6rem;
}
/* Engraved rule running out from the title. */
.frs-panel__title::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--frs-line-strong), transparent);
}
/* ── LED option cards (mode / compute / channel) ───────────────────────── */
.frs__options {
display: grid;
gap: 0.6rem;
}
.frs__options--two { grid-template-columns: 1fr 1fr; }
.frs-opt {
position: relative;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.75rem 2rem 0.8rem 0.9rem;
text-align: left;
font: inherit;
color: inherit;
background: color-mix(in srgb, var(--frs-ink) 4%, transparent);
border: none;
border-radius: 9px;
cursor: pointer;
transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
}
.frs-opt--compact { padding-top: 0.6rem; padding-bottom: 0.65rem; }
.frs-opt:hover:not(:disabled) {
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
transform: translateY(-1px);
}
.frs-opt.is-active {
background: color-mix(in srgb, var(--frs-accent) 10%, transparent);
}
.frs-opt:disabled { opacity: 0.42; cursor: not-allowed; }
.frs-opt__led {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 7px;
height: 7px;
border-radius: 50%;
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
transition: background 160ms ease, box-shadow 160ms ease;
}
.frs-opt.is-active .frs-opt__led {
background: var(--frs-accent);
box-shadow:
0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent),
inset 0 0 2px rgba(255, 255, 255, 0.5);
}
.frs-opt__head {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.frs-opt__name { font-size: 0.82rem; font-weight: 650; letter-spacing: 0.01em; }
.frs-opt__badge {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.56rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0.1rem 0.4rem;
border-radius: 4px;
color: color-mix(in srgb, var(--frs-ok) 85%, var(--frs-ink));
background: color-mix(in srgb, var(--frs-ok) 14%, transparent);
white-space: nowrap;
}
/* Verbosity diet: the description unfolds (smoothly) only on the selected
card — collapsed cards keep it as a tooltip. */
.frs-opt__desc {
font-size: 0.7rem;
line-height: 1.45;
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease;
}
.frs-opt.is-active .frs-opt__desc {
max-height: 4.5em;
opacity: 0.6;
}
/* ── Detected hardware readout (Compute panel) ─────────────────────────── */
.frs__hw {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.1rem 0.1rem 0.3rem;
min-width: 0;
}
.frs__hw-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--frs-ok);
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-ok) 60%, transparent);
animation: frs-hw-pulse 2.2s ease-in-out infinite;
}
@keyframes frs-hw-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.frs__hw-label {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.14em;
opacity: 0.5;
flex-shrink: 0;
}
.frs__hw-value {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.66rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
/* ── Storage rows + LED capacity meters ────────────────────────────────── */
.frs-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 0.8rem;
border-radius: 9px;
transition: background 160ms ease;
}
.frs-row:hover { background: color-mix(in srgb, var(--frs-ink) 4%, transparent); }
/* Blocked: a red tint + edge bar — state, not another box. */
.frs-row--blocked {
background: color-mix(in srgb, var(--frs-err) 6%, transparent);
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--frs-err) 70%, transparent);
}
.frs-row__text {
display: flex;
flex-direction: column;
gap: 0.12rem;
min-width: 0;
flex: 1;
}
.frs-row__label { font-size: 0.8rem; font-weight: 620; }
.frs-row__path {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.66rem;
opacity: 0.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 52ch;
direction: rtl; /* ellipsize the head — the tail of a path matters */
text-align: left;
margin-top: 0.1rem;
}
.frs-row__gauge {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
min-width: 170px;
flex-shrink: 0;
}
.frs-row__gauge .frs-meter { width: 100%; }
/* One quiet line: "needs ~9 GB · 449 GB free". */
.frs-row__readout {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.64rem;
opacity: 0.55;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.frs-row__readout.is-low {
color: var(--frs-err);
opacity: 1;
font-weight: 700;
}
/* The meter: segmented LEDs. Lit = consumed by the install, dim = headroom. */
.frs-meter {
position: relative;
height: 10px;
border-radius: 3px;
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.frs-meter__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 3px 0 0 3px;
background: linear-gradient(90deg,
color-mix(in srgb, var(--frs-ok) 80%, var(--frs-ink)),
var(--frs-accent));
/* LED segmentation: 5px lit / 2px gap notches. */
-webkit-mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
transition: width 480ms cubic-bezier(0.22, 1, 0.36, 1);
}
.frs-meter--over .frs-meter__fill {
background: var(--frs-err);
animation: frs-alarm 1s steps(2, jump-none) infinite;
}
@keyframes frs-alarm {
to { opacity: 0.45; }
}
/* ── Fields ────────────────────────────────────────────────────────────── */
.frs-field {
display: flex;
flex-direction: column;
gap: 0.28rem;
font-size: 0.7rem;
min-width: 0;
}
.frs-field > span {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.5;
}
.frs-select,
.frs-input {
background: color-mix(in srgb, var(--frs-ink) 8%, transparent);
border: none;
color: inherit;
color-scheme: dark;
font: inherit;
font-size: 0.76rem;
padding: 0.45rem 0.6rem;
border-radius: 7px;
min-width: 0;
transition: background 140ms ease;
}
.frs-select:hover,
.frs-input:hover { background: color-mix(in srgb, var(--frs-ink) 12%, transparent); }
.frs-select:focus-visible,
.frs-input:focus-visible,
.frs-opt:focus-visible,
.frs-btn:focus-visible {
outline: 2px solid color-mix(in srgb, var(--frs-accent) 65%, transparent);
outline-offset: 1px;
}
.frs-select { cursor: pointer; }
.frs-select option {
background: var(--frs-bg);
color: var(--frs-ink);
}
.frs-select--lang { font-size: 0.7rem; padding: 0.3rem 0.5rem; }
.frs-input::placeholder { opacity: 0.32; }
/* ── Advanced mirrors disclosure ───────────────────────────────────────── */
.frs__advanced { min-width: 0; }
.frs__advanced summary {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.5;
cursor: pointer;
user-select: none;
padding: 0.15rem 0;
list-style: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.frs__advanced summary::-webkit-details-marker { display: none; }
.frs__advanced summary::before {
content: '▸';
font-size: 0.55rem;
transition: transform 140ms ease;
}
.frs__advanced[open] summary::before { transform: rotate(90deg); }
.frs__advanced summary:hover { opacity: 0.85; }
.frs__mirror-fields {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.6rem;
margin-top: 0.5rem;
}
/* ── Footer: gate + armed button ───────────────────────────────────────── */
.frs__foot {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding-top: 0.6rem;
}
.frs__foot-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.frs__totals {
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.72rem;
opacity: 0.55;
font-variant-numeric: tabular-nums;
}
.frs__totals-sep { opacity: 0.5; }
.frs__blocker {
margin: 0;
font-size: 0.74rem;
color: var(--frs-err);
}
.frs__error {
margin: 0;
padding: 0.5rem 0.7rem;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.66rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--frs-err);
background: color-mix(in srgb, var(--frs-err) 8%, transparent);
border-radius: 8px;
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--frs-err) 70%, transparent);
}
/* ── Buttons ───────────────────────────────────────────────────────────── */
.frs-btn {
font: inherit;
font-size: 0.78rem;
font-weight: 650;
padding: 0.5rem 1.15rem;
border-radius: 9px;
border: 1px solid transparent;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease,
opacity 140ms ease, box-shadow 240ms ease, transform 140ms ease;
}
.frs-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.frs-btn--primary {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--frs-accent);
color: var(--frs-bg);
padding: 0.55rem 1.4rem;
}
.frs-btn__led {
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--frs-bg) 55%, transparent);
transition: background 200ms ease, box-shadow 200ms ease;
}
/* Armed: the button is live — LED lights, halo pulses. The single loudest
element on screen, exactly when it becomes actionable. */
.frs-btn--primary.is-armed .frs-btn__led {
background: var(--frs-bg);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-bg) 60%, transparent);
}
.frs-btn--primary.is-armed:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--frs-accent) 88%, white);
}
/* Quiet picker: reads as a text action until pointed at. */
.frs-btn--quiet {
background: transparent;
color: inherit;
opacity: 0.5;
font-weight: 500;
padding: 0.34rem 0.6rem;
font-size: 0.68rem;
flex-shrink: 0;
}
.frs-btn--quiet:hover:not(:disabled) {
opacity: 1;
background: color-mix(in srgb, var(--frs-ink) 9%, transparent);
}
/* ── Reduced motion: hold every frame still ────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.frs-rise { animation: none; }
.frs-wave__bar { animation: none; }
.frs-meter--over .frs-meter__fill { animation: none; }
.frs__hw-dot { animation: none; }
.frs-meter__fill { transition: none; }
}
/* ── Responsive: deck collapses gracefully ─────────────────────────────── */
@media (max-width: 980px) {
.frs { padding: 1.5rem; }
.frs__grid { grid-template-columns: 1fr; }
.frs__mirror-fields { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
.frs { padding: 1rem; }
.frs__mast-row { flex-direction: column; align-items: flex-start; gap: 0.8rem; }
.frs__options--two { grid-template-columns: 1fr; }
.frs-row { flex-wrap: wrap; }
.frs-row__gauge { width: 100%; }
.frs__foot-row { flex-direction: column; align-items: stretch; }
.frs-btn--primary { justify-content: center; }
}
/* ════════════════════════════════════════════════════════════════════════
Shared first-run journey pieces — used by the installing screen
(BootstrapSplash) and the model wizard (SetupWizard) so the whole
setup → install → models flow speaks one visual language.
═══════════════════════════════════════════════════════════════════════ */
/* Focused acts (installing) read better narrow. */
.frs__deck--focus { max-width: 760px; }
/* ── LED step rail ─────────────────────────────────────────────────────── */
.frs-steps {
list-style: none;
margin: 0.2rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.frs-step {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.78rem;
min-width: 0;
}
.frs-step__led {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
transition: background 200ms ease, box-shadow 200ms ease;
}
.frs-step.is-done .frs-step__led {
background: var(--frs-ok);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
}
.frs-step.is-active .frs-step__led {
background: var(--frs-accent);
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
animation: frs-hw-pulse 1.6s ease-in-out infinite;
}
.frs-step.is-done .frs-step__label { opacity: 0.55; }
.frs-step.is-active .frs-step__label { font-weight: 650; }
.frs-step.is-pending { opacity: 0.45; }
.frs-step__bytes {
margin-left: auto;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.64rem;
opacity: 0.6;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Progress variant of the LED meter — taller, journey-wide. */
.frs-meter--progress { height: 8px; }
/* ── Live log panel ────────────────────────────────────────────────────── */
.frs-log__bar {
display: flex;
align-items: center;
gap: 0.4rem;
}
.frs-log__meta {
margin-left: auto;
font-size: 0.6rem;
letter-spacing: 0.08em;
opacity: 0.8;
}
.frs-log {
margin: 0;
padding: 0.6rem 0.75rem;
max-height: 220px;
overflow-y: auto;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.64rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
color: color-mix(in srgb, var(--frs-ink) 70%, transparent);
background: color-mix(in srgb, var(--frs-bg) 55%, transparent);
border-radius: 8px;
}
/* ── Inline banner (e.g. language suggestion) ──────────────────────────── */
.frs-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.5rem 0.8rem;
border-radius: 9px;
font-size: 0.76rem;
background: color-mix(in srgb, var(--frs-accent) 8%, transparent);
}
.frs-banner__actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.frs-banner__actions--end { justify-content: flex-end; margin-top: 0.3rem; }
/* ── Failure hints ─────────────────────────────────────────────────────── */
.frs-hints { font-size: 0.74rem; line-height: 1.5; }
.frs-hints__label { font-weight: 650; }
.frs-hints ul {
margin: 0.35rem 0 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
opacity: 0.8;
}
/* ── Wizard chrome (model/engine selection act) ────────────────────────── */
.frs-wsteps {
display: flex;
align-items: center;
gap: 0.9rem;
flex-wrap: wrap;
}
.frs-wstep {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: inherit;
opacity: 0.45;
background: transparent;
border: none;
padding: 0.25rem 0.1rem;
cursor: pointer;
transition: opacity 140ms ease;
}
.frs-wstep:hover { opacity: 0.8; }
.frs-wstep.is-active { opacity: 1; }
.frs-wstep.is-done { opacity: 0.7; }
.frs-wstep__led {
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
}
.frs-wstep.is-active .frs-wstep__led {
background: var(--frs-accent);
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
}
.frs-wstep.is-done .frs-wstep__led {
background: var(--frs-ok);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
}
/* Journey rail on setup/install acts: a quiet breadcrumb of the three
stages, between the waveform and the headline. Non-interactive spans. */
.frs-wsteps--journey {
margin-top: 0.8rem;
gap: 1.2rem;
}
.frs-wsteps--journey .frs-wstep { cursor: default; }
/* Embedded app panels (Model Store / Engines) keep their own internals;
this shell just gives them breathing room inside the act. */
.frs-embed { min-width: 0; }
.frs-wnav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
}
.frs-wnav__group { display: flex; align-items: center; gap: 0.5rem; }
/* Status check rows (preflight) — same row language as storage. */
.frs-check {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
border-radius: 9px;
}
.frs-check:hover { background: color-mix(in srgb, var(--frs-ink) 4%, transparent); }
.frs-check__led {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 0.35rem;
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
}
.frs-check--pass .frs-check__led {
background: var(--frs-ok);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
}
.frs-check--warn .frs-check__led {
background: var(--chrome-severity-warn, #d79921);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--chrome-severity-warn, #d79921) 50%, transparent);
}
.frs-check--fail .frs-check__led {
background: var(--frs-err);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-err) 50%, transparent);
}
.frs-check__body { display: flex; flex-direction: column; gap: 0.12rem; min-width: 0; }
.frs-check__title { font-size: 0.78rem; font-weight: 620; }
.frs-check__detail { font-size: 0.7rem; opacity: 0.6; line-height: 1.45; }
.frs-check__fix { font-size: 0.7rem; line-height: 1.45; }
.frs-check--fail .frs-check__fix { color: var(--frs-err); }
.frs-check--warn .frs-check__fix { color: var(--chrome-severity-warn, #d79921); }
+574
View File
@@ -0,0 +1,574 @@
/**
* First-run install setup screen — "studio console" treatment.
*
* Rendered by BootstrapSplash while the Rust side is parked in the
* `awaiting_setup` stage — nothing has been downloaded or installed yet.
* The user picks install mode (installed/portable), storage locations,
* compute variant, network mirrors and update channel; every chosen
* directory is live-checked for free space against the minimum the install
* needs (Rust re-validates on submit — the UI gate is a mirror, not the
* authority). "Start installation" is the only thing that kicks off the
* bootstrap.
*
* Design language: powering on a piece of studio hardware. Serif masthead
* (Source Serif 4), engraved mono panel labels (IBM Plex Mono), a breathing
* waveform, and disk space rendered as LED capacity meters. Desktop-first:
* a wide two-column deck of rack panels floating directly on the backdrop
* (no outer chassis box), collapsing to one column on narrow windows. All
* motion is CSS-only (transform/opacity) and honors prefers-reduced-motion;
* every asset is bundled — a first run may be on a restricted network.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n, { LANGUAGES } from '../i18n';
import { useAppStore } from '../store';
import './FirstRunSetup.css';
const APP_VERSION = __APP_VERSION__ || '0.0.0';
const GIB = 1024 * 1024 * 1024;
const fmtGB = (bytes) =>
bytes == null ? '—' : `${(bytes / GIB).toFixed(bytes < 10 * GIB ? 1 : 0)} GB`;
const invoke = async (...args) => {
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
return tauriInvoke(...args);
};
/** Debounced live probe of one install target (free space / writability). */
function useTargetCheck(path) {
const [check, setCheck] = useState(null);
useEffect(() => {
if (!path) { setCheck(null); return; }
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await invoke('check_install_target', { path });
if (!cancelled) setCheck(res);
} catch { if (!cancelled) setCheck(null); }
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [path]);
return check;
}
/** Breathing waveform masthead — bar heights are stable per mount. */
function Waveform({ bars = 96 }) {
const heights = useMemo(
() => Array.from({ length: bars }, (_, i) => {
// Deterministic pseudo-random silhouette: layered sines read as speech
// cadence (syllables + phrase envelope) rather than white noise.
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
);
return 0.18 + v * 0.82;
}),
[bars],
);
return (
<div className="frs-wave" aria-hidden="true">
{heights.map((h, i) => (
<span
key={i}
className="frs-wave__bar"
style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }}
/>
))}
</div>
);
}
/**
* LED capacity meter: how much of the volume's free space this install
* consumes. Lit = consumed by the install, dim = remaining headroom.
* Overflows (need > free) clamp to full and switch to the alarm color.
*/
function CapacityMeter({ need, free }) {
const ratio = free > 0 ? need / free : 1;
const pct = Math.min(100, Math.max(3, ratio * 100));
return (
<div
className={`frs-meter ${ratio > 1 ? 'frs-meter--over' : ''}`}
role="img"
aria-label={`${fmtGB(need)} / ${fmtGB(free)}`}
>
<span className="frs-meter__fill" style={{ width: `${pct}%` }} />
</div>
);
}
/** One storage location row: label, path, space readout, Change… picker.
* The LED meter only appears when it carries information — the disk is
* getting tight (install would consume >35% of free space) or blocked.
* At 449 GB free vs 9 GB needed a bar is a meaningless sliver; a quiet
* one-line readout is cleaner. */
function StorageRow({ label, desc, path, need, check, onPick }) {
const { t } = useTranslation();
const lowSpace = check?.freeBytes != null && check.freeBytes < need;
const notWritable = check && !check.writable;
const blocked = lowSpace || notWritable;
const tight = check?.freeBytes != null && need / check.freeBytes > 0.35;
return (
<div className={`frs-row ${blocked ? 'frs-row--blocked' : ''}`}>
<div className="frs-row__text" title={desc}>
<span className="frs-row__label">{label}</span>
<code className="frs-row__path" title={path}>{path}</code>
</div>
<div className="frs-row__gauge">
{(blocked || tight) && check?.freeBytes != null && (
<CapacityMeter need={need} free={check.freeBytes} />
)}
<span className={`frs-row__readout ${lowSpace ? 'is-low' : ''}`}>
{check == null
? t('firstrun.checking', 'checking…')
: notWritable
? t('firstrun.not_writable', 'not writable')
: <>
{t('firstrun.needs', { size: fmtGB(need), defaultValue: 'needs ~{{size}}' })}
{' · '}
{t('firstrun.free', { size: fmtGB(check.freeBytes), defaultValue: '{{size}} free' })}
</>}
</span>
</div>
{onPick && (
<button type="button" className="frs-btn frs-btn--quiet" onClick={onPick}>
{t('firstrun.change', 'Change…')}
</button>
)}
</div>
);
}
/** Section: engraved mono title + rule — structure by line, not by box. */
function Panel({ title, delay, className = '', children }) {
return (
<section className={`frs-panel frs-rise ${className}`} style={{ '--rise': delay }}>
<h2 className="frs-panel__title">{title}</h2>
{children}
</section>
);
}
/** LED radio option — used for install mode, compute and update channel.
* Verbosity diet: the description unfolds only on the selected card; the
* rest expose it as a tooltip. One expanded card per group keeps the page
* calm without hiding information. */
function OptionCard({ active, disabled, onSelect, name, desc, badge, compact }) {
return (
<button
type="button"
role="radio"
aria-checked={active}
className={`frs-opt ${compact ? 'frs-opt--compact' : ''} ${active ? 'is-active' : ''}`}
disabled={disabled}
title={active ? undefined : desc}
onClick={() => !disabled && onSelect()}
>
<span className="frs-opt__led" aria-hidden="true" />
<span className="frs-opt__head">
<span className="frs-opt__name">{name}</span>
{badge && <span className="frs-opt__badge">{badge}</span>}
</span>
<span className="frs-opt__desc">{desc}</span>
</button>
);
}
export default function FirstRunSetup() {
const { t } = useTranslation();
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const [setup, setSetup] = useState(null); // get_setup_state payload
const [plan, setPlan] = useState(null); // user's editable choices
const [submitting, setSubmitting] = useState(false);
const [serverError, setServerError] = useState(null);
const mounted = useRef(true);
useEffect(() => () => { mounted.current = false; }, []);
useEffect(() => {
(async () => {
try {
const s = await invoke('get_setup_state');
if (!mounted.current) return;
setSetup(s);
setPlan({
installMode: s.portable.available && s.defaults.installMode === 'portable' ? 'portable' : 'installed',
envDir: s.defaults.envDir,
dataDir: s.defaults.dataDir,
modelsDir: s.defaults.modelsDir,
region: s.defaults.region,
updateChannel: s.defaults.updateChannel,
// Pre-select ROCm when the machine looks AMD — detection is shown
// on the card, and the user can always flip back to Auto.
torchVariant: s.hardware?.kind === 'rocm' ? 'rocm' : s.defaults.torchVariant,
mirrors: { pypiIndex: '', hfEndpoint: '', pythonDownloads: '' },
});
} catch (e) {
if (mounted.current) setServerError(String(e));
}
})();
}, []);
const portable = plan?.installMode === 'portable';
const req = setup?.requirements;
const hw = setup?.hardware;
const combinedNeed = req ? req.envBytes + req.modelsBytes + req.dataBytes : 0;
// Live target probes — in portable mode only the anchor folder matters.
const portableBase = setup?.portable?.baseDir || '';
const envCheck = useTargetCheck(portable ? null : plan?.envDir);
const dataCheck = useTargetCheck(portable ? null : plan?.dataDir);
const modelsCheck = useTargetCheck(portable ? null : plan?.modelsDir);
const portableCheck = useTargetCheck(portable ? portableBase : null);
// Mirror of the Rust gate: group targets by filesystem, sum requirements,
// block when any volume falls short or isn't writable.
const blockers = useMemo(() => {
if (!plan || !req) return [{ key: 'loading' }];
const targets = portable
? [{ check: portableCheck, need: combinedNeed, label: portableBase }]
: [
{ check: envCheck, need: req.envBytes, label: plan.envDir },
{ check: dataCheck, need: req.dataBytes, label: plan.dataDir },
{ check: modelsCheck, need: req.modelsBytes, label: plan.modelsDir },
];
if (targets.some((x) => x.check == null)) return [{ key: 'loading' }];
const out = [];
for (const { check, label } of targets) {
if (!check.writable) out.push({ key: 'not_writable', label });
}
const byFs = new Map();
for (const { check, need } of targets) {
const k = check.fsKey || check.path;
const cur = byFs.get(k) || { need: 0, free: check.freeBytes };
cur.need += need;
cur.free = Math.min(cur.free ?? Infinity, check.freeBytes ?? Infinity);
byFs.set(k, cur);
}
for (const { need, free } of byFs.values()) {
if (free != null && free < need) out.push({ key: 'space', need, free });
}
return out;
}, [plan, req, portable, portableBase, combinedNeed, envCheck, dataCheck, modelsCheck, portableCheck]);
const pickDir = useCallback(async (field) => {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const dir = await open({ directory: true, defaultPath: plan?.[field] || undefined });
if (typeof dir === 'string' && dir) setPlan((p) => ({ ...p, [field]: dir }));
} catch (e) { console.error('folder pick failed', e); }
}, [plan]);
const set = useCallback((patch) => setPlan((p) => ({ ...p, ...patch })), []);
const start = useCallback(async () => {
if (!plan || submitting) return;
setSubmitting(true);
setServerError(null);
try {
const clean = (s) => (s && s.trim() ? s.trim() : null);
await invoke('complete_setup', {
plan: {
installMode: plan.installMode,
envDir: clean(plan.envDir),
dataDir: clean(plan.dataDir),
modelsDir: clean(plan.modelsDir),
region: plan.region,
locale,
updateChannel: plan.updateChannel,
torchVariant: plan.torchVariant,
mirrors: {
pypiIndex: clean(plan.mirrors.pypiIndex),
hfEndpoint: clean(plan.mirrors.hfEndpoint),
pythonDownloads: clean(plan.mirrors.pythonDownloads),
},
},
});
// Success: the stage poll in App.jsx leaves `awaiting_setup` and the
// normal bootstrap progress UI takes over. Nothing to do here.
} catch (e) {
if (mounted.current) { setServerError(String(e)); setSubmitting(false); }
}
}, [plan, submitting, locale]);
if (!setup || !plan) {
return (
<div className="frs">
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__loading">
{serverError
? <pre className="frs__error">{serverError}</pre>
: t('firstrun.loading', 'Preparing setup…')}
</div>
</div>
);
}
const blocked = blockers.length > 0;
const spaceBlocker = blockers.find((b) => b.key === 'space');
// The full machine identity — OS/distro · arch · GPU · CPU · RAM — the
// exact matrix cell this install is for (and what bug reports cite).
const hwLine = hw
? [
[hw.osName, hw.arch].filter(Boolean).join(' '),
hw.gpu,
hw.cpuCores ? `${hw.cpuCores}×CPU` : null,
hw.ramGb ? `${hw.ramGb} GB RAM` : null,
].filter(Boolean).join(' · ')
: null;
// ROCm wheels are Linux-only — never offer a choice that can't work on
// this platform (Rust clamps it server-side too).
const rocmAvailable = setup.os === 'linux';
return (
<div className="frs">
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__deck">
{/* ── Masthead: waveform + serif headline + serial plate ────────── */}
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
<Waveform />
{/* Journey rail: this page is stage 1 of the install flow. */}
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('firstrun.title', 'Set up OmniVoice Studio')}>
<span className="frs-wstep is-active">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_setup', 'Setup')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.installing_title', 'Installing')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_models', 'Models & engines')}
</span>
</nav>
<div className="frs__mast-row">
<div className="frs__mast-text">
<h1 className="frs__title">{t('firstrun.title', 'Set up OmniVoice Studio')}</h1>
<p className="frs__subtitle">
{t('firstrun.subtitle', 'Nothing is installed yet — review where everything goes, then start. You can change these later in Settings.')}
</p>
</div>
<div className="frs__mast-meta">
{/* Language + download region live together: the two "where am
I" choices, settled before anything else. Custom mirrors
hang quietly beneath them, where they belong. */}
<div className="frs__mast-selects">
<select
className="frs-select frs-select--lang"
value={locale}
onChange={(e) => { setLocale(e.target.value); i18n.changeLanguage(e.target.value); }}
aria-label={t('firstrun.language', 'Language')}
>
{LANGUAGES.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
<select
className="frs-select frs-select--lang"
value={plan.region}
onChange={(e) => set({ region: e.target.value })}
aria-label={t('firstrun.region_label', 'Download region')}
>
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
<option value="global">🌐 {t('bootstrap.region_global', 'Global (direct)')}</option>
<option value="china">🇨🇳 {t('bootstrap.region_china', 'China (mirror)')}</option>
<option value="russia">🇷🇺 {t('bootstrap.region_russia', 'Russia (mirror)')}</option>
<option value="restricted">🌍 {t('bootstrap.region_restricted', 'Restricted (mirror)')}</option>
</select>
</div>
<details className="frs__advanced frs__advanced--mast">
<summary>{t('firstrun.mirrors_title', 'Custom mirrors (advanced)')}</summary>
<div className="frs__mirror-fields">
{[
['pypiIndex', t('firstrun.mirror_pypi', 'PyPI index URL'), 'https://mirrors.aliyun.com/pypi/simple/'],
['hfEndpoint', t('firstrun.mirror_hf', 'Hugging Face endpoint'), 'https://hf-mirror.com'],
['pythonDownloads', t('firstrun.mirror_python', 'Python downloads mirror'), 'https://gh-proxy.com/…'],
].map(([field, label, ph]) => (
<label key={field} className="frs-field">
<span>{label}</span>
<input
className="frs-input"
type="url"
placeholder={ph}
value={plan.mirrors[field]}
onChange={(e) => set({ mirrors: { ...plan.mirrors, [field]: e.target.value } })}
/>
</label>
))}
</div>
</details>
</div>
</div>
</header>
{/* ── Wide deck: storage rail (left) + decision rail (right) ────── */}
<div className="frs__grid">
<div className="frs__col frs__col--main">
<Panel title={t('firstrun.mode_title', 'Install mode')} delay={1}>
<div className="frs__options frs__options--two" role="radiogroup">
<OptionCard
active={!portable}
onSelect={() => set({ installMode: 'installed' })}
name={t('firstrun.mode_installed', 'Installed')}
desc={t('firstrun.mode_installed_desc', 'Uses standard system folders. Recommended for most users.')}
/>
<OptionCard
active={portable}
disabled={!setup.portable.available}
onSelect={() => set({ installMode: 'portable' })}
name={t('firstrun.mode_portable', 'Portable')}
desc={setup.portable.available
? t('firstrun.mode_portable_desc', 'Everything lives in one folder next to the app — move it to another disk or machine as a unit.')
: t('firstrun.mode_portable_unavailable', 'Unavailable: the folder next to the app is not writable.')}
/>
</div>
</Panel>
<Panel title={t('firstrun.storage_title', 'Storage')} delay={2}>
{portable ? (
<StorageRow
label={t('firstrun.portable_folder', 'Portable folder')}
desc={t('firstrun.portable_folder_desc', 'App environment, models, and your voice data — one folder, fully movable.')}
path={portableBase}
need={combinedNeed}
check={portableCheck}
/>
) : (
<>
<StorageRow
label={t('firstrun.env_dir', 'App environment')}
desc={t('firstrun.env_dir_desc', 'Python runtime and AI libraries.')}
path={plan.envDir}
need={req.envBytes}
check={envCheck}
onPick={() => pickDir('envDir')}
/>
<StorageRow
label={t('firstrun.data_dir', 'Voice data & projects')}
desc={t('firstrun.data_dir_desc', 'Your voices, dubs, outputs and project database.')}
path={plan.dataDir}
need={req.dataBytes}
check={dataCheck}
onPick={() => pickDir('dataDir')}
/>
<StorageRow
label={t('firstrun.models_dir', 'Model cache')}
desc={t('firstrun.models_dir_desc', 'Downloaded AI models — the largest and most relocatable part.')}
path={plan.modelsDir}
need={req.modelsBytes}
check={modelsCheck}
onPick={() => pickDir('modelsDir')}
/>
</>
)}
</Panel>
</div>
<div className="frs__col frs__col--side">
<Panel title={t('firstrun.compute_title', 'Compute')} delay={2}>
{hwLine && (
<div className="frs__hw" title={hwLine}>
<span className="frs__hw-dot" aria-hidden="true" />
<span className="frs__hw-label">
{t('firstrun.compute_detected', { defaultValue: 'Detected' })}
</span>
<span className="frs__hw-value">{hwLine}</span>
</div>
)}
<div className="frs__options" role="radiogroup">
<OptionCard
compact
active={plan.torchVariant === 'auto'}
onSelect={() => set({ torchVariant: 'auto' })}
name={t('firstrun.compute_auto', 'Auto (NVIDIA CUDA / Apple MPS / CPU)')}
desc={t('firstrun.compute_auto_desc', 'Picks the best backend on this machine at runtime — CUDA on NVIDIA, MPS on Apple Silicon, CPU otherwise.')}
badge={hw?.kind === 'cuda' || hw?.kind === 'mps'
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
: null}
/>
{rocmAvailable && (
<OptionCard
compact
active={plan.torchVariant === 'rocm'}
onSelect={() => set({ torchVariant: 'rocm' })}
name={t('firstrun.compute_rocm', 'AMD GPU (ROCm, Linux)')}
desc={t('firstrun.compute_rocm_desc', 'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.')}
badge={hw?.kind === 'rocm'
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
: null}
/>
)}
</div>
</Panel>
<Panel title={t('firstrun.channel_label', 'Update channel')} delay={3}>
<div className="frs__options" role="radiogroup">
<OptionCard
compact
active={plan.updateChannel === 'stable'}
onSelect={() => set({ updateChannel: 'stable' })}
name={t('firstrun.channel_stable', 'Stable')}
desc={t('firstrun.channel_stable_desc', 'Tested releases only — updates arrive after community validation.')}
/>
<OptionCard
compact
active={plan.updateChannel === 'preview'}
onSelect={() => set({ updateChannel: 'preview' })}
name={t('firstrun.channel_preview', 'Preview (latest main)')}
desc={t('firstrun.channel_preview_desc', 'Rolling builds from the latest main — new engines and fixes first, occasional rough edges.')}
/>
</div>
</Panel>
</div>
</div>
{/* ── Footer: gate + arm ────────────────────────────────────────── */}
<footer className="frs__foot frs-rise" style={{ '--rise': 5 }}>
{serverError && <pre className="frs__error">{serverError}</pre>}
{spaceBlocker && (
<p className="frs__blocker">
{t('firstrun.insufficient_space', {
need: fmtGB(spaceBlocker.need),
free: fmtGB(spaceBlocker.free),
defaultValue: 'Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.',
})}
</p>
)}
{blockers.some((b) => b.key === 'not_writable') && (
<p className="frs__blocker">
{t('firstrun.blocked_not_writable', 'A chosen folder is not writable — pick a different location.')}
</p>
)}
<div className="frs__foot-row">
<span className="frs__totals">
<span className="frs__plate">OVS&thinsp;·&thinsp;v{APP_VERSION}</span>
<span className="frs__totals-sep" aria-hidden="true"></span>
{t('firstrun.total_required', {
size: fmtGB(combinedNeed),
defaultValue: 'Total disk needed: ~{{size}} (one-time download on first use)',
})}
</span>
<button
type="button"
className={`frs-btn frs-btn--primary ${!blocked && !submitting ? 'is-armed' : ''}`}
disabled={blocked || submitting}
onClick={start}
>
<span className="frs-btn__led" aria-hidden="true" />
{submitting
? t('firstrun.starting', 'Starting…')
: t('firstrun.start', 'Start installation')}
</button>
</div>
</footer>
</div>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* WizardLibrary — the first-run "stock the studio" act as ONE unified list.
*
* Models and engines are different things (weights vs backends), but the
* user's question is singular — "what do I need to get?" — so every
* installable is a row of the same grammar:
*
* LED · name · chip (required / engine / optional) · size · one action
*
* Required models lead (they gate the wizard's continue), the TTS engines
* follow (Use = switch, heavy installs deferred to Settings), and the long
* tail of optional models folds behind a quiet count. Live download
* progress rides the same SSE stream the Settings model store uses; the
* full management surface (search, HF token, deletes) stays in Settings —
* a first run needs a checklist, not a store.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { useModels, useInstallModel } from '../api/hooks';
import { setupDownloadStreamUrl } from '../api/setup';
import { listEngines, selectEngine } from '../api/engines';
const fmtGB = (gb) => (gb == null ? '' : `${gb.toFixed(gb < 10 ? 1 : 0)} GB`);
/** Aggregate one repo's SSE file events into a single 0100 number. */
function aggregatePct(files) {
let done = 0;
let total = 0;
for (const f of Object.values(files)) {
done += f.downloaded || 0;
total += f.total || 0;
}
return total > 0 ? Math.min(100, Math.round((done / total) * 100)) : null;
}
function Row({ led, name, chip, chipTone, size, action, sub }) {
return (
<div className="frs-row swiz-lib__row">
<span className={`swiz-lib__led swiz-lib__led--${led}`} aria-hidden="true" />
<div className="frs-row__text">
<span className="frs-row__label">
{name}
{chip && <span className={`frs-opt__badge swiz-lib__chip swiz-lib__chip--${chipTone}`}>{chip}</span>}
</span>
{sub && <span className="swiz-lib__sub">{sub}</span>}
</div>
<span className="frs-row__readout">{size}</span>
{action}
</div>
);
}
export default function WizardLibrary() {
const { t } = useTranslation();
const modelsQuery = useModels();
const installMutation = useInstallModel();
const [engines, setEngines] = useState(null);
const [progress, setProgress] = useState({}); // { repo_id: { phase, files } }
const [showTail, setShowTail] = useState(false);
const [switching, setSwitching] = useState(null);
const esRef = useRef(null);
const models = useMemo(() => {
const list = modelsQuery.data;
return Array.isArray(list) ? list : (list?.models ?? []);
}, [modelsQuery.data]);
// Engines: TTS family only on first run — the family the studio speaks with.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const all = await listEngines();
if (!cancelled) setEngines(all?.tts ?? null);
} catch { /* backend mid-boot — the wizard polls models anyway */ }
})();
return () => { cancelled = true; };
}, []);
// One SSE stream for all rows (same channel the Settings store uses).
useEffect(() => {
const es = new EventSource(setupDownloadStreamUrl());
esRef.current = es;
es.onmessage = (evt) => {
try {
const ev = JSON.parse(evt.data);
if (!ev?.repo_id) return;
setProgress((prev) => {
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
if (ev.phase === 'install_start') return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
if (ev.phase === 'install_done' || ev.phase === 'install_error') {
if (ev.phase === 'install_done') modelsQuery.refetch();
const next = { ...prev };
delete next[ev.repo_id];
return next;
}
if (!ev.filename) return prev;
const files = { ...cur.files, [ev.filename]: { downloaded: ev.downloaded || 0, total: ev.total || 0 } };
return { ...prev, [ev.repo_id]: { ...cur, files } };
});
} catch { /* keepalive */ }
};
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const install = (repoId) => {
setProgress((p) => ({ ...p, [repoId]: { phase: 'active', files: {} } }));
installMutation.mutate(repoId, {
onError: (e) => {
toast.error(e?.message || 'install failed');
setProgress((p) => { const n = { ...p }; delete n[repoId]; return n; });
},
});
};
const useEngine = async (id) => {
setSwitching(id);
try {
const r = await selectEngine('tts', id);
setEngines((e) => (e ? { ...e, active: r.active } : e));
} catch (e) {
toast.error(e?.message || 'switch failed');
} finally {
setSwitching(null);
}
};
const supported = models.filter((m) => m.supported !== false);
const required = supported.filter((m) => m.required);
const optional = supported.filter((m) => !m.required);
const modelRow = (m, chip, chipTone) => {
const p = progress[m.repo_id];
const pct = p ? aggregatePct(p.files) : null;
const downloading = !!p;
return (
<Row
key={m.repo_id}
led={m.installed ? 'ok' : downloading ? 'busy' : 'off'}
name={m.label}
chip={chip}
chipTone={chipTone}
size={fmtGB(m.size_gb)}
sub={downloading ? (
<span className="swiz-lib__bar"><span style={{ width: `${pct ?? 4}%` }} /></span>
) : null}
action={m.installed ? (
<span className="swiz-lib__state"></span>
) : downloading ? (
<span className="swiz-lib__state swiz-lib__state--busy">
{pct != null ? `${pct}%` : t('firstrun.lib_downloading', 'downloading…')}
</span>
) : (
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__act" onClick={() => install(m.repo_id)}>
{t('firstrun.lib_download', 'Download')}
</button>
)}
/>
);
};
return (
<div className="swiz-lib">
{required.map((m) => modelRow(m, t('firstrun.chip_required', 'required'), 'req'))}
{(engines?.backends ?? []).map((b) => (
<Row
key={b.id}
led={b.id === engines.active ? 'active' : b.available ? 'ok' : 'off'}
name={b.display_name}
chip={t('firstrun.chip_engine', 'engine')}
chipTone="eng"
size=""
action={b.id === engines.active ? (
<span className="swiz-lib__state swiz-lib__state--active">{t('firstrun.lib_active', 'active')}</span>
) : b.available ? (
<button
type="button"
className="frs-btn frs-btn--quiet swiz-lib__act"
disabled={switching === b.id}
onClick={() => useEngine(b.id)}
>
{t('firstrun.lib_use', 'Use')}
</button>
) : (
<span className="swiz-lib__state" title={b.reason || undefined}>
{t('firstrun.lib_in_settings', 'install later in Settings')}
</span>
)}
/>
))}
{optional.length > 0 && !showTail && (
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__more" onClick={() => setShowTail(true)}>
{t('firstrun.lib_show_all', { count: optional.length, defaultValue: 'Show {{count}} optional models' })}
</button>
)}
{showTail && optional.map((m) => modelRow(m, t('firstrun.chip_optional', 'optional'), 'opt'))}
</div>
);
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "لم يتم العثور على أي إصدارات",
"load_error": "تعذر تحميل الإصدارات (غير متصل؟)",
"retry_load": "أعد المحاولة"
},
"firstrun": {
"loading": "جارٍ تجهيز الإعداد…",
"title": "إعداد OmniVoice Studio",
"subtitle": "لم يتم تثبيت أي شيء بعد — راجع أماكن حفظ كل شيء ثم ابدأ. يمكنك تغييرها لاحقًا من الإعدادات.",
"language": "اللغة",
"mode_title": "وضع التثبيت",
"mode_installed": "مثبَّت",
"mode_installed_desc": "يستخدم مجلدات النظام القياسية. يُنصح به لمعظم المستخدمين.",
"mode_portable": "محمول",
"mode_portable_desc": "كل شيء في مجلد واحد بجوار التطبيق — انقله كوحدة واحدة إلى قرص أو جهاز آخر.",
"mode_portable_unavailable": "غير متاح: المجلد بجوار التطبيق غير قابل للكتابة.",
"storage_title": "التخزين",
"portable_folder": "المجلد المحمول",
"portable_folder_desc": "بيئة التشغيل والنماذج وبيانات صوتك — مجلد واحد قابل للنقل بالكامل.",
"env_dir": "بيئة التطبيق",
"env_dir_desc": "بيئة Python ومكتبات الذكاء الاصطناعي.",
"data_dir": "بيانات الصوت والمشاريع",
"data_dir_desc": "أصواتك ودبلجاتك ومخرجاتك وقاعدة بيانات المشاريع.",
"models_dir": "ذاكرة النماذج المؤقتة",
"models_dir_desc": "نماذج الذكاء الاصطناعي المنزَّلة — الجزء الأكبر والأسهل في النقل.",
"needs": "يحتاج ~{{size}}",
"free": "متاح {{size}}",
"checking": "جارٍ الفحص…",
"not_writable": "غير قابل للكتابة",
"change": "تغيير…",
"compute_title": "المعالجة",
"compute_label": "GPU / مسرّع",
"compute_auto": "تلقائي (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "بطاقة AMD (ROCm، Linux)",
"channel_label": "قناة التحديث",
"channel_stable": "مستقرة",
"channel_preview": "معاينة (أحدث main)",
"network_title": "الشبكة",
"region_label": "منطقة التنزيل",
"mirrors_title": "مرايا مخصصة (متقدم)",
"mirror_pypi": "عنوان فهرس PyPI",
"mirror_hf": "نقطة نهاية Hugging Face",
"mirror_python": "مرآة تنزيلات Python",
"insufficient_space": "المساحة غير كافية: يحتاج هذا التوزيع إلى ~{{need}} على قرص واحد، والمتاح {{free}} فقط. اختر موقعًا آخر.",
"blocked_not_writable": "أحد المجلدات المختارة غير قابل للكتابة — اختر موقعًا آخر.",
"total_required": "إجمالي المساحة المطلوبة: ~{{size}} (تنزيل لمرة واحدة عند أول استخدام)",
"start": "بدء التثبيت",
"starting": "جارٍ البدء…",
"compute_detected": "تم الاكتشاف",
"compute_match": "يطابق هذا الجهاز",
"compute_auto_desc": "يختار أفضل واجهة خلفية لهذا الجهاز عند التشغيل — CUDA على NVIDIA وMPS على Apple Silicon وإلا CPU.",
"compute_rocm_desc": "يثبّت حزم PyTorch ROCm لبطاقات AMD على Linux. اتركه على تلقائي إن لم تكن متأكدًا.",
"channel_stable_desc": "إصدارات مُختبرة فقط — تصل التحديثات بعد تحقق المجتمع.",
"channel_preview_desc": "بنى متجددة من أحدث main — محركات وإصلاحات جديدة أولًا، مع خشونة طفيفة أحيانًا.",
"installing_title": "جارٍ التثبيت",
"activity_title": "النشاط",
"stage_setup": "الإعداد",
"stage_models": "النماذج والمحركات",
"chip_required": "مطلوب",
"chip_optional": "اختياري",
"chip_engine": "محرك",
"lib_download": "تنزيل",
"lib_downloading": "جارٍ التنزيل…",
"lib_use": "استخدام",
"lib_active": "نشط",
"lib_in_settings": "ثبّته لاحقًا من الإعدادات",
"lib_show_all": "عرض {{count}} نموذجًا اختياريًا"
}
}
}
+64 -1
View File
@@ -1598,5 +1598,68 @@
"none": "Keine Veröffentlichungen gefunden",
"load_error": "Veröffentlichungen konnten nicht geladen werden (offline?)",
"retry_load": "Versuchen Sie es noch einmal"
},
"firstrun": {
"loading": "Einrichtung wird vorbereitet…",
"title": "OmniVoice Studio einrichten",
"subtitle": "Noch ist nichts installiert prüfe, wo alles gespeichert wird, und starte dann. Später in den Einstellungen änderbar.",
"language": "Sprache",
"mode_title": "Installationsmodus",
"mode_installed": "Installiert",
"mode_installed_desc": "Nutzt die Standard-Systemordner. Für die meisten empfohlen.",
"mode_portable": "Portabel",
"mode_portable_desc": "Alles liegt in einem Ordner neben der App als Einheit auf andere Laufwerke oder Rechner verschiebbar.",
"mode_portable_unavailable": "Nicht verfügbar: Der Ordner neben der App ist nicht beschreibbar.",
"storage_title": "Speicher",
"portable_folder": "Portabler Ordner",
"portable_folder_desc": "Laufzeitumgebung, Modelle und deine Sprachdaten ein Ordner, komplett verschiebbar.",
"env_dir": "App-Umgebung",
"env_dir_desc": "Python-Runtime und KI-Bibliotheken.",
"data_dir": "Sprachdaten & Projekte",
"data_dir_desc": "Deine Stimmen, Synchronisationen, Ausgaben und die Projektdatenbank.",
"models_dir": "Modell-Cache",
"models_dir_desc": "Heruntergeladene KI-Modelle der größte und am leichtesten verlagerbare Teil.",
"needs": "benötigt ~{{size}}",
"free": "{{size}} frei",
"checking": "prüfe…",
"not_writable": "nicht beschreibbar",
"change": "Ändern…",
"compute_title": "Rechenleistung",
"compute_label": "GPU / Beschleuniger",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD-GPU (ROCm, Linux)",
"channel_label": "Update-Kanal",
"channel_stable": "Stabil",
"channel_preview": "Vorschau (aktueller main)",
"network_title": "Netzwerk",
"region_label": "Download-Region",
"mirrors_title": "Eigene Mirrors (erweitert)",
"mirror_pypi": "PyPI-Index-URL",
"mirror_hf": "Hugging-Face-Endpoint",
"mirror_python": "Python-Download-Mirror",
"insufficient_space": "Zu wenig Speicherplatz: Dieses Layout braucht ~{{need}} auf einem Laufwerk, nur {{free}} verfügbar. Wähle einen anderen Ort.",
"blocked_not_writable": "Ein gewählter Ordner ist nicht beschreibbar wähle einen anderen Ort.",
"total_required": "Benötigter Speicher insgesamt: ~{{size}} (einmaliger Download beim ersten Start)",
"start": "Installation starten",
"starting": "Starte…",
"compute_detected": "Erkannt",
"compute_match": "passt zu diesem Rechner",
"compute_auto_desc": "Wählt zur Laufzeit das beste Backend dieses Rechners — CUDA auf NVIDIA, MPS auf Apple Silicon, sonst CPU.",
"compute_rocm_desc": "Installiert PyTorch-ROCm-Wheels für AMD-Grafikkarten unter Linux. Im Zweifel auf Auto lassen.",
"channel_stable_desc": "Nur getestete Releases — Updates kommen nach Community-Validierung.",
"channel_preview_desc": "Rollende Builds vom neuesten main — neue Engines und Fixes zuerst, gelegentlich kleine Kanten.",
"installing_title": "Installation",
"activity_title": "Aktivität",
"stage_setup": "Einrichtung",
"stage_models": "Modelle & Engines",
"chip_required": "erforderlich",
"chip_optional": "optional",
"chip_engine": "Engine",
"lib_download": "Herunterladen",
"lib_downloading": "lädt…",
"lib_use": "Verwenden",
"lib_active": "aktiv",
"lib_in_settings": "später in den Einstellungen",
"lib_show_all": "{{count}} optionale Modelle anzeigen"
}
}
}
+63
View File
@@ -1590,5 +1590,68 @@
"other_ways": "Other ways to help",
"star_github": "Star on GitHub",
"join_discord": "Join Discord"
},
"firstrun": {
"loading": "Preparing setup…",
"title": "Set up OmniVoice Studio",
"subtitle": "Nothing is installed yet — review where everything goes, then start. You can change these later in Settings.",
"language": "Language",
"mode_title": "Install mode",
"mode_installed": "Installed",
"mode_installed_desc": "Uses standard system folders. Recommended for most users.",
"mode_portable": "Portable",
"mode_portable_desc": "Everything lives in one folder next to the app — move it to another disk or machine as a unit.",
"mode_portable_unavailable": "Unavailable: the folder next to the app is not writable.",
"storage_title": "Storage",
"portable_folder": "Portable folder",
"portable_folder_desc": "App environment, models, and your voice data — one folder, fully movable.",
"env_dir": "App environment",
"env_dir_desc": "Python runtime and AI libraries.",
"data_dir": "Voice data & projects",
"data_dir_desc": "Your voices, dubs, outputs and project database.",
"models_dir": "Model cache",
"models_dir_desc": "Downloaded AI models — the largest and most relocatable part.",
"needs": "needs ~{{size}}",
"free": "{{size}} free",
"checking": "checking…",
"not_writable": "not writable",
"change": "Change…",
"compute_title": "Compute",
"compute_label": "GPU / accelerator",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD GPU (ROCm, Linux)",
"channel_label": "Update channel",
"channel_stable": "Stable",
"channel_preview": "Preview (latest main)",
"network_title": "Network",
"region_label": "Download region",
"mirrors_title": "Custom mirrors (advanced)",
"mirror_pypi": "PyPI index URL",
"mirror_hf": "Hugging Face endpoint",
"mirror_python": "Python downloads mirror",
"insufficient_space": "Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.",
"blocked_not_writable": "A chosen folder is not writable — pick a different location.",
"total_required": "Total disk needed: ~{{size}} (one-time download on first use)",
"start": "Start installation",
"starting": "Starting…",
"compute_detected": "Detected",
"compute_match": "matches this machine",
"compute_auto_desc": "Picks the best backend on this machine at runtime — CUDA on NVIDIA, MPS on Apple Silicon, CPU otherwise.",
"compute_rocm_desc": "Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.",
"channel_stable_desc": "Tested releases only — updates arrive after community validation.",
"channel_preview_desc": "Rolling builds from the latest main — new engines and fixes first, occasional rough edges.",
"installing_title": "Installing",
"activity_title": "Activity",
"stage_setup": "Setup",
"stage_models": "Models & engines",
"chip_required": "required",
"chip_optional": "optional",
"chip_engine": "engine",
"lib_download": "Download",
"lib_downloading": "downloading…",
"lib_use": "Use",
"lib_active": "active",
"lib_in_settings": "install later in Settings",
"lib_show_all": "Show {{count}} optional models"
}
}
+64 -1
View File
@@ -1598,5 +1598,68 @@
"none": "No se encontraron lanzamientos",
"load_error": "No se pudieron cargar las versiones (¿sin conexión?)",
"retry_load": "Reintentar"
},
"firstrun": {
"loading": "Preparando la configuración…",
"title": "Configurar OmniVoice Studio",
"subtitle": "Aún no se ha instalado nada: revisa dónde irá cada cosa y luego comienza. Podrás cambiarlo después en Ajustes.",
"language": "Idioma",
"mode_title": "Modo de instalación",
"mode_installed": "Instalado",
"mode_installed_desc": "Usa las carpetas estándar del sistema. Recomendado para la mayoría.",
"mode_portable": "Portátil",
"mode_portable_desc": "Todo vive en una carpeta junto a la aplicación: muévela a otro disco o equipo como una unidad.",
"mode_portable_unavailable": "No disponible: la carpeta junto a la aplicación no es escribible.",
"storage_title": "Almacenamiento",
"portable_folder": "Carpeta portátil",
"portable_folder_desc": "Entorno, modelos y tus datos de voz: una sola carpeta, totalmente movible.",
"env_dir": "Entorno de la aplicación",
"env_dir_desc": "Runtime de Python y bibliotecas de IA.",
"data_dir": "Datos de voz y proyectos",
"data_dir_desc": "Tus voces, doblajes, salidas y la base de datos de proyectos.",
"models_dir": "Caché de modelos",
"models_dir_desc": "Modelos de IA descargados: la parte más grande y fácil de reubicar.",
"needs": "necesita ~{{size}}",
"free": "{{size}} libres",
"checking": "comprobando…",
"not_writable": "no escribible",
"change": "Cambiar…",
"compute_title": "Cómputo",
"compute_label": "GPU / acelerador",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Canal de actualizaciones",
"channel_stable": "Estable",
"channel_preview": "Preview (último main)",
"network_title": "Red",
"region_label": "Región de descarga",
"mirrors_title": "Mirrors personalizados (avanzado)",
"mirror_pypi": "URL del índice PyPI",
"mirror_hf": "Endpoint de Hugging Face",
"mirror_python": "Mirror de descargas de Python",
"insufficient_space": "Espacio insuficiente: esta configuración necesita ~{{need}} en un mismo disco y solo hay {{free}} disponibles. Elige otra ubicación.",
"blocked_not_writable": "Una carpeta elegida no es escribible: elige otra ubicación.",
"total_required": "Espacio total necesario: ~{{size}} (descarga única en el primer uso)",
"start": "Iniciar instalación",
"starting": "Iniciando…",
"compute_detected": "Detectado",
"compute_match": "coincide con este equipo",
"compute_auto_desc": "Elige el mejor backend de este equipo en tiempo de ejecución: CUDA en NVIDIA, MPS en Apple Silicon, CPU en otro caso.",
"compute_rocm_desc": "Instala las wheels ROCm de PyTorch para tarjetas AMD en Linux. Deja Auto si tienes dudas.",
"channel_stable_desc": "Solo versiones probadas: las actualizaciones llegan tras la validación de la comunidad.",
"channel_preview_desc": "Builds continuas del último main: nuevos motores y arreglos antes, con algún borde áspero ocasional.",
"installing_title": "Instalando",
"activity_title": "Actividad",
"stage_setup": "Configuración",
"stage_models": "Modelos y motores",
"chip_required": "requerido",
"chip_optional": "opcional",
"chip_engine": "motor",
"lib_download": "Descargar",
"lib_downloading": "descargando…",
"lib_use": "Usar",
"lib_active": "activo",
"lib_in_settings": "instalar luego en Ajustes",
"lib_show_all": "Mostrar {{count}} modelos opcionales"
}
}
}
+64 -1
View File
@@ -1598,5 +1598,68 @@
"none": "Aucune version trouvée",
"load_error": "Impossible de charger les versions (hors ligne ?)",
"retry_load": "Réessayer"
},
"firstrun": {
"loading": "Préparation de la configuration…",
"title": "Configurer OmniVoice Studio",
"subtitle": "Rien n'est encore installé : vérifiez où tout sera placé, puis lancez. Modifiable plus tard dans les Réglages.",
"language": "Langue",
"mode_title": "Mode d'installation",
"mode_installed": "Installé",
"mode_installed_desc": "Utilise les dossiers système standard. Recommandé pour la plupart des utilisateurs.",
"mode_portable": "Portable",
"mode_portable_desc": "Tout vit dans un dossier à côté de l'application — déplacez-le vers un autre disque ou une autre machine d'un bloc.",
"mode_portable_unavailable": "Indisponible : le dossier à côté de l'application n'est pas accessible en écriture.",
"storage_title": "Stockage",
"portable_folder": "Dossier portable",
"portable_folder_desc": "Environnement, modèles et vos données vocales — un seul dossier, entièrement déplaçable.",
"env_dir": "Environnement de l'application",
"env_dir_desc": "Runtime Python et bibliothèques d'IA.",
"data_dir": "Données vocales et projets",
"data_dir_desc": "Vos voix, doublages, sorties et la base de données des projets.",
"models_dir": "Cache des modèles",
"models_dir_desc": "Modèles d'IA téléchargés — la partie la plus volumineuse et la plus facile à déplacer.",
"needs": "requiert ~{{size}}",
"free": "{{size}} libres",
"checking": "vérification…",
"not_writable": "non inscriptible",
"change": "Modifier…",
"compute_title": "Calcul",
"compute_label": "GPU / accélérateur",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Canal de mise à jour",
"channel_stable": "Stable",
"channel_preview": "Préversion (dernier main)",
"network_title": "Réseau",
"region_label": "Région de téléchargement",
"mirrors_title": "Miroirs personnalisés (avancé)",
"mirror_pypi": "URL de l'index PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Miroir de téléchargement Python",
"insufficient_space": "Espace insuffisant : cette configuration nécessite ~{{need}} sur un même disque, seulement {{free}} disponibles. Choisissez un autre emplacement.",
"blocked_not_writable": "Un dossier choisi n'est pas inscriptible — choisissez un autre emplacement.",
"total_required": "Espace disque total : ~{{size}} (téléchargement unique au premier lancement)",
"start": "Démarrer l'installation",
"starting": "Démarrage…",
"compute_detected": "Détecté",
"compute_match": "correspond à cette machine",
"compute_auto_desc": "Choisit le meilleur backend de cette machine à l'exécution — CUDA sur NVIDIA, MPS sur Apple Silicon, sinon CPU.",
"compute_rocm_desc": "Installe les wheels ROCm de PyTorch pour les cartes AMD sous Linux. Laissez Auto en cas de doute.",
"channel_stable_desc": "Uniquement des versions testées — les mises à jour arrivent après validation par la communauté.",
"channel_preview_desc": "Builds continues du dernier main — nouveaux moteurs et correctifs en premier, quelques aspérités possibles.",
"installing_title": "Installation",
"activity_title": "Activité",
"stage_setup": "Configuration",
"stage_models": "Modèles et moteurs",
"chip_required": "requis",
"chip_optional": "optionnel",
"chip_engine": "moteur",
"lib_download": "Télécharger",
"lib_downloading": "téléchargement…",
"lib_use": "Utiliser",
"lib_active": "actif",
"lib_in_settings": "installer plus tard dans Réglages",
"lib_show_all": "Afficher {{count}} modèles optionnels"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "कोई रिलीज़ नहीं मिली",
"load_error": "रिलीज़ लोड नहीं हो सकी (ऑफ़लाइन?)",
"retry_load": "पुनः प्रयास करें"
},
"firstrun": {
"loading": "सेटअप तैयार हो रहा है…",
"title": "OmniVoice Studio सेट करें",
"subtitle": "अभी कुछ भी इंस्टॉल नहीं हुआ है — देखें कि सब कहाँ जाएगा, फिर शुरू करें। बाद में सेटिंग्स में बदल सकते हैं।",
"language": "भाषा",
"mode_title": "इंस्टॉल मोड",
"mode_installed": "इंस्टॉल्ड",
"mode_installed_desc": "सिस्टम के मानक फ़ोल्डर इस्तेमाल करता है। अधिकांश उपयोगकर्ताओं के लिए अनुशंसित।",
"mode_portable": "पोर्टेबल",
"mode_portable_desc": "सब कुछ ऐप के बगल में एक फ़ोल्डर में रहता है — इसे किसी और डिस्क या मशीन पर एक साथ ले जाएँ।",
"mode_portable_unavailable": "अनुपलब्ध: ऐप के बगल वाला फ़ोल्डर लिखने योग्य नहीं है।",
"storage_title": "स्टोरेज",
"portable_folder": "पोर्टेबल फ़ोल्डर",
"portable_folder_desc": "ऐप एनवायरनमेंट, मॉडल और आपका वॉइस डेटा — एक फ़ोल्डर, पूरी तरह मूवेबल।",
"env_dir": "ऐप एनवायरनमेंट",
"env_dir_desc": "Python रनटाइम और AI लाइब्रेरीज़।",
"data_dir": "वॉइस डेटा और प्रोजेक्ट",
"data_dir_desc": "आपकी आवाज़ें, डबिंग, आउटपुट और प्रोजेक्ट डेटाबेस।",
"models_dir": "मॉडल कैश",
"models_dir_desc": "डाउनलोड किए गए AI मॉडल — सबसे बड़ा और आसानी से स्थानांतरित होने वाला हिस्सा।",
"needs": "~{{size}} चाहिए",
"free": "{{size}} खाली",
"checking": "जाँच रहा है…",
"not_writable": "लिखने योग्य नहीं",
"change": "बदलें…",
"compute_title": "कंप्यूट",
"compute_label": "GPU / एक्सेलेरेटर",
"compute_auto": "ऑटो (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD GPU (ROCm, Linux)",
"channel_label": "अपडेट चैनल",
"channel_stable": "स्थिर",
"channel_preview": "प्रीव्यू (नवीनतम main)",
"network_title": "नेटवर्क",
"region_label": "डाउनलोड क्षेत्र",
"mirrors_title": "कस्टम मिरर (उन्नत)",
"mirror_pypi": "PyPI इंडेक्स URL",
"mirror_hf": "Hugging Face एंडपॉइंट",
"mirror_python": "Python डाउनलोड मिरर",
"insufficient_space": "जगह कम है: इस लेआउट को एक ही डिस्क पर ~{{need}} चाहिए, केवल {{free}} उपलब्ध है। दूसरी जगह चुनें।",
"blocked_not_writable": "चुना गया फ़ोल्डर लिखने योग्य नहीं है — दूसरी जगह चुनें।",
"total_required": "कुल आवश्यक डिस्क: ~{{size}} (पहली बार में एक-बार डाउनलोड)",
"start": "इंस्टॉलेशन शुरू करें",
"starting": "शुरू हो रहा है…",
"compute_detected": "पहचाना गया",
"compute_match": "इस मशीन से मेल खाता है",
"compute_auto_desc": "रनटाइम पर इस मशीन का सर्वश्रेष्ठ बैकएंड चुनता है — NVIDIA पर CUDA, Apple Silicon पर MPS, अन्यथा CPU।",
"compute_rocm_desc": "Linux पर AMD ग्राफ़िक्स कार्ड के लिए PyTorch ROCm व्हील इंस्टॉल करता है। संदेह हो तो Auto पर रहने दें।",
"channel_stable_desc": "केवल परीक्षित रिलीज़ — सामुदायिक सत्यापन के बाद अपडेट आते हैं।",
"channel_preview_desc": "नवीनतम main की रोलिंग बिल्ड — नए इंजन और फ़िक्स सबसे पहले, कभी-कभी छोटी खामियाँ।",
"installing_title": "इंस्टॉल हो रहा है",
"activity_title": "गतिविधि",
"stage_setup": "सेटअप",
"stage_models": "मॉडल और इंजन",
"chip_required": "आवश्यक",
"chip_optional": "वैकल्पिक",
"chip_engine": "इंजन",
"lib_download": "डाउनलोड",
"lib_downloading": "डाउनलोड हो रहा है…",
"lib_use": "उपयोग करें",
"lib_active": "सक्रिय",
"lib_in_settings": "बाद में सेटिंग्स में इंस्टॉल करें",
"lib_show_all": "{{count}} वैकल्पिक मॉडल दिखाएँ"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Tidak ada rilis yang ditemukan",
"load_error": "Tidak dapat memuat rilis (offline?)",
"retry_load": "Coba lagi"
},
"firstrun": {
"loading": "Menyiapkan penyiapan…",
"title": "Siapkan OmniVoice Studio",
"subtitle": "Belum ada yang terpasang — tinjau ke mana semuanya disimpan, lalu mulai. Bisa diubah nanti di Pengaturan.",
"language": "Bahasa",
"mode_title": "Mode pemasangan",
"mode_installed": "Terpasang",
"mode_installed_desc": "Menggunakan folder sistem standar. Disarankan untuk sebagian besar pengguna.",
"mode_portable": "Portabel",
"mode_portable_desc": "Semuanya berada dalam satu folder di samping aplikasi — pindahkan utuh ke disk atau mesin lain.",
"mode_portable_unavailable": "Tidak tersedia: folder di samping aplikasi tidak dapat ditulisi.",
"storage_title": "Penyimpanan",
"portable_folder": "Folder portabel",
"portable_folder_desc": "Lingkungan, model, dan data suara Anda — satu folder, sepenuhnya dapat dipindah.",
"env_dir": "Lingkungan aplikasi",
"env_dir_desc": "Runtime Python dan pustaka AI.",
"data_dir": "Data suara & proyek",
"data_dir_desc": "Suara Anda, sulih suara, keluaran, dan basis data proyek.",
"models_dir": "Cache model",
"models_dir_desc": "Model AI yang diunduh — bagian terbesar dan paling mudah dipindah.",
"needs": "butuh ~{{size}}",
"free": "{{size}} kosong",
"checking": "memeriksa…",
"not_writable": "tidak dapat ditulisi",
"change": "Ubah…",
"compute_title": "Komputasi",
"compute_label": "GPU / akselerator",
"compute_auto": "Otomatis (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Saluran pembaruan",
"channel_stable": "Stabil",
"channel_preview": "Pratinjau (main terbaru)",
"network_title": "Jaringan",
"region_label": "Wilayah unduhan",
"mirrors_title": "Mirror kustom (lanjutan)",
"mirror_pypi": "URL indeks PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Mirror unduhan Python",
"insufficient_space": "Ruang tidak cukup: tata letak ini butuh ~{{need}} pada satu disk, hanya tersedia {{free}}. Pilih lokasi lain.",
"blocked_not_writable": "Folder yang dipilih tidak dapat ditulisi — pilih lokasi lain.",
"total_required": "Total disk yang dibutuhkan: ~{{size}} (unduhan sekali saat pertama dipakai)",
"start": "Mulai pemasangan",
"starting": "Memulai…",
"compute_detected": "Terdeteksi",
"compute_match": "cocok dengan mesin ini",
"compute_auto_desc": "Memilih backend terbaik mesin ini saat berjalan — CUDA di NVIDIA, MPS di Apple Silicon, selain itu CPU.",
"compute_rocm_desc": "Memasang wheel PyTorch ROCm untuk kartu grafis AMD di Linux. Biarkan Auto jika ragu.",
"channel_stable_desc": "Hanya rilis teruji — pembaruan tiba setelah validasi komunitas.",
"channel_preview_desc": "Build bergulir dari main terbaru — mesin dan perbaikan baru lebih dulu, sesekali ada sisi kasar.",
"installing_title": "Memasang",
"activity_title": "Aktivitas",
"stage_setup": "Penyiapan",
"stage_models": "Model & mesin",
"chip_required": "wajib",
"chip_optional": "opsional",
"chip_engine": "mesin",
"lib_download": "Unduh",
"lib_downloading": "mengunduh…",
"lib_use": "Pakai",
"lib_active": "aktif",
"lib_in_settings": "pasang nanti di Pengaturan",
"lib_show_all": "Tampilkan {{count}} model opsional"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Nessuna versione trovata",
"load_error": "Impossibile caricare le versioni (offline?)",
"retry_load": "Riprova"
},
"firstrun": {
"loading": "Preparazione della configurazione…",
"title": "Configura OmniVoice Studio",
"subtitle": "Non è stato ancora installato nulla: controlla dove andrà tutto, poi avvia. Potrai cambiarlo nelle Impostazioni.",
"language": "Lingua",
"mode_title": "Modalità di installazione",
"mode_installed": "Installata",
"mode_installed_desc": "Usa le cartelle di sistema standard. Consigliata alla maggior parte degli utenti.",
"mode_portable": "Portatile",
"mode_portable_desc": "Tutto risiede in una cartella accanto all'app: spostala su un altro disco o computer come un'unica unità.",
"mode_portable_unavailable": "Non disponibile: la cartella accanto all'app non è scrivibile.",
"storage_title": "Archiviazione",
"portable_folder": "Cartella portatile",
"portable_folder_desc": "Ambiente, modelli e i tuoi dati vocali: una sola cartella, completamente spostabile.",
"env_dir": "Ambiente dell'app",
"env_dir_desc": "Runtime Python e librerie IA.",
"data_dir": "Dati vocali e progetti",
"data_dir_desc": "Le tue voci, i doppiaggi, gli output e il database dei progetti.",
"models_dir": "Cache dei modelli",
"models_dir_desc": "Modelli IA scaricati: la parte più grande e più facile da spostare.",
"needs": "richiede ~{{size}}",
"free": "{{size}} liberi",
"checking": "controllo…",
"not_writable": "non scrivibile",
"change": "Cambia…",
"compute_title": "Calcolo",
"compute_label": "GPU / acceleratore",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Canale di aggiornamento",
"channel_stable": "Stabile",
"channel_preview": "Anteprima (ultimo main)",
"network_title": "Rete",
"region_label": "Area di download",
"mirrors_title": "Mirror personalizzati (avanzato)",
"mirror_pypi": "URL indice PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Mirror download Python",
"insufficient_space": "Spazio insufficiente: questa configurazione richiede ~{{need}} sullo stesso disco, ma sono disponibili solo {{free}}. Scegli un'altra posizione.",
"blocked_not_writable": "Una cartella scelta non è scrivibile: scegli un'altra posizione.",
"total_required": "Spazio totale necessario: ~{{size}} (download unico al primo utilizzo)",
"start": "Avvia installazione",
"starting": "Avvio…",
"compute_detected": "Rilevato",
"compute_match": "corrisponde a questa macchina",
"compute_auto_desc": "Sceglie il miglior backend di questa macchina a runtime — CUDA su NVIDIA, MPS su Apple Silicon, altrimenti CPU.",
"compute_rocm_desc": "Installa le wheel ROCm di PyTorch per le schede AMD su Linux. In caso di dubbio lascia Auto.",
"channel_stable_desc": "Solo release testate — gli aggiornamenti arrivano dopo la convalida della community.",
"channel_preview_desc": "Build continue dall'ultimo main — nuovi motori e fix per primi, con qualche spigolo occasionale.",
"installing_title": "Installazione",
"activity_title": "Attività",
"stage_setup": "Configurazione",
"stage_models": "Modelli e motori",
"chip_required": "richiesto",
"chip_optional": "opzionale",
"chip_engine": "motore",
"lib_download": "Scarica",
"lib_downloading": "download…",
"lib_use": "Usa",
"lib_active": "attivo",
"lib_in_settings": "installa dopo nelle Impostazioni",
"lib_show_all": "Mostra {{count}} modelli opzionali"
}
}
}
+64 -1
View File
@@ -1598,5 +1598,68 @@
"none": "リリースが見つかりませんでした",
"load_error": "リリースをロードできませんでした (オフライン?)",
"retry_load": "再試行"
},
"firstrun": {
"loading": "セットアップを準備中…",
"title": "OmniVoice Studio のセットアップ",
"subtitle": "まだ何もインストールされていません。保存先を確認してから開始してください。後で設定から変更できます。",
"language": "言語",
"mode_title": "インストール方式",
"mode_installed": "標準インストール",
"mode_installed_desc": "システム標準のフォルダーを使用します。ほとんどの方におすすめです。",
"mode_portable": "ポータブル",
"mode_portable_desc": "すべてをアプリの隣の 1 つのフォルダーに保存します。別のディスクや PC へまるごと移動できます。",
"mode_portable_unavailable": "利用不可:アプリの隣のフォルダーに書き込めません。",
"storage_title": "ストレージ",
"portable_folder": "ポータブルフォルダー",
"portable_folder_desc": "実行環境・モデル・音声データを 1 つのフォルダーにまとめ、丸ごと移動できます。",
"env_dir": "アプリ実行環境",
"env_dir_desc": "Python ランタイムと AI ライブラリ。",
"data_dir": "音声データとプロジェクト",
"data_dir_desc": "あなたの声、吹き替え、出力ファイル、プロジェクトのデータベース。",
"models_dir": "モデルキャッシュ",
"models_dir_desc": "ダウンロードした AI モデル。最も容量が大きく、移動に向いています。",
"needs": "約 {{size}} 必要",
"free": "空き {{size}}",
"checking": "確認中…",
"not_writable": "書き込み不可",
"change": "変更…",
"compute_title": "コンピュート",
"compute_label": "GPU / アクセラレーター",
"compute_auto": "自動(NVIDIA CUDA / Apple MPS / CPU",
"compute_rocm": "AMD GPUROCm、Linux",
"channel_label": "更新チャンネル",
"channel_stable": "安定版",
"channel_preview": "プレビュー(最新 main",
"network_title": "ネットワーク",
"region_label": "ダウンロード地域",
"mirrors_title": "カスタムミラー(上級者向け)",
"mirror_pypi": "PyPI インデックス URL",
"mirror_hf": "Hugging Face エンドポイント",
"mirror_python": "Python ダウンロードミラー",
"insufficient_space": "空き容量が不足しています:この構成は同一ディスクに約 {{need}} 必要ですが、{{free}} しかありません。別の場所を選んでください。",
"blocked_not_writable": "選択したフォルダーに書き込めません。別の場所を選んでください。",
"total_required": "必要なディスク容量:約 {{size}}(初回のみダウンロード)",
"start": "インストールを開始",
"starting": "開始しています…",
"compute_detected": "検出済み",
"compute_match": "このマシンに一致",
"compute_auto_desc": "実行時にこのマシン最適のバックエンドを選択します。NVIDIA は CUDA、Apple シリコンは MPS、それ以外は CPU。",
"compute_rocm_desc": "Linux 上の AMD GPU 向けに PyTorch ROCm ホイールをインストールします。不明な場合は「自動」のままに。",
"channel_stable_desc": "テスト済みリリースのみ。コミュニティ検証後に更新が届きます。",
"channel_preview_desc": "最新 main のローリングビルド。新エンジンや修正をいち早く入手できますが、粗削りな場合があります。",
"installing_title": "インストール中",
"activity_title": "アクティビティ",
"stage_setup": "セットアップ",
"stage_models": "モデルとエンジン",
"chip_required": "必須",
"chip_optional": "任意",
"chip_engine": "エンジン",
"lib_download": "ダウンロード",
"lib_downloading": "ダウンロード中…",
"lib_use": "使用",
"lib_active": "使用中",
"lib_in_settings": "後で設定からインストール",
"lib_show_all": "任意モデル {{count}} 件を表示"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "릴리스를 찾을 수 없습니다.",
"load_error": "릴리스를 로드할 수 없습니다(오프라인?)",
"retry_load": "재시도"
},
"firstrun": {
"loading": "설정 준비 중…",
"title": "OmniVoice Studio 설정",
"subtitle": "아직 아무것도 설치되지 않았습니다. 저장 위치를 확인한 뒤 시작하세요. 나중에 설정에서 변경할 수 있습니다.",
"language": "언어",
"mode_title": "설치 방식",
"mode_installed": "일반 설치",
"mode_installed_desc": "시스템 표준 폴더를 사용합니다. 대부분의 사용자에게 권장됩니다.",
"mode_portable": "포터블",
"mode_portable_desc": "모든 것이 앱 옆의 폴더 하나에 저장됩니다. 다른 디스크나 PC로 통째로 옮길 수 있습니다.",
"mode_portable_unavailable": "사용 불가: 앱 옆 폴더에 쓸 수 없습니다.",
"storage_title": "저장소",
"portable_folder": "포터블 폴더",
"portable_folder_desc": "실행 환경, 모델, 음성 데이터를 폴더 하나에 담아 통째로 이동할 수 있습니다.",
"env_dir": "앱 실행 환경",
"env_dir_desc": "Python 런타임과 AI 라이브러리.",
"data_dir": "음성 데이터 및 프로젝트",
"data_dir_desc": "내 목소리, 더빙, 출력물, 프로젝트 데이터베이스.",
"models_dir": "모델 캐시",
"models_dir_desc": "다운로드한 AI 모델 — 가장 크고 옮기기 쉬운 부분입니다.",
"needs": "약 {{size}} 필요",
"free": "{{size}} 남음",
"checking": "확인 중…",
"not_writable": "쓰기 불가",
"change": "변경…",
"compute_title": "연산",
"compute_label": "GPU / 가속기",
"compute_auto": "자동 (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD GPU (ROCm, Linux)",
"channel_label": "업데이트 채널",
"channel_stable": "안정",
"channel_preview": "프리뷰 (최신 main)",
"network_title": "네트워크",
"region_label": "다운로드 지역",
"mirrors_title": "사용자 지정 미러 (고급)",
"mirror_pypi": "PyPI 인덱스 URL",
"mirror_hf": "Hugging Face 엔드포인트",
"mirror_python": "Python 다운로드 미러",
"insufficient_space": "공간 부족: 이 구성은 한 디스크에 약 {{need}}가 필요하지만 {{free}}만 남아 있습니다. 다른 위치를 선택하세요.",
"blocked_not_writable": "선택한 폴더에 쓸 수 없습니다. 다른 위치를 선택하세요.",
"total_required": "총 필요 공간: 약 {{size}} (최초 1회 다운로드)",
"start": "설치 시작",
"starting": "시작 중…",
"compute_detected": "감지됨",
"compute_match": "이 컴퓨터와 일치",
"compute_auto_desc": "실행 시 이 컴퓨터에 가장 적합한 백엔드를 선택합니다. NVIDIA는 CUDA, Apple Silicon은 MPS, 그 외에는 CPU.",
"compute_rocm_desc": "Linux의 AMD 그래픽 카드용 PyTorch ROCm 휠을 설치합니다. 잘 모르면 자동으로 두세요.",
"channel_stable_desc": "테스트된 릴리스만 — 커뮤니티 검증 후 업데이트가 도착합니다.",
"channel_preview_desc": "최신 main의 롤링 빌드 — 새 엔진과 수정 사항을 가장 먼저 받지만 가끔 거친 부분이 있습니다.",
"installing_title": "설치 중",
"activity_title": "활동",
"stage_setup": "설정",
"stage_models": "모델 및 엔진",
"chip_required": "필수",
"chip_optional": "선택",
"chip_engine": "엔진",
"lib_download": "다운로드",
"lib_downloading": "다운로드 중…",
"lib_use": "사용",
"lib_active": "사용 중",
"lib_in_settings": "나중에 설정에서 설치",
"lib_show_all": "선택 모델 {{count}}개 표시"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Geen releases gevonden",
"load_error": "Kan releases niet laden (offline?)",
"retry_load": "Opnieuw proberen"
},
"firstrun": {
"loading": "Installatie voorbereiden…",
"title": "OmniVoice Studio instellen",
"subtitle": "Er is nog niets geïnstalleerd — controleer waar alles komt te staan en start daarna. Later te wijzigen in Instellingen.",
"language": "Taal",
"mode_title": "Installatiemodus",
"mode_installed": "Geïnstalleerd",
"mode_installed_desc": "Gebruikt de standaard systeemmappen. Aanbevolen voor de meeste gebruikers.",
"mode_portable": "Draagbaar",
"mode_portable_desc": "Alles staat in één map naast de app — verplaats hem als geheel naar een andere schijf of machine.",
"mode_portable_unavailable": "Niet beschikbaar: de map naast de app is niet beschrijfbaar.",
"storage_title": "Opslag",
"portable_folder": "Draagbare map",
"portable_folder_desc": "Omgeving, modellen en je stemdata — één map, volledig verplaatsbaar.",
"env_dir": "App-omgeving",
"env_dir_desc": "Python-runtime en AI-bibliotheken.",
"data_dir": "Stemdata & projecten",
"data_dir_desc": "Je stemmen, dubs, uitvoer en de projectdatabase.",
"models_dir": "Modelcache",
"models_dir_desc": "Gedownloade AI-modellen — het grootste en best verplaatsbare deel.",
"needs": "vereist ~{{size}}",
"free": "{{size}} vrij",
"checking": "controleren…",
"not_writable": "niet beschrijfbaar",
"change": "Wijzigen…",
"compute_title": "Rekenkracht",
"compute_label": "GPU / versneller",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD GPU (ROCm, Linux)",
"channel_label": "Updatekanaal",
"channel_stable": "Stabiel",
"channel_preview": "Preview (nieuwste main)",
"network_title": "Netwerk",
"region_label": "Downloadregio",
"mirrors_title": "Eigen mirrors (geavanceerd)",
"mirror_pypi": "PyPI-index-URL",
"mirror_hf": "Hugging Face-endpoint",
"mirror_python": "Python-downloadmirror",
"insufficient_space": "Onvoldoende ruimte: deze indeling vereist ~{{need}} op één schijf, slechts {{free}} beschikbaar. Kies een andere locatie.",
"blocked_not_writable": "Een gekozen map is niet beschrijfbaar — kies een andere locatie.",
"total_required": "Totaal benodigde schijfruimte: ~{{size}} (eenmalige download bij eerste gebruik)",
"start": "Installatie starten",
"starting": "Starten…",
"compute_detected": "Gedetecteerd",
"compute_match": "past bij deze machine",
"compute_auto_desc": "Kiest tijdens runtime de beste backend van deze machine — CUDA op NVIDIA, MPS op Apple Silicon, anders CPU.",
"compute_rocm_desc": "Installeert PyTorch ROCm-wheels voor AMD-kaarten op Linux. Laat op Auto staan bij twijfel.",
"channel_stable_desc": "Alleen geteste releases — updates komen na validatie door de community.",
"channel_preview_desc": "Doorlopende builds van de nieuwste main — nieuwe engines en fixes eerst, af en toe een ruw randje.",
"installing_title": "Installeren",
"activity_title": "Activiteit",
"stage_setup": "Installatie",
"stage_models": "Modellen & engines",
"chip_required": "vereist",
"chip_optional": "optioneel",
"chip_engine": "engine",
"lib_download": "Downloaden",
"lib_downloading": "downloaden…",
"lib_use": "Gebruik",
"lib_active": "actief",
"lib_in_settings": "later installeren in Instellingen",
"lib_show_all": "Toon {{count}} optionele modellen"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Nie znaleziono żadnych wydań",
"load_error": "Nie można wczytać wersji (offline?)",
"retry_load": "Spróbuj ponownie"
},
"firstrun": {
"loading": "Przygotowywanie konfiguracji…",
"title": "Skonfiguruj OmniVoice Studio",
"subtitle": "Nic nie zostało jeszcze zainstalowane — sprawdź, gdzie wszystko trafi, a potem rozpocznij. Można to później zmienić w Ustawieniach.",
"language": "Język",
"mode_title": "Tryb instalacji",
"mode_installed": "Zainstalowana",
"mode_installed_desc": "Używa standardowych folderów systemowych. Zalecane dla większości.",
"mode_portable": "Przenośna",
"mode_portable_desc": "Wszystko mieści się w jednym folderze obok aplikacji — przenieś go na inny dysk lub komputer w całości.",
"mode_portable_unavailable": "Niedostępne: folder obok aplikacji nie jest zapisywalny.",
"storage_title": "Pamięć",
"portable_folder": "Folder przenośny",
"portable_folder_desc": "Środowisko, modele i dane głosowe — jeden folder, w pełni przenośny.",
"env_dir": "Środowisko aplikacji",
"env_dir_desc": "Środowisko Python i biblioteki AI.",
"data_dir": "Dane głosowe i projekty",
"data_dir_desc": "Twoje głosy, dubbingi, pliki wyjściowe i baza projektów.",
"models_dir": "Pamięć podręczna modeli",
"models_dir_desc": "Pobrane modele AI — największa i najłatwiejsza do przeniesienia część.",
"needs": "potrzebuje ~{{size}}",
"free": "{{size}} wolne",
"checking": "sprawdzanie…",
"not_writable": "brak zapisu",
"change": "Zmień…",
"compute_title": "Obliczenia",
"compute_label": "GPU / akcelerator",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Kanał aktualizacji",
"channel_stable": "Stabilny",
"channel_preview": "Podgląd (najnowszy main)",
"network_title": "Sieć",
"region_label": "Region pobierania",
"mirrors_title": "Własne serwery lustrzane (zaawansowane)",
"mirror_pypi": "URL indeksu PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Mirror pobierania Pythona",
"insufficient_space": "Za mało miejsca: ten układ wymaga ~{{need}} na jednym dysku, dostępne tylko {{free}}. Wybierz inną lokalizację.",
"blocked_not_writable": "Wybrany folder nie jest zapisywalny — wybierz inną lokalizację.",
"total_required": "Łącznie potrzeba: ~{{size}} (jednorazowe pobranie przy pierwszym użyciu)",
"start": "Rozpocznij instalację",
"starting": "Uruchamianie…",
"compute_detected": "Wykryto",
"compute_match": "pasuje do tej maszyny",
"compute_auto_desc": "Wybiera najlepszy backend tej maszyny w czasie działania — CUDA na NVIDIA, MPS na Apple Silicon, inaczej CPU.",
"compute_rocm_desc": "Instaluje pakiety PyTorch ROCm dla kart AMD w Linuksie. W razie wątpliwości zostaw Auto.",
"channel_stable_desc": "Tylko przetestowane wydania — aktualizacje przychodzą po walidacji społeczności.",
"channel_preview_desc": "Kroczące kompilacje z najnowszego main — nowe silniki i poprawki najpierw, czasem drobne niedociągnięcia.",
"installing_title": "Instalowanie",
"activity_title": "Aktywność",
"stage_setup": "Konfiguracja",
"stage_models": "Modele i silniki",
"chip_required": "wymagany",
"chip_optional": "opcjonalny",
"chip_engine": "silnik",
"lib_download": "Pobierz",
"lib_downloading": "pobieranie…",
"lib_use": "Użyj",
"lib_active": "aktywny",
"lib_in_settings": "zainstaluj później w Ustawieniach",
"lib_show_all": "Pokaż opcjonalne modele: {{count}}"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Nenhum lançamento encontrado",
"load_error": "Não foi possível carregar as versões (off-line?)",
"retry_load": "Tentar novamente"
},
"firstrun": {
"loading": "Preparando a configuração…",
"title": "Configurar o OmniVoice Studio",
"subtitle": "Nada foi instalado ainda — confira onde tudo será salvo e então comece. Você pode mudar depois nas Configurações.",
"language": "Idioma",
"mode_title": "Modo de instalação",
"mode_installed": "Instalado",
"mode_installed_desc": "Usa as pastas padrão do sistema. Recomendado para a maioria.",
"mode_portable": "Portátil",
"mode_portable_desc": "Tudo fica em uma pasta ao lado do app — mova-a para outro disco ou máquina como uma unidade.",
"mode_portable_unavailable": "Indisponível: a pasta ao lado do app não é gravável.",
"storage_title": "Armazenamento",
"portable_folder": "Pasta portátil",
"portable_folder_desc": "Ambiente, modelos e seus dados de voz — uma pasta, totalmente móvel.",
"env_dir": "Ambiente do aplicativo",
"env_dir_desc": "Runtime Python e bibliotecas de IA.",
"data_dir": "Dados de voz e projetos",
"data_dir_desc": "Suas vozes, dublagens, saídas e o banco de dados de projetos.",
"models_dir": "Cache de modelos",
"models_dir_desc": "Modelos de IA baixados — a parte maior e mais fácil de realocar.",
"needs": "precisa de ~{{size}}",
"free": "{{size}} livres",
"checking": "verificando…",
"not_writable": "sem permissão de escrita",
"change": "Alterar…",
"compute_title": "Computação",
"compute_label": "GPU / acelerador",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Canal de atualização",
"channel_stable": "Estável",
"channel_preview": "Preview (main mais recente)",
"network_title": "Rede",
"region_label": "Região de download",
"mirrors_title": "Mirrors personalizados (avançado)",
"mirror_pypi": "URL do índice PyPI",
"mirror_hf": "Endpoint do Hugging Face",
"mirror_python": "Mirror de downloads do Python",
"insufficient_space": "Espaço insuficiente: este layout precisa de ~{{need}} em um mesmo disco, e só há {{free}} disponíveis. Escolha outro local.",
"blocked_not_writable": "Uma pasta escolhida não é gravável — escolha outro local.",
"total_required": "Espaço total necessário: ~{{size}} (download único no primeiro uso)",
"start": "Iniciar instalação",
"starting": "Iniciando…",
"compute_detected": "Detectado",
"compute_match": "corresponde a esta máquina",
"compute_auto_desc": "Escolhe o melhor backend desta máquina em tempo de execução — CUDA em NVIDIA, MPS em Apple Silicon, senão CPU.",
"compute_rocm_desc": "Instala as wheels ROCm do PyTorch para placas AMD no Linux. Deixe em Auto se não tiver certeza.",
"channel_stable_desc": "Apenas versões testadas — as atualizações chegam após validação da comunidade.",
"channel_preview_desc": "Builds contínuas do main mais recente — novos motores e correções primeiro, com arestas ocasionais.",
"installing_title": "Instalando",
"activity_title": "Atividade",
"stage_setup": "Configuração",
"stage_models": "Modelos e motores",
"chip_required": "obrigatório",
"chip_optional": "opcional",
"chip_engine": "motor",
"lib_download": "Baixar",
"lib_downloading": "baixando…",
"lib_use": "Usar",
"lib_active": "ativo",
"lib_in_settings": "instalar depois em Configurações",
"lib_show_all": "Mostrar {{count}} modelos opcionais"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Релизов не найдено",
"load_error": "Не удалось загрузить выпуски (офлайн?)",
"retry_load": "Повторить попытку"
},
"firstrun": {
"loading": "Подготовка установки…",
"title": "Настройка OmniVoice Studio",
"subtitle": "Пока ничего не установлено — проверьте, куда всё будет сохранено, затем начните. Это можно изменить позже в настройках.",
"language": "Язык",
"mode_title": "Режим установки",
"mode_installed": "Установленный",
"mode_installed_desc": "Использует стандартные системные папки. Рекомендуется большинству.",
"mode_portable": "Портативный",
"mode_portable_desc": "Всё хранится в одной папке рядом с приложением — её можно целиком перенести на другой диск или компьютер.",
"mode_portable_unavailable": "Недоступно: папка рядом с приложением недоступна для записи.",
"storage_title": "Хранилище",
"portable_folder": "Портативная папка",
"portable_folder_desc": "Среда, модели и ваши голосовые данные — одна папка, полностью переносимая.",
"env_dir": "Среда приложения",
"env_dir_desc": "Python и библиотеки ИИ.",
"data_dir": "Голосовые данные и проекты",
"data_dir_desc": "Ваши голоса, дубляжи, результаты и база данных проектов.",
"models_dir": "Кэш моделей",
"models_dir_desc": "Скачанные модели ИИ — самая большая и легко переносимая часть.",
"needs": "нужно ~{{size}}",
"free": "свободно {{size}}",
"checking": "проверка…",
"not_writable": "нет записи",
"change": "Изменить…",
"compute_title": "Вычисления",
"compute_label": "GPU / ускоритель",
"compute_auto": "Авто (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Канал обновлений",
"channel_stable": "Стабильный",
"channel_preview": "Предварительный (последний main)",
"network_title": "Сеть",
"region_label": "Регион загрузки",
"mirrors_title": "Свои зеркала (дополнительно)",
"mirror_pypi": "URL индекса PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Зеркало загрузок Python",
"insufficient_space": "Недостаточно места: для этой схемы нужно ~{{need}} на одном диске, доступно только {{free}}. Выберите другое расположение.",
"blocked_not_writable": "Выбранная папка недоступна для записи — выберите другое расположение.",
"total_required": "Всего потребуется: ~{{size}} (однократная загрузка при первом запуске)",
"start": "Начать установку",
"starting": "Запуск…",
"compute_detected": "Обнаружено",
"compute_match": "соответствует этой машине",
"compute_auto_desc": "Выбирает лучший бэкенд этой машины во время запуска — CUDA на NVIDIA, MPS на Apple Silicon, иначе CPU.",
"compute_rocm_desc": "Устанавливает ROCm-сборки PyTorch для видеокарт AMD в Linux. Если не уверены — оставьте «Авто».",
"channel_stable_desc": "Только проверенные релизы — обновления приходят после проверки сообществом.",
"channel_preview_desc": "Скользящие сборки из последнего main — новые движки и исправления раньше всех, изредка с шероховатостями.",
"installing_title": "Установка",
"activity_title": "Журнал",
"stage_setup": "Настройка",
"stage_models": "Модели и движки",
"chip_required": "обязательно",
"chip_optional": "опционально",
"chip_engine": "движок",
"lib_download": "Скачать",
"lib_downloading": "загрузка…",
"lib_use": "Использовать",
"lib_active": "активен",
"lib_in_settings": "установить позже в настройках",
"lib_show_all": "Показать опциональные модели: {{count}}"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Inga utgåvor hittades",
"load_error": "Det gick inte att läsa in utgåvor (offline?)",
"retry_load": "Försök igen"
},
"firstrun": {
"loading": "Förbereder installationen…",
"title": "Konfigurera OmniVoice Studio",
"subtitle": "Inget är installerat ännu — granska var allt hamnar och starta sedan. Kan ändras senare i Inställningar.",
"language": "Språk",
"mode_title": "Installationsläge",
"mode_installed": "Installerad",
"mode_installed_desc": "Använder systemets standardmappar. Rekommenderas för de flesta.",
"mode_portable": "Portabel",
"mode_portable_desc": "Allt ligger i en mapp bredvid appen — flytta den som en enhet till en annan disk eller dator.",
"mode_portable_unavailable": "Otillgängligt: mappen bredvid appen är inte skrivbar.",
"storage_title": "Lagring",
"portable_folder": "Portabel mapp",
"portable_folder_desc": "Miljö, modeller och dina röstdata — en mapp, helt flyttbar.",
"env_dir": "Appmiljö",
"env_dir_desc": "Python-runtime och AI-bibliotek.",
"data_dir": "Röstdata & projekt",
"data_dir_desc": "Dina röster, dubbningar, utdata och projektdatabasen.",
"models_dir": "Modellcache",
"models_dir_desc": "Nedladdade AI-modeller — den största och mest flyttbara delen.",
"needs": "kräver ~{{size}}",
"free": "{{size}} ledigt",
"checking": "kontrollerar…",
"not_writable": "ej skrivbar",
"change": "Ändra…",
"compute_title": "Beräkning",
"compute_label": "GPU / accelerator",
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD GPU (ROCm, Linux)",
"channel_label": "Uppdateringskanal",
"channel_stable": "Stabil",
"channel_preview": "Förhandsvisning (senaste main)",
"network_title": "Nätverk",
"region_label": "Nedladdningsregion",
"mirrors_title": "Egna speglar (avancerat)",
"mirror_pypi": "PyPI-index-URL",
"mirror_hf": "Hugging Face-endpoint",
"mirror_python": "Python-nedladdningsspegel",
"insufficient_space": "Otillräckligt utrymme: den här layouten kräver ~{{need}} på en disk, endast {{free}} ledigt. Välj en annan plats.",
"blocked_not_writable": "En vald mapp är inte skrivbar — välj en annan plats.",
"total_required": "Totalt diskbehov: ~{{size}} (engångsnedladdning vid första användning)",
"start": "Starta installationen",
"starting": "Startar…",
"compute_detected": "Identifierad",
"compute_match": "matchar den här datorn",
"compute_auto_desc": "Väljer den bästa backenden på den här datorn vid körning — CUDA på NVIDIA, MPS på Apple Silicon, annars CPU.",
"compute_rocm_desc": "Installerar PyTorch ROCm-paket för AMD-grafikkort på Linux. Lämna på Auto om du är osäker.",
"channel_stable_desc": "Endast testade utgåvor — uppdateringar kommer efter community-validering.",
"channel_preview_desc": "Rullande byggen från senaste main — nya motorer och fixar först, ibland lite ojämnt.",
"installing_title": "Installerar",
"activity_title": "Aktivitet",
"stage_setup": "Installation",
"stage_models": "Modeller & motorer",
"chip_required": "krävs",
"chip_optional": "valfri",
"chip_engine": "motor",
"lib_download": "Ladda ner",
"lib_downloading": "laddar ner…",
"lib_use": "Använd",
"lib_active": "aktiv",
"lib_in_settings": "installera senare i Inställningar",
"lib_show_all": "Visa {{count}} valfria modeller"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "ไม่พบการเผยแพร่",
"load_error": "ไม่สามารถโหลดรุ่นต่างๆ (ออฟไลน์?)",
"retry_load": "ลองอีกครั้ง"
},
"firstrun": {
"loading": "กำลังเตรียมการติดตั้ง…",
"title": "ตั้งค่า OmniVoice Studio",
"subtitle": "ยังไม่มีการติดตั้งใด ๆ — ตรวจสอบตำแหน่งจัดเก็บทั้งหมดก่อน แล้วจึงเริ่ม สามารถเปลี่ยนภายหลังได้ในการตั้งค่า",
"language": "ภาษา",
"mode_title": "โหมดการติดตั้ง",
"mode_installed": "ติดตั้งปกติ",
"mode_installed_desc": "ใช้โฟลเดอร์มาตรฐานของระบบ แนะนำสำหรับผู้ใช้ส่วนใหญ่",
"mode_portable": "พกพา",
"mode_portable_desc": "ทุกอย่างอยู่ในโฟลเดอร์เดียวข้างแอป — ย้ายไปยังดิสก์หรือเครื่องอื่นได้ทั้งชุด",
"mode_portable_unavailable": "ใช้ไม่ได้: โฟลเดอร์ข้างแอปไม่สามารถเขียนได้",
"storage_title": "พื้นที่จัดเก็บ",
"portable_folder": "โฟลเดอร์พกพา",
"portable_folder_desc": "สภาพแวดล้อม โมเดล และข้อมูลเสียงของคุณ — โฟลเดอร์เดียว ย้ายได้ทั้งหมด",
"env_dir": "สภาพแวดล้อมของแอป",
"env_dir_desc": "Python runtime และไลบรารี AI",
"data_dir": "ข้อมูลเสียงและโปรเจ็กต์",
"data_dir_desc": "เสียงของคุณ งานพากย์ ไฟล์ผลลัพธ์ และฐานข้อมูลโปรเจ็กต์",
"models_dir": "แคชโมเดล",
"models_dir_desc": "โมเดล AI ที่ดาวน์โหลดแล้ว — ส่วนที่ใหญ่ที่สุดและย้ายง่ายที่สุด",
"needs": "ต้องการ ~{{size}}",
"free": "ว่าง {{size}}",
"checking": "กำลังตรวจสอบ…",
"not_writable": "เขียนไม่ได้",
"change": "เปลี่ยน…",
"compute_title": "การประมวลผล",
"compute_label": "GPU / ตัวเร่งความเร็ว",
"compute_auto": "อัตโนมัติ (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "ช่องทางอัปเดต",
"channel_stable": "เสถียร",
"channel_preview": "พรีวิว (main ล่าสุด)",
"network_title": "เครือข่าย",
"region_label": "ภูมิภาคการดาวน์โหลด",
"mirrors_title": "มิเรอร์กำหนดเอง (ขั้นสูง)",
"mirror_pypi": "URL ดัชนี PyPI",
"mirror_hf": "ปลายทาง Hugging Face",
"mirror_python": "มิเรอร์ดาวน์โหลด Python",
"insufficient_space": "พื้นที่ไม่พอ: รูปแบบนี้ต้องการ ~{{need}} บนดิสก์เดียว แต่เหลือเพียง {{free}} โปรดเลือกตำแหน่งอื่น",
"blocked_not_writable": "โฟลเดอร์ที่เลือกเขียนไม่ได้ — โปรดเลือกตำแหน่งอื่น",
"total_required": "พื้นที่ดิสก์ที่ต้องใช้ทั้งหมด: ~{{size}} (ดาวน์โหลดครั้งเดียวเมื่อใช้ครั้งแรก)",
"start": "เริ่มการติดตั้ง",
"starting": "กำลังเริ่ม…",
"compute_detected": "ตรวจพบ",
"compute_match": "ตรงกับเครื่องนี้",
"compute_auto_desc": "เลือกแบ็กเอนด์ที่ดีที่สุดของเครื่องนี้ขณะทำงาน — CUDA บน NVIDIA, MPS บน Apple Silicon, นอกนั้นใช้ CPU",
"compute_rocm_desc": "ติดตั้ง PyTorch ROCm สำหรับการ์ดจอ AMD บน Linux หากไม่แน่ใจให้คงไว้ที่อัตโนมัติ",
"channel_stable_desc": "เฉพาะรุ่นที่ทดสอบแล้ว — อัปเดตมาถึงหลังการตรวจสอบจากชุมชน",
"channel_preview_desc": "บิลด์ต่อเนื่องจาก main ล่าสุด — ได้เอนจินและการแก้ไขใหม่ก่อนใคร อาจมีจุดขรุขระบ้าง",
"installing_title": "กำลังติดตั้ง",
"activity_title": "กิจกรรม",
"stage_setup": "ตั้งค่า",
"stage_models": "โมเดลและเอนจิน",
"chip_required": "จำเป็น",
"chip_optional": "ตัวเลือก",
"chip_engine": "เอนจิน",
"lib_download": "ดาวน์โหลด",
"lib_downloading": "กำลังดาวน์โหลด…",
"lib_use": "ใช้",
"lib_active": "ใช้งานอยู่",
"lib_in_settings": "ติดตั้งภายหลังในการตั้งค่า",
"lib_show_all": "แสดงโมเดลตัวเลือก {{count}} รายการ"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Yayın bulunamadı",
"load_error": "Sürümler yüklenemedi (çevrimdışı mı?)",
"retry_load": "Yeniden dene"
},
"firstrun": {
"loading": "Kurulum hazırlanıyor…",
"title": "OmniVoice Studio'yu kur",
"subtitle": "Henüz hiçbir şey kurulmadı — her şeyin nereye gideceğini gözden geçirin, sonra başlatın. Daha sonra Ayarlar'dan değiştirebilirsiniz.",
"language": "Dil",
"mode_title": "Kurulum modu",
"mode_installed": "Kurulu",
"mode_installed_desc": "Standart sistem klasörlerini kullanır. Çoğu kullanıcı için önerilir.",
"mode_portable": "Taşınabilir",
"mode_portable_desc": "Her şey uygulamanın yanındaki tek bir klasörde durur — başka bir diske veya makineye bütün halinde taşıyın.",
"mode_portable_unavailable": "Kullanılamıyor: uygulamanın yanındaki klasör yazılabilir değil.",
"storage_title": "Depolama",
"portable_folder": "Taşınabilir klasör",
"portable_folder_desc": "Çalışma ortamı, modeller ve ses verileriniz — tek klasör, tamamen taşınabilir.",
"env_dir": "Uygulama ortamı",
"env_dir_desc": "Python çalışma zamanı ve yapay zekâ kitaplıkları.",
"data_dir": "Ses verileri ve projeler",
"data_dir_desc": "Sesleriniz, dublajlar, çıktılar ve proje veritabanı.",
"models_dir": "Model önbelleği",
"models_dir_desc": "İndirilen yapay zekâ modelleri — en büyük ve en kolay taşınan kısım.",
"needs": "~{{size}} gerekir",
"free": "{{size}} boş",
"checking": "denetleniyor…",
"not_writable": "yazılamaz",
"change": "Değiştir…",
"compute_title": "Hesaplama",
"compute_label": "GPU / hızlandırıcı",
"compute_auto": "Otomatik (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "AMD GPU (ROCm, Linux)",
"channel_label": "Güncelleme kanalı",
"channel_stable": "Kararlı",
"channel_preview": "Önizleme (en yeni main)",
"network_title": "Ağ",
"region_label": "İndirme bölgesi",
"mirrors_title": "Özel yansılar (gelişmiş)",
"mirror_pypi": "PyPI dizin URL'si",
"mirror_hf": "Hugging Face uç noktası",
"mirror_python": "Python indirme yansısı",
"insufficient_space": "Yetersiz alan: bu düzen tek diskte ~{{need}} gerektirir, yalnızca {{free}} boş. Başka bir konum seçin.",
"blocked_not_writable": "Seçilen bir klasör yazılabilir değil — başka bir konum seçin.",
"total_required": "Gereken toplam disk: ~{{size}} (ilk kullanımda tek seferlik indirme)",
"start": "Kurulumu başlat",
"starting": "Başlatılıyor…",
"compute_detected": "Algılandı",
"compute_match": "bu makineyle eşleşiyor",
"compute_auto_desc": "Çalışma anında bu makinenin en iyi arka ucunu seçer — NVIDIA'da CUDA, Apple Silicon'da MPS, aksi halde CPU.",
"compute_rocm_desc": "Linux'ta AMD ekran kartları için PyTorch ROCm paketlerini kurar. Emin değilseniz Otomatik'te bırakın.",
"channel_stable_desc": "Yalnızca test edilmiş sürümler — güncellemeler topluluk doğrulamasından sonra gelir.",
"channel_preview_desc": "En yeni main'den sürekli derlemeler — yeni motorlar ve düzeltmeler önce gelir, ara sıra pürüz olabilir.",
"installing_title": "Kuruluyor",
"activity_title": "Etkinlik",
"stage_setup": "Kurulum",
"stage_models": "Modeller ve motorlar",
"chip_required": "gerekli",
"chip_optional": "isteğe bağlı",
"chip_engine": "motor",
"lib_download": "İndir",
"lib_downloading": "indiriliyor…",
"lib_use": "Kullan",
"lib_active": "etkin",
"lib_in_settings": "daha sonra Ayarlardan kur",
"lib_show_all": "{{count}} isteğe bağlı modeli göster"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Випусків не знайдено",
"load_error": "Не вдалося завантажити випуски (офлайн?)",
"retry_load": "Повторіть спробу"
},
"firstrun": {
"loading": "Підготовка налаштування…",
"title": "Налаштування OmniVoice Studio",
"subtitle": "Поки нічого не встановлено — перегляньте, куди все буде збережено, і починайте. Потім це можна змінити в Налаштуваннях.",
"language": "Мова",
"mode_title": "Режим встановлення",
"mode_installed": "Встановлений",
"mode_installed_desc": "Використовує стандартні системні теки. Рекомендовано більшості.",
"mode_portable": "Портативний",
"mode_portable_desc": "Усе зберігається в одній теці поряд із застосунком — переносьте її цілком на інший диск чи комп'ютер.",
"mode_portable_unavailable": "Недоступно: тека поряд із застосунком недоступна для запису.",
"storage_title": "Сховище",
"portable_folder": "Портативна тека",
"portable_folder_desc": "Середовище, моделі та ваші голосові дані — одна тека, повністю переносна.",
"env_dir": "Середовище застосунку",
"env_dir_desc": "Python та бібліотеки ШІ.",
"data_dir": "Голосові дані та проєкти",
"data_dir_desc": "Ваші голоси, дубляжі, результати та база даних проєктів.",
"models_dir": "Кеш моделей",
"models_dir_desc": "Завантажені моделі ШІ — найбільша та найлегша до переносу частина.",
"needs": "потрібно ~{{size}}",
"free": "вільно {{size}}",
"checking": "перевірка…",
"not_writable": "запис неможливий",
"change": "Змінити…",
"compute_title": "Обчислення",
"compute_label": "GPU / прискорювач",
"compute_auto": "Авто (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Канал оновлень",
"channel_stable": "Стабільний",
"channel_preview": "Попередній (останній main)",
"network_title": "Мережа",
"region_label": "Регіон завантаження",
"mirrors_title": "Власні дзеркала (додатково)",
"mirror_pypi": "URL індексу PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Дзеркало завантажень Python",
"insufficient_space": "Недостатньо місця: ця схема потребує ~{{need}} на одному диску, доступно лише {{free}}. Виберіть інше розташування.",
"blocked_not_writable": "Вибрана тека недоступна для запису — виберіть інше розташування.",
"total_required": "Загалом потрібно: ~{{size}} (одноразове завантаження під час першого використання)",
"start": "Почати встановлення",
"starting": "Запуск…",
"compute_detected": "Виявлено",
"compute_match": "відповідає цій машині",
"compute_auto_desc": "Обирає найкращий бекенд цієї машини під час запуску — CUDA на NVIDIA, MPS на Apple Silicon, інакше CPU.",
"compute_rocm_desc": "Встановлює ROCm-збірки PyTorch для відеокарт AMD у Linux. Якщо не впевнені — залиште «Авто».",
"channel_stable_desc": "Лише перевірені релізи — оновлення надходять після перевірки спільнотою.",
"channel_preview_desc": "Ковзні збірки з останнього main — нові рушії та виправлення першими, зрідка з шорсткостями.",
"installing_title": "Встановлення",
"activity_title": "Журнал",
"stage_setup": "Налаштування",
"stage_models": "Моделі та рушії",
"chip_required": "обов’язково",
"chip_optional": "опційно",
"chip_engine": "рушій",
"lib_download": "Завантажити",
"lib_downloading": "завантаження…",
"lib_use": "Використати",
"lib_active": "активний",
"lib_in_settings": "встановити пізніше в налаштуваннях",
"lib_show_all": "Показати опційні моделі: {{count}}"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "Không tìm thấy bản phát hành nào",
"load_error": "Không thể tải bản phát hành (ngoại tuyến?)",
"retry_load": "Thử lại"
},
"firstrun": {
"loading": "Đang chuẩn bị thiết lập…",
"title": "Thiết lập OmniVoice Studio",
"subtitle": "Chưa có gì được cài đặt — hãy xem lại nơi lưu mọi thứ rồi bắt đầu. Có thể thay đổi sau trong Cài đặt.",
"language": "Ngôn ngữ",
"mode_title": "Chế độ cài đặt",
"mode_installed": "Cài đặt",
"mode_installed_desc": "Dùng các thư mục hệ thống tiêu chuẩn. Khuyến nghị cho đa số người dùng.",
"mode_portable": "Di động",
"mode_portable_desc": "Mọi thứ nằm trong một thư mục cạnh ứng dụng — di chuyển nguyên khối sang ổ đĩa hoặc máy khác.",
"mode_portable_unavailable": "Không khả dụng: thư mục cạnh ứng dụng không ghi được.",
"storage_title": "Lưu trữ",
"portable_folder": "Thư mục di động",
"portable_folder_desc": "Môi trường, mô hình và dữ liệu giọng nói của bạn — một thư mục, di chuyển trọn vẹn.",
"env_dir": "Môi trường ứng dụng",
"env_dir_desc": "Python runtime và các thư viện AI.",
"data_dir": "Dữ liệu giọng nói & dự án",
"data_dir_desc": "Giọng nói, lồng tiếng, tệp xuất và cơ sở dữ liệu dự án của bạn.",
"models_dir": "Bộ nhớ đệm mô hình",
"models_dir_desc": "Các mô hình AI đã tải — phần lớn nhất và dễ di dời nhất.",
"needs": "cần ~{{size}}",
"free": "còn trống {{size}}",
"checking": "đang kiểm tra…",
"not_writable": "không ghi được",
"change": "Thay đổi…",
"compute_title": "Tính toán",
"compute_label": "GPU / bộ tăng tốc",
"compute_auto": "Tự động (NVIDIA CUDA / Apple MPS / CPU)",
"compute_rocm": "GPU AMD (ROCm, Linux)",
"channel_label": "Kênh cập nhật",
"channel_stable": "Ổn định",
"channel_preview": "Xem trước (main mới nhất)",
"network_title": "Mạng",
"region_label": "Khu vực tải xuống",
"mirrors_title": "Mirror tùy chỉnh (nâng cao)",
"mirror_pypi": "URL chỉ mục PyPI",
"mirror_hf": "Endpoint Hugging Face",
"mirror_python": "Mirror tải Python",
"insufficient_space": "Không đủ dung lượng: bố cục này cần ~{{need}} trên cùng một ổ đĩa, chỉ còn {{free}}. Hãy chọn vị trí khác.",
"blocked_not_writable": "Một thư mục đã chọn không ghi được — hãy chọn vị trí khác.",
"total_required": "Tổng dung lượng cần: ~{{size}} (tải một lần khi dùng lần đầu)",
"start": "Bắt đầu cài đặt",
"starting": "Đang khởi động…",
"compute_detected": "Đã phát hiện",
"compute_match": "khớp với máy này",
"compute_auto_desc": "Chọn backend tốt nhất của máy này khi chạy — CUDA trên NVIDIA, MPS trên Apple Silicon, còn lại dùng CPU.",
"compute_rocm_desc": "Cài wheel PyTorch ROCm cho card đồ họa AMD trên Linux. Nếu không chắc, hãy để Tự động.",
"channel_stable_desc": "Chỉ các bản phát hành đã kiểm thử — cập nhật đến sau khi cộng đồng xác nhận.",
"channel_preview_desc": "Bản dựng liên tục từ main mới nhất — engine và bản sửa mới nhất trước tiên, đôi khi còn thô.",
"installing_title": "Đang cài đặt",
"activity_title": "Hoạt động",
"stage_setup": "Thiết lập",
"stage_models": "Mô hình & engine",
"chip_required": "bắt buộc",
"chip_optional": "tùy chọn",
"chip_engine": "engine",
"lib_download": "Tải xuống",
"lib_downloading": "đang tải…",
"lib_use": "Dùng",
"lib_active": "đang dùng",
"lib_in_settings": "cài sau trong Cài đặt",
"lib_show_all": "Hiện {{count}} mô hình tùy chọn"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "未找到任何版本",
"load_error": "无法加载版本(离线?)",
"retry_load": "重试"
},
"firstrun": {
"loading": "正在准备安装向导…",
"title": "设置 OmniVoice Studio",
"subtitle": "尚未安装任何内容——先确认各项存储位置,再开始安装。之后可在设置中更改。",
"language": "语言",
"mode_title": "安装模式",
"mode_installed": "标准安装",
"mode_installed_desc": "使用系统标准文件夹。推荐大多数用户使用。",
"mode_portable": "便携模式",
"mode_portable_desc": "所有内容都放在应用旁的一个文件夹里——可整体移动到其他磁盘或电脑。",
"mode_portable_unavailable": "不可用:应用所在文件夹不可写。",
"storage_title": "存储",
"portable_folder": "便携文件夹",
"portable_folder_desc": "运行环境、模型和您的语音数据——一个文件夹,可整体移动。",
"env_dir": "应用环境",
"env_dir_desc": "Python 运行时与 AI 库。",
"data_dir": "语音数据与项目",
"data_dir_desc": "您的声音、配音、输出文件和项目数据库。",
"models_dir": "模型缓存",
"models_dir_desc": "已下载的 AI 模型——占用最大、最适合放到别的磁盘。",
"needs": "约需 {{size}}",
"free": "可用 {{size}}",
"checking": "检查中…",
"not_writable": "不可写",
"change": "更改…",
"compute_title": "计算",
"compute_label": "GPU / 加速器",
"compute_auto": "自动(NVIDIA CUDA / Apple MPS / CPU",
"compute_rocm": "AMD 显卡(ROCmLinux",
"channel_label": "更新通道",
"channel_stable": "稳定版",
"channel_preview": "预览版(最新 main",
"network_title": "网络",
"region_label": "下载区域",
"mirrors_title": "自定义镜像(高级)",
"mirror_pypi": "PyPI 索引地址",
"mirror_hf": "Hugging Face 端点",
"mirror_python": "Python 下载镜像",
"insufficient_space": "磁盘空间不足:此方案需要同一磁盘约 {{need}},当前仅剩 {{free}}。请更换位置。",
"blocked_not_writable": "所选文件夹不可写——请更换位置。",
"total_required": "共需磁盘空间:约 {{size}}(首次使用时一次性下载)",
"start": "开始安装",
"starting": "正在启动…",
"compute_detected": "已检测",
"compute_match": "与本机匹配",
"compute_auto_desc": "运行时自动选择本机最佳后端——NVIDIA 用 CUDA、Apple 芯片用 MPS,否则用 CPU。",
"compute_rocm_desc": "为 Linux 上的 AMD 显卡安装 PyTorch ROCm 版本。不确定时请保持“自动”。",
"channel_stable_desc": "仅推送经过测试的正式版本——经社区验证后才更新。",
"channel_preview_desc": "来自最新 main 分支的滚动构建——最先获得新引擎和修复,偶有小问题。",
"installing_title": "正在安装",
"activity_title": "活动日志",
"stage_setup": "设置",
"stage_models": "模型与引擎",
"chip_required": "必需",
"chip_optional": "可选",
"chip_engine": "引擎",
"lib_download": "下载",
"lib_downloading": "下载中…",
"lib_use": "使用",
"lib_active": "当前",
"lib_in_settings": "稍后在设置中安装",
"lib_show_all": "显示 {{count}} 个可选模型"
}
}
}
+64 -1
View File
@@ -1597,5 +1597,68 @@
"none": "未找到任何版本",
"load_error": "無法載入版本(離線?)",
"retry_load": "重試"
},
"firstrun": {
"loading": "正在準備安裝精靈…",
"title": "設定 OmniVoice Studio",
"subtitle": "尚未安裝任何內容——先確認各項儲存位置,再開始安裝。之後可在設定中變更。",
"language": "語言",
"mode_title": "安裝模式",
"mode_installed": "標準安裝",
"mode_installed_desc": "使用系統標準資料夾。建議大多數使用者採用。",
"mode_portable": "可攜模式",
"mode_portable_desc": "所有內容都放在應用程式旁的一個資料夾——可整體移動到其他磁碟或電腦。",
"mode_portable_unavailable": "無法使用:應用程式旁的資料夾不可寫入。",
"storage_title": "儲存",
"portable_folder": "可攜資料夾",
"portable_folder_desc": "執行環境、模型與您的語音資料——一個資料夾,可整體搬移。",
"env_dir": "應用程式環境",
"env_dir_desc": "Python 執行環境與 AI 函式庫。",
"data_dir": "語音資料與專案",
"data_dir_desc": "您的聲音、配音、輸出檔與專案資料庫。",
"models_dir": "模型快取",
"models_dir_desc": "已下載的 AI 模型——體積最大、最適合放到其他磁碟。",
"needs": "約需 {{size}}",
"free": "可用 {{size}}",
"checking": "檢查中…",
"not_writable": "不可寫入",
"change": "變更…",
"compute_title": "運算",
"compute_label": "GPU / 加速器",
"compute_auto": "自動(NVIDIA CUDA / Apple MPS / CPU",
"compute_rocm": "AMD 顯示卡(ROCmLinux",
"channel_label": "更新頻道",
"channel_stable": "穩定版",
"channel_preview": "預覽版(最新 main",
"network_title": "網路",
"region_label": "下載區域",
"mirrors_title": "自訂鏡像(進階)",
"mirror_pypi": "PyPI 索引網址",
"mirror_hf": "Hugging Face 端點",
"mirror_python": "Python 下載鏡像",
"insufficient_space": "磁碟空間不足:此配置需要同一磁碟約 {{need}},目前僅剩 {{free}}。請更換位置。",
"blocked_not_writable": "所選資料夾不可寫入——請更換位置。",
"total_required": "共需磁碟空間:約 {{size}}(首次使用時一次性下載)",
"start": "開始安裝",
"starting": "正在啟動…",
"compute_detected": "已偵測",
"compute_match": "與本機相符",
"compute_auto_desc": "執行時自動選擇本機最佳後端——NVIDIA 用 CUDA、Apple 晶片用 MPS,否則用 CPU。",
"compute_rocm_desc": "為 Linux 上的 AMD 顯示卡安裝 PyTorch ROCm 版本。不確定時請保持「自動」。",
"channel_stable_desc": "僅推送經過測試的正式版本——經社群驗證後才更新。",
"channel_preview_desc": "來自最新 main 分支的滾動建置——最先獲得新引擎與修復,偶有小問題。",
"installing_title": "正在安裝",
"activity_title": "活動日誌",
"stage_setup": "設定",
"stage_models": "模型與引擎",
"chip_required": "必需",
"chip_optional": "可選",
"chip_engine": "引擎",
"lib_download": "下載",
"lib_downloading": "下載中…",
"lib_use": "使用",
"lib_active": "目前",
"lib_in_settings": "稍後在設定中安裝",
"lib_show_all": "顯示 {{count}} 個可選模型"
}
}
}
+97 -520
View File
@@ -1,556 +1,133 @@
/*
SetupWizard premium onboarding flow
*/
/* Model wizard final act of the first-run journey. All structural styling
* comes from the shared studio-console system (frs-* in FirstRunSetup.css);
* this file only carries the wizard-specific glue. */
.setup-wizard {
width: 100%;
max-width: none;
margin: 0;
padding: 0 32px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ── Slide transition for step content ──────────────────────────────── */
@keyframes swiz-enter {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes swiz-enter-back {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
/* Slides stack the act's panel + nav with the deck's rhythm. */
.swiz-slide {
animation: swiz-enter 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
display: contents;
}
/* ── Step pills — connected stepper bar ─────────────────────────────── */
.setup-wizard__steps {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding: 8px 12px 0;
flex-shrink: 0;
flex-wrap: wrap;
position: relative;
}
.setup-wizard__step {
padding: 6px 16px;
border-radius: 999px;
font-size: 0.74rem;
letter-spacing: 0.02em;
line-height: 1.4;
color: var(--color-fg-muted, #928374);
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
font-weight: 500;
}
.setup-wizard__step:hover:not(.setup-wizard__step--active) {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.setup-wizard__step--active {
background: linear-gradient(135deg, rgba(211, 134, 155, 0.2), rgba(211, 134, 155, 0.1));
border-color: rgba(211, 134, 155, 0.4);
color: #f3a5b6;
font-weight: 600;
box-shadow:
0 0 0 1px rgba(211, 134, 155, 0.15),
0 0 12px rgba(211, 134, 155, 0.1);
}
.setup-wizard__step--done {
color: #8ec07c;
border-color: rgba(142, 192, 124, 0.3);
background: rgba(142, 192, 124, 0.06);
}
/* Connector lines between pills */
.setup-wizard__step-connector {
width: 20px;
height: 1px;
background: rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.setup-wizard__step-connector--done {
background: rgba(142, 192, 124, 0.3);
}
/* ── Hero — horizontal single-line ───────────────────────────────────── */
.setup-wizard__hero {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 6px 12px 4px;
flex-shrink: 0;
max-width: 760px;
margin-left: auto;
margin-right: auto;
}
.setup-wizard__logo {
width: 32px;
height: 32px;
flex-shrink: 0;
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.3));
}
.setup-wizard__hero-text {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
flex-direction: column;
gap: 1.1rem;
min-width: 0;
}
.setup-wizard__hero h1 {
.swiz-note {
margin: 0;
font-size: 1.2rem;
font-family: var(--font-display, var(--font-sans));
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
white-space: nowrap;
}
.setup-wizard__sub {
margin: 0;
color: var(--color-fg-muted);
font-size: 0.78rem;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
/* ── Embed panel — the ONLY scrollable region ────────────────────────── */
.setup-wizard__embed {
padding: 4px 0;
margin-top: 4px;
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* ── Nav bar — pinned to bottom ──────────────────────────────────────── */
.setup-wizard__nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 0 6px;
background: var(--color-bg, #1d2021);
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
/* ── Card / checklist rows ───────────────────────────────────────────── */
.setup-wizard__card {
display: flex;
flex-direction: column;
gap: 10px;
}
.setup-wizard__card--error {
background: rgba(251, 73, 52, 0.06);
border-color: rgba(251, 73, 52, 0.3);
color: var(--color-danger);
}
.setup-wizard__card-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--color-fg-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.setup-wizard__row {
display: flex;
align-items: center;
gap: 12px;
}
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.setup-wizard__row-title { font-weight: 600; font-size: 0.84rem; line-height: 1.4; }
.setup-wizard__muted {
color: var(--color-fg-muted);
font-size: 0.74rem;
line-height: 1.5;
}
.setup-wizard__muted code { color: var(--color-fg-subtle); }
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
/* ── Welcome step — glassmorphism cards with stagger ─────────────────── */
.setup-wizard__welcome {
display: flex;
flex-direction: column;
gap: 10px;
}
.setup-wizard__welcome-grid {
display: flex;
flex-direction: column;
gap: 6px;
opacity: 0.6;
}
@keyframes swiz-card-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
.swiz-note--warn {
opacity: 1;
color: var(--chrome-severity-warn, #d79921);
}
.swiz-welcome-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
animation: swiz-card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
position: relative;
overflow: hidden;
}
.swiz-welcome-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(to bottom, rgba(211, 134, 155, 0.5), rgba(211, 134, 155, 0.1));
border-radius: 3px 0 0 3px;
}
.swiz-welcome-card:nth-child(1) { animation-delay: 0s; }
.swiz-welcome-card:nth-child(2) { animation-delay: 0.08s; }
.swiz-welcome-card:nth-child(3) { animation-delay: 0.16s; }
.swiz-welcome-card:hover {
background: rgba(255, 255, 255, 0.045);
border-color: rgba(255, 255, 255, 0.1);
transform: translateX(2px);
}
.swiz-welcome-card__icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(211, 134, 155, 0.1);
color: #d3869b;
}
.swiz-welcome-card__body {
flex: 1;
min-width: 0;
}
.swiz-welcome-card__title {
font-weight: 600;
font-size: 0.82rem;
line-height: 1.3;
margin-bottom: 2px;
display: block;
}
.swiz-welcome-card__desc {
margin: 0;
color: var(--color-fg-muted);
font-size: 0.78rem;
line-height: 1.55;
}
.swiz-welcome-note {
margin: 0;
color: var(--color-fg-subtle);
font-size: 0.74rem;
line-height: 1.5;
text-align: center;
opacity: 0;
animation: swiz-card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) 0.28s both;
}
/* ── Preflight checklist ─────────────────────────────────────────────── */
.swiz-checklist {
display: flex;
flex-direction: column;
gap: 2px;
}
.swiz-check-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.swiz-check-header__label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-fg-muted);
}
@keyframes swiz-check-in {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
.swiz-check-row {
animation: swiz-check-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.swiz-check-row:nth-child(2) { animation-delay: 0.04s; }
.swiz-check-row:nth-child(3) { animation-delay: 0.08s; }
.swiz-check-row:nth-child(4) { animation-delay: 0.12s; }
.swiz-check-row:nth-child(5) { animation-delay: 0.16s; }
.swiz-check-row:nth-child(6) { animation-delay: 0.20s; }
.swiz-check-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.swiz-check-icon--pass {
background: rgba(142, 192, 124, 0.12);
color: #8ec07c;
}
.swiz-check-icon--warn {
background: rgba(250, 189, 47, 0.12);
color: #fabd2f;
}
.swiz-check-icon--fail {
background: rgba(251, 73, 52, 0.12);
color: #fb4934;
}
/* Recheck action lives inside the engraved panel title row. */
.swiz-recheck { letter-spacing: 0; text-transform: none; }
.swiz-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
color: var(--color-fg-muted);
font-size: 0.8rem;
}
.swiz-loading .spinner { animation: spin 1s linear infinite; }
/* ── Model list ──────────────────────────────────────────────────────── */
.setup-wizard__models {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.setup-wizard__models li {
display: flex;
align-items: center;
gap: 8px;
gap: 0.5rem;
font-size: 0.78rem;
}
.setup-wizard__models code { font-size: 0.68rem; }
.setup-wizard__aggregate {
display: flex;
flex-direction: column;
gap: 6px;
opacity: 0.65;
padding: 0.4rem 0;
}
.setup-wizard__files {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
/* Embedded Settings panels (Model Store / Engines / DictationDemo) manage
* their own internals; cap their height so the act scrolls inside itself
* rather than pushing the nav off-screen. */
.frs-embed {
max-height: min(58vh, 640px);
overflow-y: auto;
padding: 4px 2px 2px;
}
.setup-wizard__file {
display: grid;
grid-template-columns: 1fr 120px 44px;
align-items: center;
gap: 10px;
font-size: 0.72rem;
}
.setup-wizard__file-name {
font-family: var(--font-mono);
color: var(--chrome-fg-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setup-wizard__file-pct {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--color-fg-muted);
border-radius: 10px;
}
.setup-wizard__actions {
/* ── Unified library list (models + engines, one grammar) ─────────────── */
.swiz-lib {
display: flex;
gap: 10px;
justify-content: center;
padding-top: 8px;
flex-direction: column;
gap: 0.35rem;
max-height: min(56vh, 620px);
overflow-y: auto;
}
/* ── Inline HF token ─────────────────────────────────────────────────── */
.models-toolbar__actions {
display: flex;
align-items: center;
gap: 6px;
}
.models-toolbar__hf-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 6px;
font-size: 0.68rem;
color: #fe8019;
background: rgba(254, 128, 25, 0.08);
border: 1px solid rgba(254, 128, 25, 0.2);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.models-toolbar__hf-btn:hover {
background: rgba(254, 128, 25, 0.15);
border-color: rgba(254, 128, 25, 0.35);
}
.models-toolbar__hf-row {
display: flex;
align-items: center;
gap: 4px;
}
.models-toolbar__hf-input {
width: 150px;
padding: 3px 8px;
border-radius: 5px;
border: 1px solid rgba(254, 128, 25, 0.25);
background: rgba(255, 255, 255, 0.04);
color: var(--chrome-fg, #ebdbb2);
font-size: 0.7rem;
font-family: var(--font-mono);
outline: none;
transition: border-color 0.15s;
}
.models-toolbar__hf-input:focus {
border-color: rgba(254, 128, 25, 0.5);
}
.models-toolbar__hf-input::placeholder {
color: var(--chrome-fg-dim, #665c54);
}
.models-toolbar__hf-ok {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.66rem;
color: #8ec07c;
padding: 2px 6px;
border-radius: 999px;
background: rgba(142, 192, 124, 0.08);
border: 1px solid rgba(142, 192, 124, 0.2);
}
.models-toolbar__hf-link {
font-size: 0.68rem;
color: #83a598;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s;
}
.models-toolbar__hf-link:hover {
color: #b8bb26;
text-decoration: underline;
}
.swiz-lib__row { gap: 0.8rem; padding: 0.5rem 0.7rem; }
/* ── Footnote — polished ─────────────────────────────────────────────── */
.setup-wizard__footnote {
color: var(--color-fg-subtle, #665c54);
font-size: 0.66rem;
margin: 0;
padding: 6px 0 8px;
text-align: center;
line-height: 1.5;
.swiz-lib__led {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
}
.setup-wizard__footnote code {
font-size: 0.62rem;
color: var(--chrome-fg-dim, #665c54);
background: rgba(255, 255, 255, 0.03);
padding: 1px 5px;
border-radius: 4px;
.swiz-lib__led--ok {
background: var(--frs-ok);
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
}
.setup-wizard__footnote-link {
color: var(--chrome-accent, #d3869b);
.swiz-lib__led--active {
background: var(--frs-accent);
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
}
.swiz-lib__led--busy {
background: var(--frs-accent);
animation: frs-hw-pulse 1.4s ease-in-out infinite;
}
.swiz-lib__chip { margin-left: 0.5rem; }
.swiz-lib__chip--req {
color: color-mix(in srgb, var(--frs-accent) 90%, var(--frs-ink));
background: color-mix(in srgb, var(--frs-accent) 14%, transparent);
}
.swiz-lib__chip--eng {
color: color-mix(in srgb, var(--frs-ink) 75%, transparent);
background: color-mix(in srgb, var(--frs-ink) 10%, transparent);
}
.swiz-lib__chip--opt {
color: color-mix(in srgb, var(--frs-ink) 60%, transparent);
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
}
.swiz-lib__state {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.64rem;
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: none;
opacity: 0.8;
transition: opacity 0.15s;
}
.setup-wizard__footnote-link:hover {
opacity: 1;
text-decoration: underline;
opacity: 0.55;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Missing models indicator ────────────────────────────────────────── */
.swiz-missing {
padding: 6px 10px;
border-radius: 6px;
background: rgba(251, 73, 52, 0.06);
border: 1px solid rgba(251, 73, 52, 0.15);
font-size: 0.72rem;
.swiz-lib__state--active { color: var(--frs-accent); opacity: 1; }
.swiz-lib__state--busy { color: var(--frs-accent); opacity: 1; font-variant-numeric: tabular-nums; }
.swiz-lib__act { flex-shrink: 0; }
/* Slim inline download bar under the row label. */
.swiz-lib__bar {
display: block;
height: 3px;
margin-top: 0.3rem;
border-radius: 2px;
background: color-mix(in srgb, var(--frs-ink) 8%, transparent);
overflow: hidden;
max-width: 280px;
}
.swiz-status-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: var(--color-fg-muted);
font-size: 0.78rem;
.swiz-lib__bar > span {
display: block;
height: 100%;
border-radius: 2px;
background: var(--frs-accent);
transition: width 400ms ease;
}
.swiz-status-loading .spinner { animation: spin 1s linear infinite; }
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.setup-wizard {
padding: 0 14px;
}
.setup-wizard__steps { gap: 3px; padding: 8px 8px 0; }
.setup-wizard__step { padding: 4px 10px; font-size: 0.7rem; }
.setup-wizard__step-connector { width: 10px; }
.setup-wizard__hero { gap: 8px; padding: 6px 8px 4px; }
.setup-wizard__hero h1 { font-size: 1rem; }
.setup-wizard__sub { font-size: 0.72rem; }
.swiz-welcome-card { padding: 10px 14px; gap: 10px; }
.swiz-welcome-card__icon { width: 26px; height: 26px; }
}
.swiz-lib__sub { display: block; min-width: 0; }
.swiz-lib__more { align-self: flex-start; }
+209 -237
View File
@@ -1,13 +1,10 @@
import React, { useCallback, useEffect, useState, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
CheckCircle, Loader, ArrowRight, AlertTriangle, XCircle,
RefreshCw, Monitor, Download, Cog, FolderOpen,
} from 'lucide-react';
import { Button } from '../ui';
import { Loader } from 'lucide-react';
import { useSetupStatus, usePreflight } from '../api/hooks';
import { ModelStoreTab, EnginesTab } from './Settings';
import WizardLibrary from '../components/WizardLibrary';
import DictationDemo from '../components/DictationDemo';
import '../components/FirstRunSetup.css';
import './SetupWizard.css';
import '../components/Misc.css';
@@ -39,13 +36,30 @@ async function revealPath(path) {
} catch { /* ignore — probably web preview */ }
}
const CHECK_ICON = {
pass: <CheckCircle size={13} />,
warn: <AlertTriangle size={13} />,
fail: <XCircle size={13} />,
};
/** Whisper waveform — the journey's signature, same as setup + install. */
function Waveform({ bars = 96 }) {
const heights = useMemo(
() => Array.from({ length: bars }, (_, i) => {
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
);
return 0.18 + v * 0.82;
}),
[bars],
);
return (
<div className="frs-wave" aria-hidden="true">
{heights.map((h, i) => (
<span key={i} className="frs-wave__bar" style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }} />
))}
</div>
);
}
/* ── Preflight panel ───────────────────────────────────────────────────── */
/* ── Preflight panel — LED check rows ──────────────────────────────────── */
function PreflightPanel({ report, loading, onRecheck }) {
const { t } = useTranslation();
@@ -58,78 +72,74 @@ function PreflightPanel({ report, loading, onRecheck }) {
}
if (!report) return null;
return (
<div className="swiz-checklist">
<div className="swiz-check-header">
<span className="swiz-check-header__label">{t('setup.system_preflight')}</span>
<Button variant="ghost" size="sm" onClick={onRecheck} leading={<RefreshCw size={12} />}>
{t('setup.recheck')}
</Button>
</div>
<section className="frs-panel">
<h2 className="frs-panel__title">
{t('setup.system_preflight')}
<button type="button" className="frs-btn frs-btn--quiet swiz-recheck" onClick={onRecheck}>
{t('setup.recheck')}
</button>
</h2>
{report.checks.map((c) => (
<div key={c.id} className="setup-wizard__row swiz-check-row" style={{ alignItems: 'flex-start', padding: '8px 4px' }}>
<span className={`swiz-check-icon swiz-check-icon--${c.status}`}>
{CHECK_ICON[c.status] || null}
</span>
<div className="setup-wizard__row-body">
<span className="setup-wizard__row-title">{c.label}</span>
<span className="setup-wizard__muted" style={{ whiteSpace: 'normal' }}>{c.detail}</span>
<div key={c.id} className={`frs-check frs-check--${c.status}`}>
<span className="frs-check__led" aria-hidden="true" />
<div className="frs-check__body">
<span className="frs-check__title">{c.label}</span>
<span className="frs-check__detail">{c.detail}</span>
{c.fix && c.status !== 'pass' && (
<span className="setup-wizard__muted" style={{
color: c.status === 'fail' ? 'var(--color-danger)' : 'var(--color-warn, #fabd2f)',
marginTop: 2,
whiteSpace: 'normal',
}}>
{c.fix}
</span>
<span className="frs-check__fix"> {c.fix}</span>
)}
</div>
</div>
))}
</div>
</section>
);
}
/* ── Stepper nav with connectors ───────────────────────────────────────── */
/* ── LED stepper rail ──────────────────────────────────────────────────── */
function StepperNav({ step, onStep }) {
const { t } = useTranslation();
const stepLabels = [t('setup.welcome'), t('setup.system_check'), t('setup.install_models'), t('setup.pick_engines'), t('setup.try_dictation')];
// Three steps, no welcome ceremony: the journey rail + setup page already
// oriented the user. Models + engines share one act (required gate +
// optional extras).
const stepLabels = [t('setup.system_check'), t('firstrun.stage_models', 'Models & engines'), t('setup.try_dictation')];
return (
<div className="setup-wizard__steps" data-tauri-drag-region>
<nav className="frs-wsteps" data-tauri-drag-region>
{stepLabels.map((label, i) => (
<React.Fragment key={label}>
{i > 0 && (
<span className={`setup-wizard__step-connector${step > i - 1 ? ' setup-wizard__step-connector--done' : ''}`} />
)}
<button
className={[
'setup-wizard__step',
step === i ? 'setup-wizard__step--active' : '',
step > i ? 'setup-wizard__step--done' : '',
].filter(Boolean).join(' ')}
onClick={() => onStep(i)}
type="button"
aria-current={step === i ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${label}${step > i ? ' (completed)' : ''}`}
>
{step > i ? '✓ ' : `${i + 1}. `}{label}
</button>
</React.Fragment>
<button
key={label}
type="button"
className={[
'frs-wstep',
step === i ? 'is-active' : '',
step > i ? 'is-done' : '',
].filter(Boolean).join(' ')}
onClick={() => onStep(i)}
aria-current={step === i ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${label}${step > i ? ' (completed)' : ''}`}
>
<span className="frs-wstep__led" aria-hidden="true" />
{label}
</button>
))}
</div>
</nav>
);
}
/* ── Main wizard component ─────────────────────────────────────────────── */
/**
* First-run / "no models installed" gate.
* First-run / "no models installed" gate the final act of the first-run
* journey (setup install models/engines). Rendered in the same studio
* console design system (frs-*) so the handoff from the install splash is
* seamless.
*
* Flow:
* 0. Welcome hero + explainer + "continue"
* 1. System /setup/preflight results
* 2. Models ModelStoreTab, unlocks on models_ready
* 3. Engines EnginesTab + "Enter studio"
* 0. Welcome what's left to do
* 1. System /setup/preflight results
* 2. Models & engines ModelStoreTab (required, gates continue) +
* EnginesTab (optional) in one act
* 3. Dictation guided demo, then "Enter studio"
*/
export default function SetupWizard({ onReady }) {
const { t } = useTranslation();
@@ -144,7 +154,7 @@ export default function SetupWizard({ onReady }) {
// Poll setup status every 4s while on Models step
useEffect(() => {
if (step !== 2) return;
if (step !== 1) return;
const iv = setInterval(() => setupQuery.refetch(), 4000);
return () => clearInterval(iv);
}, [step, setupQuery]);
@@ -156,191 +166,153 @@ export default function SetupWizard({ onReady }) {
const cachePath = status?.hf_cache_dir || '~/.cache/huggingface';
const WELCOME_CARDS = [
{
icon: <Monitor size={16} />,
title: t('setup.system_check'),
desc: t('setup.system_check_desc'),
},
{
icon: <Download size={16} />,
title: t('setup.install_models'),
desc: t('setup.install_models_desc'),
},
{
icon: <Cog size={16} />,
title: t('setup.pick_engines'),
desc: t('setup.pick_engines_desc'),
},
const STEP_SUBTITLES = [
t('setup.system_check_desc'),
t('setup.install_models_desc'),
t('setup.try_dictation'),
];
return (
<div className="setup-wizard">
<StepperNav step={step} onStep={setStep} />
<div className="frs swiz">
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__deck">
<div
data-tauri-drag-region
onDoubleClick={doubleClickMaximize}
className="setup-wizard__hero"
>
<img src="/favicon.svg" alt="" className="setup-wizard__logo" />
<div className="setup-wizard__hero-text">
<h1 data-tauri-drag-region>OmniVoice Studio</h1>
<span className="setup-wizard__sub" data-tauri-drag-region>
{t('setup.hero_desc')}
</span>
</div>
</div>
{/* 0. Welcome */}
{step === 0 && (
<div className="swiz-slide" key="step-0">
<div className="setup-wizard__embed">
<div className="setup-wizard__welcome">
<div className="setup-wizard__welcome-grid">
{WELCOME_CARDS.map((card, i) => (
<div className="swiz-welcome-card" key={i}>
<div className="swiz-welcome-card__icon">{card.icon}</div>
<div className="swiz-welcome-card__body">
<span className="swiz-welcome-card__title">{card.title}</span>
<p className="swiz-welcome-card__desc">{card.desc}</p>
</div>
</div>
))}
</div>
<p className="swiz-welcome-note">
{t('setup.first_run')}
</p>
{/* ── Masthead: identical identity to setup + install acts ──────── */}
<header
className="frs__mast frs-rise"
style={{ '--rise': 0 }}
data-tauri-drag-region
onDoubleClick={doubleClickMaximize}
>
<Waveform />
<div className="frs__mast-row">
<div className="frs__mast-text">
<h1 className="frs__title" data-tauri-drag-region>OmniVoice Studio</h1>
<p className="frs__subtitle" data-tauri-drag-region>{STEP_SUBTITLES[step]}</p>
</div>
<div className="frs__mast-meta">
<StepperNav step={step} onStep={setStep} />
</div>
</div>
<div className="setup-wizard__nav">
<span />
<Button
variant="primary" size="sm"
onClick={() => setStep(1)}
trailing={<ArrowRight size={14} />}
>
{t('setup.get_started')}
</Button>
</div>
</div>
)}
</header>
{/* 1. System check */}
{step === 1 && (
<div className="swiz-slide" key="step-1">
<div className="setup-wizard__embed">
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
</div>
<div className="setup-wizard__nav">
<Button variant="ghost" onClick={() => setStep(0)}>{t('setup.back')}</Button>
<Button
variant={preflightOk ? 'primary' : 'ghost'}
onClick={() => setStep(2)}
trailing={<ArrowRight size={14} />}
disabled={!preflightOk}
title={preflightOk ? '' : t('setup.resolve_blockers')}
>
{preflightOk
? (pre?.has_warnings ? t('setup.continue_warn') : t('setup.continue_ok'))
: t('setup.continue_blocked')}
</Button>
</div>
</div>
)}
{/* 2. Models */}
{step === 2 && (
<div className="swiz-slide" key="step-2">
<div className="setup-wizard__embed">
<ModelStoreTab info={null} modelBadge={null} />
{!modelsReady && status?.missing?.length > 0 && (
<p className="setup-wizard__muted swiz-missing" style={{ marginTop: 8 }}>
{t('setup.still_needed')}{' '}
{status.missing.map(m => m.label).join(', ')}
</p>
)}
</div>
<div className="setup-wizard__nav">
<Button variant="ghost" onClick={() => setStep(1)}>{t('setup.back')}</Button>
<Button
variant={modelsReady ? 'primary' : 'ghost'}
onClick={() => setStep(3)}
trailing={<ArrowRight size={14} />}
disabled={!modelsReady}
title={modelsReady ? '' : t('setup.install_required_models')}
>
{modelsReady
? t('setup.models_ready')
: t('setup.waiting_models')}
</Button>
</div>
</div>
)}
{/* 3. Engines */}
{step === 3 && (
<div className="swiz-slide" key="step-3">
<div className="setup-wizard__embed">
<EnginesTab />
</div>
<div className="setup-wizard__nav">
<Button variant="ghost" onClick={() => setStep(2)}>{t('setup.back')}</Button>
<Button
variant="primary"
onClick={() => setStep(4)}
leading={<CheckCircle size={14} />}
>
{t('setup.next_try_dictation')}
</Button>
</div>
</div>
)}
{/* 4. Dictation guided walkthrough. Skippable (per cross-platform
parity rule: some users genuinely don't want dictation). */}
{step === 4 && (
<div className="swiz-slide" key="step-4">
<div className="setup-wizard__embed">
<DictationDemo />
</div>
<div className="setup-wizard__nav">
<Button variant="ghost" onClick={() => setStep(3)}>{t('setup.back')}</Button>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="subtle" onClick={onReady}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={onReady}
leading={<CheckCircle size={14} />}
{/* 0. System check first thing a user sees: the probe auto-runs,
no welcome ceremony (the journey rail + setup page already
oriented them). */}
{step === 0 && (
<div className="swiz-slide" key="step-0">
<div className="frs-rise" style={{ '--rise': 1 }}>
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
</div>
<div className="frs-wnav frs-rise" style={{ '--rise': 2 }}>
<span />
<button
type="button"
className={`frs-btn frs-btn--primary ${preflightOk ? 'is-armed' : ''}`}
onClick={() => setStep(1)}
disabled={!preflightOk}
title={preflightOk ? '' : t('setup.resolve_blockers')}
>
{t('setup.enter_studio')}
</Button>
<span className="frs-btn__led" aria-hidden="true" />
{preflightOk
? (pre?.has_warnings ? t('setup.continue_warn') : t('setup.continue_ok'))
: t('setup.continue_blocked')}
</button>
</div>
</div>
</div>
)}
{!status && step > 1 && (
<div className="swiz-status-loading">
<Loader className="spinner" size={14} /> {t('setup.checking')}
</div>
)}
<p className="setup-wizard__footnote">
{t('setup.footer_downloads')} <code>huggingface.co</code>
<span style={{ margin: '0 2px' }}>·</span>
{t('setup_extra.cache_label')} <code>{shortenPath(cachePath)}</code>
{'__TAURI_INTERNALS__' in window && cachePath && (
<button
className="setup-wizard__footnote-link"
onClick={() => revealPath(cachePath)}
title={t('setup.open_finder')}
>
<FolderOpen size={10} style={{ verticalAlign: '-1px', marginRight: 2 }} />
{t('setup.open')}
</button>
)}
</p>
{/* 1. Models & engines ONE unified list: every installable is a
row of the same grammar (LED · name · chip · size · action).
Required models gate continue; engines and the optional tail
ride in the same inventory. */}
{step === 1 && (
<div className="swiz-slide" key="step-1">
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
<h2 className="frs-panel__title">{t('firstrun.stage_models', 'Models & engines')}</h2>
<WizardLibrary />
{!modelsReady && status?.missing?.length > 0 && (
<p className="swiz-note swiz-note--warn">
{t('setup.still_needed')}{' '}
{status.missing.map(m => m.label).join(', ')}
</p>
)}
</section>
<div className="frs-wnav frs-rise" style={{ '--rise': 2 }}>
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setStep(0)}>
{t('setup.back')}
</button>
<button
type="button"
className={`frs-btn frs-btn--primary ${modelsReady ? 'is-armed' : ''}`}
onClick={() => setStep(2)}
disabled={!modelsReady}
title={modelsReady ? '' : t('setup.install_required_models')}
>
<span className="frs-btn__led" aria-hidden="true" />
{modelsReady ? t('setup.models_ready') : t('setup.waiting_models')}
</button>
</div>
</div>
)}
{/* 2. Dictation guided walkthrough. Skippable (per cross-platform
parity rule: some users genuinely don't want dictation). */}
{step === 2 && (
<div className="swiz-slide" key="step-2">
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
<h2 className="frs-panel__title">{t('setup.try_dictation')}</h2>
<div className="frs-embed">
<DictationDemo />
</div>
</section>
<div className="frs-wnav frs-rise" style={{ '--rise': 2 }}>
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setStep(1)}>
{t('setup.back')}
</button>
<div className="frs-wnav__group">
<button type="button" className="frs-btn frs-btn--quiet" onClick={onReady}>
{t('common.cancel')}
</button>
<button
type="button"
className="frs-btn frs-btn--primary is-armed"
onClick={onReady}
>
<span className="frs-btn__led" aria-hidden="true" />
{t('setup.enter_studio')}
</button>
</div>
</div>
</div>
)}
{!status && step > 0 && (
<div className="swiz-loading">
<Loader className="spinner" size={14} /> {t('setup.checking')}
</div>
)}
<footer className="frs__foot">
<div className="frs__foot-row">
<span className="frs__totals">
{t('setup.footer_downloads')} <code>huggingface.co</code>
<span className="frs__totals-sep" aria-hidden="true">·</span>
{t('setup_extra.cache_label')} <code>{shortenPath(cachePath)}</code>
{'__TAURI_INTERNALS__' in window && cachePath && (
<button
type="button"
className="frs-btn frs-btn--quiet"
onClick={() => revealPath(cachePath)}
title={t('setup.open_finder')}
>
{t('setup.open')}
</button>
)}
</span>
</div>
</footer>
</div>
</div>
);
}