From b757990f8d7484c773117260e7d521720a1abe13 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Fri, 27 Mar 2026 03:12:59 -0300 Subject: [PATCH] feat: add runtime lifecycle health revalidation --- autoresearch.jsonl | 8 + autoresearch.md | 104 +- autoresearch.sh | 4 +- experiments/bench_tauri_runtime_surface.py | 141 ++ experiments/worklog.md | 54 + tauri-app/package-lock.json | 1679 ++++++++++++++++- tauri-app/package.json | 7 +- tauri-app/src-tauri/src/lib.rs | 188 +- tauri-app/src-tauri/src/runtime/config.rs | 32 + tauri-app/src-tauri/src/runtime/health.rs | 145 ++ tauri-app/src-tauri/src/runtime/mod.rs | 389 ++++ tauri-app/src-tauri/src/runtime/process.rs | 75 + tauri-app/src-tauri/src/runtime/types.rs | 84 + tauri-app/src/App.tsx | 30 +- tauri-app/src/components/Sidebar.tsx | 83 +- tauri-app/src/features/runtime/api.ts | 120 ++ .../components/RuntimeSettingsDrawer.css | 355 ++++ .../components/RuntimeSettingsDrawer.tsx | 480 +++++ .../runtime/components/RuntimeStatusCard.css | 159 ++ .../runtime/components/RuntimeStatusCard.tsx | 72 + .../components/RuntimeSurface.test.tsx | 425 +++++ .../features/runtime/runtime-utils.test.ts | 164 ++ .../src/features/runtime/runtime-utils.ts | 119 ++ tauri-app/src/features/runtime/types.ts | 54 + .../src/features/runtime/useRuntimeManager.ts | 101 + tauri-app/src/hooks/useModels.ts | 20 +- tauri-app/src/index.css | 36 +- 27 files changed, 4851 insertions(+), 277 deletions(-) create mode 100644 experiments/bench_tauri_runtime_surface.py create mode 100644 tauri-app/src-tauri/src/runtime/config.rs create mode 100644 tauri-app/src-tauri/src/runtime/health.rs create mode 100644 tauri-app/src-tauri/src/runtime/mod.rs create mode 100644 tauri-app/src-tauri/src/runtime/process.rs create mode 100644 tauri-app/src-tauri/src/runtime/types.rs create mode 100644 tauri-app/src/features/runtime/api.ts create mode 100644 tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.css create mode 100644 tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx create mode 100644 tauri-app/src/features/runtime/components/RuntimeStatusCard.css create mode 100644 tauri-app/src/features/runtime/components/RuntimeStatusCard.tsx create mode 100644 tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx create mode 100644 tauri-app/src/features/runtime/runtime-utils.test.ts create mode 100644 tauri-app/src/features/runtime/runtime-utils.ts create mode 100644 tauri-app/src/features/runtime/types.ts create mode 100644 tauri-app/src/features/runtime/useRuntimeManager.ts diff --git a/autoresearch.jsonl b/autoresearch.jsonl index ec8b58b..2055ff9 100644 --- a/autoresearch.jsonl +++ b/autoresearch.jsonl @@ -55,3 +55,11 @@ {"run":38,"commit":"6920e2a","metric":100.0,"metrics":{"passed_cases":66,"failed_cases":0,"total_cases":66},"status":"keep","description":"add cli live execution pipeline","timestamp":1774566821,"segment":16} {"type":"config","name":"localllm-cli-ureq-transport","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"} {"run":39,"commit":"9860d64","metric":100.0,"metrics":{"passed_cases":69,"failed_cases":0,"total_cases":69},"status":"keep","description":"add ureq http transport and execute_live_command","timestamp":1774568181,"segment":17} +{"type":"config","name":"tauri-runtime-manager-unification","metricName":"runtime_surface_score","metricUnit":"%","bestDirection":"higher"} +{"run":40,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"unified_control_plane":1,"design_refactor_done":1},"status":"keep","description":"unify runtime manager config/start flow and refactor runtime UI","timestamp":1774578913,"segment":18} +{"run":41,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"health_probe_passed":1},"status":"keep","description":"replace tcp-only readiness with /health readiness probe","timestamp":1774579405,"segment":18} +{"run":42,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"runtime_smoke_passed":1},"status":"keep","description":"add runtime UI smoke coverage and preserve saved model on empty rescans","timestamp":1774579810,"segment":18} +{"type":"config","name":"tauri-runtime-lifecycle-acceptance","metricName":"runtime_lifecycle_score","metricUnit":"%","bestDirection":"higher"} +{"run":43,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1},"status":"keep","description":"align the Tauri lifecycle harness and keep the local CTA visible during mode switches","timestamp":1774585583,"segment":19} +{"run":44,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1,"saved_startup_flow_passed":1,"local_onboarding_flow_passed":1},"status":"keep","description":"cover saved local startup transitions and local onboarding from browse to ready","timestamp":1774586404,"segment":19} +{"run":45,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1,"saved_startup_flow_passed":1,"local_onboarding_flow_passed":1,"health_revalidation_passed":1},"status":"keep","description":"revalidate managed local health after startup and surface degraded state","timestamp":1774591917,"segment":19} diff --git a/autoresearch.md b/autoresearch.md index 826b2d1..b60e294 100644 --- a/autoresearch.md +++ b/autoresearch.md @@ -1,80 +1,56 @@ -# Autoresearch: localllm-cli-control-surface +# Autoresearch: tauri-runtime-lifecycle-acceptance ## Objective -Implement a full management CLI on top of the current local backend version, starting with -a professional command surface for status, model operations, auth, config, logs, health, -and throughput benchmarking. This segment focuses first on the CLI surface and contracts, -so later experiments can add real execution behavior without redesigning the UX shape. +Harden the Tauri desktop runtime manager around lifecycle acceptance so the saved +runtime flow stays trustworthy from configuration through apply/start behavior. This +segment focuses on the app-surface contract: local/remote mode transitions should show +the right controls immediately, local startup should stay driven by the saved runtime +state, and the verification harness should score the desktop runtime instead of the old +CLI surface. ## Metrics -- **Primary**: cli_surface_score (percent, higher is better) -- **Secondary**: passed_cases, failed_cases, total_cases +- **Primary**: runtime_lifecycle_score (percent, higher is better) +- **Secondary**: unit_passed, build_passed, cargo_check_passed, local_mode_cta_passed, saved_startup_flow_passed, local_onboarding_flow_passed, health_revalidation_passed ## How to Run -`./autoresearch.sh` - runs targeted ignored tests for CLI command-surface behavior and outputs `METRIC name=number` lines. +- `./autoresearch.sh` +- `cd tauri-app && npm test -- src/features/runtime/runtime-utils.test.ts src/features/runtime/components/RuntimeSurface.test.tsx` +- `cd tauri-app && npm run build` +- `cargo check --manifest-path tauri-app/src-tauri/Cargo.toml` ## Files in Scope -- `crates/localllm-cli/` - new CLI crate, parser, command UX, output modes, and CLI-focused tests -- `crates/inference-server/src/main.rs` - current backend env/config seam the CLI will eventually manage -- `crates/inference-server/src/routes.rs` - existing inference endpoints the CLI can interrogate today -- `crates/inference-server/src/compat.rs` - existing auth/config knobs the CLI must understand and expose coherently -- `crates/api-contracts/` - shared contracts the CLI may eventually consume -- `experiments/bench_cli_surface.py` - benchmark harness for the autoresearch loop -- `autoresearch.sh` - loop benchmark entrypoint and fast preflight +- `tauri-app/src/App.tsx` - app shell ownership for runtime and model connectivity +- `tauri-app/src/components/Sidebar.tsx` - sidebar entry point for runtime controls +- `tauri-app/src/features/runtime/` - unified runtime domain, UI, utils, and Tauri API wrappers +- `tauri-app/src/index.css` - global visual tokens for the desktop app +- `tauri-app/src/hooks/useModels.ts` - model loading through resolved runtime connection +- `tauri-app/src-tauri/src/runtime/` - backend source of truth for runtime config/state +- `tauri-app/src-tauri/src/lib.rs` - command wiring and legacy compatibility seams ## Off Limits - `models/` model binaries - `vendor/` third-party sources and prebuilt llama binaries -- `registry/` Python registry service unless a later CLI experiment explicitly needs to replace or wire it -- `ui/` frontend files -- Tauri app implementation in this segment -- unrelated latency or template experiments +- unrelated CLI autoresearch files and crates unless a runtime experiment explicitly needs them +- unrelated latency/template experiments ## Constraints -- Preserve the currently working backend behavior. -- Regular test suite must stay green for keeps. -- Prefer a stable, professional CLI contract over premature execution internals. -- Commands should map cleanly to the product plan discussed with the user: status, models, auth, config, bench, logs, doctor. -- The benchmark is test-driven: keep/discard follows the CLI surface suite score first. -- A justified CLI-focused dependency is acceptable if it reduces maintenance cost. +- Backend/Tauri remains the source of truth for runtime config and process state. +- The frontend should expose one startup/config flow, not parallel state machines. +- Remote mode must never silently fall back to localhost. +- Verification must include `npm test`, `npm run build`, and `cargo check`. +- Preserve existing chat/model behavior outside the runtime-management refactor. ## What's Been Tried -- Segment 5 pivots autoresearch from template hardening to productization, specifically a complete CLI-first control surface on top of the current backend version. -- The current repo has no CLI crate, no Tauri app, and no control-plane crate; the backend is still configured almost entirely by `LOCALLLM_*` environment variables and exposes only `/health`, `/v1/models`, and `/v1/chat/completions`. -- The first benchmark for this segment is intentionally surface-oriented: ignored tests score whether the CLI presents the expected professional command set before deeper execution behavior is implemented. -- Baseline run 15 (`fd773a9`) scored only `18.182%`: the new CLI scaffold currently exposes `status` and `models list`, but the broader management surface (`models scan/load/watch/download`, `auth`, `config`, `bench`, `logs`, `doctor`) is still absent. -- Run 16 (`70cc59a`) expanded the Clap parser to the full intended command surface and raised segment 5 from `18.182%` to `100.0%` (11/11 passing), giving the CLI a stable UX contract before execution handlers are wired. -- Segment 6 raises the bar from command presence to semantic usefulness: the benchmark now checks whether key workflows produce meaningful command summaries that preserve defaults and high-value arguments, as a stepping stone toward real execution planning. -- Baseline run 17 (`53a7dbb`) scored `68.75%`: the parser already accepted the workflows, but `render_command_summary()` still threw away important semantic detail such as nested command paths, output format, alias selection, and throughput defaults. -- Run 18 (`66b30de`) enriched semantic summaries across the CLI and raised segment 6 from `68.75%` to `100.0%` (16/16 passing), so the next bar should move from summary correctness to execution-planning or output-contract semantics. -- Segment 7 raises the bar from semantic summaries to structured execution plans, checking whether key commands preserve actionable details in a machine-usable plan that can later drive real handlers. -- Baseline run 19 (`9154a7b`) scored `69.565%`: the CLI still passes all parser and summary tests, but every new plan-oriented workflow fails because `plan_command()` currently returns `SummaryOnly` for all commands. -- Run 20 (`bc584be`) introduced concrete `CommandPlan` variants for status, model scan/load, auth enable, config set, throughput bench, and logs, raising segment 7 from `69.565%` to `100.0%` (23/23 passing). -- Segment 8 raises the bar from machine-usable plans to execution contracts, checking whether key workflows can already describe the concrete HTTP/local action they intend to perform at the backend/control-plane seam. -- Baseline run 21 (`3d1460b`) scored `76.667%`: the CLI already preserves parser, summary, and plan semantics, but `execution_contract()` still collapses the new workflows to `SummaryOnly { command: "unplanned" }` instead of concrete HTTP/local contracts. -- Run 22 (`38acb52`) mapped the highest-value `CommandPlan` variants into concrete `ExecutionContract` values for `/health`, `/v1/chat/completions`, local model scan/load, local auth/config mutation, and local log reads, raising segment 8 from `76.667%` to `100.0%` (30/30 passing). -- Segment 9 raises the bar from contract shape to execution stubs, checking whether the same high-value workflows can already describe concrete HTTP method/path/body details or named local actions that a future real executor can run directly. -- Baseline run 23 (`7030290`) scored `83.333%`: parser, summaries, plans, and execution contracts still pass, but `execution_stub()` is only a placeholder and returns `SummaryOnly { command: "unimplemented" }` instead of concrete HTTP/local stubs for the highest-value workflows. -- Run 24 (`50d435a`) mapped the highest-value `ExecutionContract` variants into concrete `ExecutionStub` values for `GET /health`, `POST /v1/chat/completions`, local model scan/load, local auth config, and local log tail actions, raising segment 9 from `83.333%` to `100.0%` (36/36 passing). -- Segment 10 raises the bar from execution stubs to dry-run previews, checking whether the same high-value workflows can expand into full previewable HTTP URLs or local-effect descriptions that are ready to show in a future `--dry-run` UX. -- Baseline run 25 (`cd480c4`) scored `85.714%`: parser, summaries, plans, contracts, and stubs still pass, but `execution_preview()` remained a placeholder and returned `SummaryOnly { command: "preview-unimplemented" }` instead of final dry-run previews. -- Run 26 (`c8ef7cf`) mapped the highest-value execution stubs into concrete `ExecutionPreview` values with full health/chat URLs and readable local-effect descriptions, raising segment 10 from `85.714%` to `100.0%` (42/42 passing). -- Segment 11 raises the bar from preview objects to final `--dry-run` output, checking whether the CLI can now render those previews as actual text/json UX for the same high-value workflows. -- Baseline run 27 (`7738acf`) scored `87.5%`: the CLI already had parser/summary/plan/contract/stub/preview layers, but `render_execution_preview()` still collapsed everything to `DRY RUN {summary}` instead of final text/json dry-run output. -- Run 28 (`99e5041`) rendered the highest-value `ExecutionPreview` values into final user-facing text/json dry-run output, raising segment 11 from `87.5%` to `100.0%` (48/48 passing). -- Segment 12 raises the bar from dry-run UX to the first real backend-call specifications for commands that can already work today, specifically `status`, `models list`, and `bench throughput`. -- Baseline run 29 (`1ff5ba3`) scored `94.118%`: every earlier CLI layer still passed, but `backend_invocation()` was still a placeholder and the new backend-facing tests for live status/models/bench requests failed. -- Run 30 (`bf40b2f`) added concrete backend invocation specs for `GET /health`, `GET /v1/models`, and `POST /v1/chat/completions` with preserved `--model`, token budget, server base URL, and auth presence, raising segment 12 from `94.118%` to `100.0%` (51/51 passing). -- Segment 13 raises the bar again from backend invocation specs to executable backend request construction, checking whether the same live-compatible commands can now produce final HTTP method/url/header/body request objects with deterministic JSON payloads. -- Run 31 (`f970c05`) scored `94.444%` because `backend_request()` was still a placeholder and the new backend-request tests failed for `status`, `models list`, and `bench throughput`. -- Run 32 (`5de35c5`) introduced concrete executable backend request specs with bearer/content-type headers and deterministic JSON benchmark payloads, raising segment 13 from `94.444%` to `100.0%` (54/54 passing). -- Segment 14 raises the bar from executable backend request specs to backend response interpretation, checking whether the same live-compatible commands can parse real response bodies into CLI-facing previews for health, model lists, and throughput usage metrics. -- Run 33 (`8cf03e3`) scored `94.737%` because `backend_response_preview()` was still a placeholder and the new response-parsing tests failed for `status`, `models list`, and `bench throughput`. -- Run 34 (`0506a4c`) introduced CLI-local backend response previews for health payloads, model list ids/count, and throughput usage metrics, raising segment 14 from `94.737%` to `100.0%` (57/57 passing). -- Segment 15 raises the bar from internal backend response previews to final user-facing live output rendering, checking whether the same live-compatible commands can render text/json output directly from parsed backend responses. -- Run 35 (`e1ada2c`) scored `90.476%` because `render_backend_response()` still collapsed the live-compatible workflows to `LIVE {summary}` instead of final text/json output. -- Run 36 (`c54107d`) rendered the first live-compatible backend responses into final user-facing text/json output, raising segment 15 from `90.476%` to `100.0%` (63/63 passing). -- Segment 16 raises the bar from live output rendering to the first real executable handlers, checking whether the same live-compatible workflows can execute through an injectable transport seam and return the final rendered output end-to-end. -- Run 37 (`388733a`) scored `95.455%` because `execute_live_command_with()` still ignored the transport response and returned `LIVE EXEC {summary}` instead of final live output. -- Run 38 (`6920e2a`) wired the injected transport response into `render_backend_response()`, raising segment 16 from `95.455%` to `100.0%` (66/66 passing). -- Segment 17 adds `ureq` as the real HTTP transport and implements `execute_live_command(cli)` that delegates to `execute_live_command_with()` using `ureq`. Run 39 (`9860d64`) scored `100.0%` (69/69 passing) as a direct keep — the CLI is now functionally complete for the commands the current backend supports. +- Earlier autoresearch segments 0-17 focused on latency, dynamic chat templates, and a CLI management surface. The latest completed CLI segment (`localllm-cli-ureq-transport`) reached `100.0%` on run 39 (`9860d64`), but that objective is now stale for the desktop app work. +- The Tauri chat layer was previously fixed so new conversations persist correctly, `...` content is separated into a collapsible thinking panel, and per-message/per-chat token accounting is stored and rendered. +- The first runtime-manager slice introduced a backend runtime domain in `tauri-app/src-tauri/src/runtime/`, a frontend runtime feature in `tauri-app/src/features/runtime/`, a compact sidebar status card, and a settings drawer. +- Review of that slice exposed two major correctness issues that were fixed: remote mode no longer falls back to localhost, and runtime config changes that would drift away from a running child process are now blocked with a restart-required error. +- Even after those fixes, the runtime manager still failed in practice because the app kept two frontend control planes: the new runtime card/drawer started the backend from `RuntimeState.inner.config`, while the embedded legacy `ServerPanel` still edited local model/runtime settings through `save_config`, which only wrote the config file and did not update the in-memory runtime state used by the new start buttons. +- The latest experiment resolves that split by removing `useServerProcess` and the embedded legacy `ServerPanel` from the active sidebar flow, moving local runtime configuration into the unified runtime drawer, and making the sidebar card either open setup or start the saved runtime config from the same domain. +- The runtime visuals were also refactored toward a cleaner AI-desktop feel with updated runtime card/drawer styling, a more deliberate font stack, cooler blue-cyan accents, and less of the earlier purple-heavy sidebar look. +- The current experiment hardens readiness detection in `tauri-app/src-tauri/src/runtime/health.rs` so startup now waits for a successful `GET /health` response with `{"status":"ok"}` instead of treating any open TCP port as ready. Rust tests now cover healthy HTTP, unhealthy payloads, and plain-open-port false positives. +- The latest experiment adds app-surface smoke coverage for `RuntimeStatusCard` and `RuntimeSettingsDrawer`, then fixes a real regression in `chooseModel()` so a previously saved model path is preserved when a folder rescan returns no models. This keeps the unified drawer start action available for valid saved configs instead of silently disabling it. +- The current experiment re-initializes the harness around the Tauri desktop surface by replacing the stale CLI benchmark with `experiments/bench_tauri_runtime_surface.py`, updating `autoresearch.sh` to score runtime-focused frontend tests plus build/check verification, and fixing a real drawer regression where switching from remote to local mode hid the primary local CTA until after save. +- The latest experiment extends the runtime lifecycle acceptance contract with two hook-backed smoke checks: a saved local config must surface `stopped -> starting -> ready` through `useRuntimeManager`, and local onboarding via folder browse must auto-select a model, persist it, and reach a ready local runtime from the same drawer flow. +- The latest experiment adds periodic runtime truthfulness checks after startup: `refresh_process_state()` now revalidates `/health` while the managed child is still running, downgrades stale `ready` state to `degraded` when the process stops serving correctly, and restores `ready` after recovery. The desktop surface test already rendered degraded snapshots correctly; the missing correctness gap was backend state revalidation. +- Current keep criteria for this segment are: the desktop runtime benchmark targets the active Tauri surface, switching into local mode keeps the apply/start CTA visible immediately, saved local startup exposes `starting` before `ready`, onboarding can browse/save/start from one local flow, stale managed processes demote from `ready` to `degraded` when `/health` stops reporting ready, and verification stays green across Vitest, TypeScript/Vite build, and Rust compilation. diff --git a/autoresearch.sh b/autoresearch.sh index 8022c71..beea055 100644 --- a/autoresearch.sh +++ b/autoresearch.sh @@ -6,7 +6,7 @@ ROOT="$SCRIPT_DIR" mkdir -p "$ROOT/experiments" echo "[INFO] Fast preflight (cargo check)..." >&2 -cargo check -p localllm-cli --quiet +cargo check --manifest-path "$ROOT/tauri-app/src-tauri/Cargo.toml" --quiet -python "$ROOT/experiments/bench_cli_surface.py" \ +python "$ROOT/experiments/bench_tauri_runtime_surface.py" \ --root "$ROOT" diff --git a/experiments/bench_tauri_runtime_surface.py b/experiments/bench_tauri_runtime_surface.py new file mode 100644 index 0000000..1a192e2 --- /dev/null +++ b/experiments/bench_tauri_runtime_surface.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def run_command(command: list[str], cwd: Path) -> tuple[int, str]: + completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True) + output = (completed.stdout or "") + (completed.stderr or "") + if output: + sys.stdout.write(output) + if not output.endswith("\n"): + sys.stdout.write("\n") + return completed.returncode, output + + +def command_name(name: str) -> str: + if os.name == "nt" and name == "npm": + return "npm.cmd" + return name + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + args = parser.parse_args() + + root = Path(args.root) + tauri_app = root / "tauri-app" + tauri_backend = tauri_app / "src-tauri" + + unit_command = [ + command_name("npm"), + "test", + "--", + "src/features/runtime/runtime-utils.test.ts", + "src/features/runtime/components/RuntimeSurface.test.tsx", + ] + saved_startup_command = [ + command_name("npm"), + "test", + "--", + "src/features/runtime/components/RuntimeSurface.test.tsx", + "-t", + "covers the saved local startup lifecycle from stopped to starting to ready", + ] + local_onboarding_command = [ + command_name("npm"), + "test", + "--", + "src/features/runtime/components/RuntimeSurface.test.tsx", + "-t", + "supports local onboarding from browsing a models folder through save and start", + ] + local_mode_cta_command = [ + command_name("npm"), + "test", + "--", + "src/features/runtime/components/RuntimeSurface.test.tsx", + "-t", + "keeps the local primary CTA visible while switching from remote to local mode", + ] + health_revalidation_surface_command = [ + command_name("npm"), + "test", + "--", + "src/features/runtime/components/RuntimeSurface.test.tsx", + "-t", + "surfaces degraded local runtime state after periodic health revalidation fails", + ] + build_command = [command_name("npm"), "run", "build"] + cargo_check_command = [ + command_name("cargo"), + "check", + "--manifest-path", + str(tauri_backend / "Cargo.toml"), + ] + health_revalidation_backend_command = [ + command_name("cargo"), + "test", + "--manifest-path", + str(tauri_backend / "Cargo.toml"), + "runtime::tests::runtime_revalidation_marks_unhealthy_local_process_as_degraded", + "--", + "--exact", + ] + + unit_code, _ = run_command(unit_command, tauri_app) + saved_startup_code, _ = run_command(saved_startup_command, tauri_app) + local_onboarding_code, _ = run_command(local_onboarding_command, tauri_app) + local_mode_cta_code, _ = run_command(local_mode_cta_command, tauri_app) + health_revalidation_surface_code, _ = run_command( + health_revalidation_surface_command, tauri_app + ) + build_code, _ = run_command(build_command, tauri_app) + cargo_check_code, _ = run_command(cargo_check_command, root) + health_revalidation_backend_code, _ = run_command( + health_revalidation_backend_command, root + ) + + unit_passed = 1 if unit_code == 0 else 0 + saved_startup_flow_passed = 1 if saved_startup_code == 0 else 0 + local_onboarding_flow_passed = 1 if local_onboarding_code == 0 else 0 + local_mode_cta_passed = 1 if local_mode_cta_code == 0 else 0 + health_revalidation_passed = ( + 1 + if health_revalidation_surface_code == 0 + and health_revalidation_backend_code == 0 + else 0 + ) + build_passed = 1 if build_code == 0 else 0 + cargo_check_passed = 1 if cargo_check_code == 0 else 0 + + total_checks = 7 + passed_checks = ( + unit_passed + + saved_startup_flow_passed + + local_onboarding_flow_passed + + local_mode_cta_passed + + health_revalidation_passed + + build_passed + + cargo_check_passed + ) + score = round((passed_checks / total_checks) * 100.0, 3) + + print(f"METRIC runtime_lifecycle_score={score}") + print(f"METRIC unit_passed={unit_passed}") + print(f"METRIC local_mode_cta_passed={local_mode_cta_passed}") + print(f"METRIC saved_startup_flow_passed={saved_startup_flow_passed}") + print(f"METRIC local_onboarding_flow_passed={local_onboarding_flow_passed}") + print(f"METRIC health_revalidation_passed={health_revalidation_passed}") + print(f"METRIC build_passed={build_passed}") + print(f"METRIC cargo_check_passed={cargo_check_passed}") + + return 0 if passed_checks == total_checks else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/worklog.md b/experiments/worklog.md index 4d4c5d6..85f949b 100644 --- a/experiments/worklog.md +++ b/experiments/worklog.md @@ -380,3 +380,57 @@ - Result: cli_surface_score=100.0, passed_cases=69, failed_cases=0, total_cases=69. - Insight: The CLI now has a real HTTP client (`ureq`) wired end-to-end for live-compatible commands. The injectable transport seam remains available for testing without a running server. The CLI is functionally complete for the commands that the current backend already supports. - Next: CLI is at natural saturation given the current backend. Further progress requires new admin/control-plane endpoints in the backend for scan, load, auth, config, and watch commands. + +## Segment 18: Unified Tauri Runtime Manager + +- Objective: replace the split desktop runtime control flow with one saved runtime-manager surface that owns local startup, remote routing, and local model/runtime settings. +- Benchmark: targeted verification bundle using `npm test`, `npm run build`, and `cargo check` plus acceptance checks for a single runtime control plane and updated runtime UI. +- Keep/discard rule: a keep requires one working config/start path, no broken sidebar start button, and green verification across frontend and Rust checks. + +### Run 40: unify runtime manager config/start flow and refactor runtime UI - runtime_surface_score=100.0 (KEEP) +- Timestamp: 2026-03-26 23:35 +- What changed: Removed the active dependency on `useServerProcess` and the embedded legacy `ServerPanel` from the sidebar flow, rebuilt `RuntimeSettingsDrawer` into a full local/remote runtime workspace, added Tauri wrappers for GPU/model picking and scanning, made the sidebar runtime card route setup/start from the same saved runtime state, and refreshed runtime/sidebar/global styling toward a cleaner AI desktop identity. +- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, unified_control_plane=1, design_refactor_done=1. +- Insight: The runtime failures were caused by state ownership drift, not startup logic alone. Once local runtime configuration and startup lived in the same domain, both runtime entry points aligned and the broken start path disappeared. +- Next: improve readiness probing beyond raw TCP-open detection, then smoke-test the desktop runtime end to end against a real local model and record a stricter follow-up score for startup/readiness behavior. + +### Run 41: replace tcp-only readiness with /health readiness probe - runtime_surface_score=100.0 (KEEP) +- Timestamp: 2026-03-26 23:43 +- What changed: Replaced the runtime startup readiness check in `tauri-app/src-tauri/src/runtime/health.rs` so it now issues `GET /health`, requires an HTTP 200 response, and validates `{"status":"ok"}` before marking the local API as ready. Added Rust tests covering healthy HTTP success, unhealthy JSON payload rejection, and plain-open-port rejection. +- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, health_probe_passed=1. +- Insight: TCP-open was too weak for a desktop-managed API because any bound socket could masquerade as readiness. A lightweight health request gives the runtime manager a much more truthful startup signal without introducing frontend complexity. +- Next: run an end-to-end desktop smoke test against a real local model so the next segment can score startup behavior from the actual app surface, not just backend verification. + +### Run 42: add runtime UI smoke coverage and preserve saved model on empty rescans - runtime_surface_score=100.0 (KEEP) +- Timestamp: 2026-03-26 23:50 +- What changed: Added jsdom-based runtime smoke tests for `RuntimeStatusCard` and `RuntimeSettingsDrawer`, installed `jsdom` for Vitest browserless component coverage, and fixed `chooseModel()` in `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` so a previously saved `model_path` is preserved when a models-folder rescan returns no results. +- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, runtime_smoke_passed=1. +- Insight: The first useful app-surface smoke test immediately caught a real regression that backend/runtime checks could not see: a valid saved model could disappear from drawer state after an empty scan, which silently disabled the unified start action. +- Next: push the smoke coverage one step further with a focused end-to-end startup path check, ideally validating that a saved local config transitions from setup -> starting -> ready correctly from the desktop surface. + +## Segment 19: Tauri Runtime Lifecycle Acceptance + +- Objective: replace the stale CLI-oriented benchmark with a Tauri desktop lifecycle benchmark and harden the app-surface contract around local/remote mode transitions. +- Benchmark: `./autoresearch.sh` now runs `experiments/bench_tauri_runtime_surface.py`, which scores runtime-focused frontend tests plus `npm run build` and `cargo check`. +- Keep/discard rule: higher `runtime_lifecycle_score` wins while the active desktop runtime CTA and verification bundle remain green. + +### Run 43: align the Tauri lifecycle harness and keep the local CTA visible during mode switches - runtime_lifecycle_score=100.0 (KEEP) +- Timestamp: 2026-03-27 01:26 +- What changed: Replaced the stale CLI benchmark in `autoresearch.sh` with a new `experiments/bench_tauri_runtime_surface.py` driver, added a runtime smoke test that switches `RuntimeSettingsDrawer` from remote to local mode, and fixed the drawer footer in `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` so the local primary CTA is keyed off the edited local `mode` instead of the previously saved `snapshot.config.connection_mode`. +- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1. +- Insight: The previous segment was saturated partly because the harness was no longer measuring the live desktop contract. Once the benchmark targeted the Tauri runtime surface directly, it immediately exposed a real lifecycle regression: switching from remote to local mode could hide the apply/start CTA before save, making the saved local flow feel broken even though the backend state was valid. +- Next: raise the lifecycle score again with a stricter saved-startup path check, ideally covering the `stopped -> starting -> ready` transition and degraded behavior after a failed health revalidation. + +### Run 44: cover saved local startup transitions and local onboarding from browse to ready - runtime_lifecycle_score=100.0 (KEEP) +- Timestamp: 2026-03-27 01:40 +- What changed: Extended `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` with two hook-backed lifecycle checks that run through `useRuntimeManager`: one now requires a saved local config to expose `Local API offline -> Starting local API -> Ready`, and another exercises local onboarding by browsing a models folder, auto-selecting the discovered model, saving config, and starting the local API from the same drawer flow. Updated `tauri-app/src/features/runtime/useRuntimeManager.ts` to publish optimistic `starting` and `stopping` snapshots while the Tauri commands are in flight. Raised `experiments/bench_tauri_runtime_surface.py` so the benchmark now records `local_mode_cta_passed`, `saved_startup_flow_passed`, and `local_onboarding_flow_passed` as explicit lifecycle checks alongside the broader runtime suite. +- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1, saved_startup_flow_passed=1, local_onboarding_flow_passed=1. +- Insight: The saved local startup flow was previously trustworthy only after the backend returned; the desktop surface showed `Working...` but skipped the visible `starting` state entirely. Moving the in-flight state into `useRuntimeManager` closes that UX gap and makes the status card/drawer reflect the real lifecycle immediately. The onboarding smoke also proves the unified drawer now covers browse -> auto-select -> save -> start without falling back to separate legacy controls. +- Next: keep the lifecycle segment focused on truthfulness after startup, especially periodic `/health` revalidation that can demote a stale `ready` process into `degraded` when the child stops serving correctly. + +### Run 45: revalidate managed local health after startup and surface degraded state - runtime_lifecycle_score=100.0 (KEEP) +- Timestamp: 2026-03-27 03:11 +- What changed: Added a degraded-state lifecycle check to `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx`, extended `experiments/bench_tauri_runtime_surface.py` with `health_revalidation_passed`, and updated `tauri-app/src-tauri/src/runtime/health.rs` plus `tauri-app/src-tauri/src/runtime/mod.rs` so a running managed local child is periodically revalidated via `/health`. The runtime now demotes stale `ready` state to `degraded` when the process is still alive but no longer serves a healthy endpoint, and clears the `health_check_failed` error when health recovers. Added Rust tests covering both the degrade and recovery paths. +- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1, saved_startup_flow_passed=1, local_onboarding_flow_passed=1, health_revalidation_passed=1. +- Insight: The frontend surface was already honest once it received a degraded snapshot; the real lifecycle gap was that backend runtime state could stay stuck at `ready` forever as long as the child process had not exited. Revalidating `/health` inside runtime state refresh closes that truthfulness gap without reintroducing a second frontend control plane. +- Next: this lifecycle segment is saturated again at `100.0%`. The next worthwhile move should raise the bar beyond state truthfulness, likely around a higher-fidelity desktop integration check or a real managed-runtime smoke path against an actual local model process. diff --git a/tauri-app/package-lock.json b/tauri-app/package-lock.json index 1af3b4b..fce8537 100644 --- a/tauri-app/package-lock.json +++ b/tauri-app/package-lock.json @@ -22,10 +22,73 @@ "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.0.1", "typescript": "^5.5.3", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^4.1.2" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -308,6 +371,193 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -699,6 +949,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -749,6 +1017,288 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1106,6 +1656,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1342,6 +1899,17 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1387,6 +1955,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -1460,6 +2046,102 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", @@ -1473,6 +2155,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1528,6 +2220,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1535,6 +2237,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1542,6 +2258,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1560,6 +2290,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dompurify": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", @@ -1576,6 +2323,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1625,6 +2392,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1659,12 +2464,83 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1691,6 +2567,267 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1713,6 +2850,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", @@ -1725,6 +2872,13 @@ "node": ">= 18" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1758,6 +2912,37 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1765,6 +2950,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -1794,6 +2992,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1829,6 +3037,57 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -1874,6 +3133,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1893,6 +3165,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1903,6 +3182,125 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1917,6 +3315,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2008,6 +3416,275 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/tauri-app/package.json b/tauri-app/package.json index 2a2b390..31ac5d6 100644 --- a/tauri-app/package.json +++ b/tauri-app/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "vitest run", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri" @@ -12,9 +13,9 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", - "marked": "^12.0.0", "dompurify": "^3.0.0", "highlight.js": "^11.0.0", + "marked": "^12.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -23,8 +24,10 @@ "@types/dompurify": "^3.0.0", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.0.1", "typescript": "^5.5.3", "vite": "^5.4.8", - "@vitejs/plugin-react": "^4.3.1" + "vitest": "^4.1.2" } } diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 45f6c49..7d9abaf 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -1,10 +1,14 @@ -use std::path::{Path, PathBuf}; -use std::process::{Child, Command}; -use std::sync::Mutex; +use std::path::Path; +use std::process::Command; use tauri::{AppHandle, Manager, State}; use tauri_plugin_dialog::DialogExt; -pub struct ServerState(pub Mutex>); +mod runtime; + +use runtime::config::{load_runtime_config, save_runtime_config}; +use runtime::process::find_localllm_binary; +use runtime::types::{RuntimeConfig, RuntimeSnapshot}; +use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState}; // ─── GPU ────────────────────────────────────────────────────────────────────── @@ -47,107 +51,73 @@ fn query_gpu() -> Result { // ─── Server lifecycle ───────────────────────────────────────────────────────── -/// Locate the `localllm` binary by searching several well-known locations: -/// 1. Workspace `target/release/` relative to the Tauri app's source tree -/// 2. Next to the running executable (for installed/bundled builds) -/// 3. System PATH -fn find_localllm_binary() -> Option { - let exe_name = if cfg!(target_os = "windows") { "localllm.exe" } else { "localllm" }; +#[tauri::command] +fn bootstrap_runtime_command( + app: AppHandle, + state: State, +) -> Result { + bootstrap_runtime(app, state) +} - // 1. Workspace target/release — works when running `npm run tauri dev` from tauri-app/ - // The Tauri dev binary lives at tauri-app/src-tauri/target/debug/forgelm-chat.exe - // so we walk up to find the workspace root and look for target/release/localllm.exe - if let Ok(exe) = std::env::current_exe() { - // Walk up from the current exe looking for target/release/localllm.exe - let mut dir = exe.parent(); - while let Some(d) = dir { - let candidate = d.join("target").join("release").join(exe_name); - if candidate.exists() { - return Some(candidate); - } - // Also check sibling: if we're inside target/debug, go up two levels - let candidate2 = d.join(exe_name); - if candidate2.exists() { - return Some(candidate2); - } - dir = d.parent(); - } - } +#[tauri::command] +fn get_runtime_snapshot_command(state: State) -> Result { + get_runtime_snapshot(state) +} - // 2. Relative to CWD (useful when launched from repo root) - let cwd_candidate = PathBuf::from("target").join("release").join(exe_name); - if cwd_candidate.exists() { - return Some(cwd_candidate); - } +#[tauri::command] +fn save_runtime_config_command_handler( + app: AppHandle, + state: State, + config: RuntimeConfig, +) -> Result { + save_runtime_config_command(app, state, config) +} - // 3. System PATH - if let Ok(output) = Command::new(if cfg!(target_os = "windows") { "where" } else { "which" }) - .arg("localllm") - .output() - { - if output.status.success() { - let path_str = String::from_utf8_lossy(&output.stdout); - let first_line = path_str.lines().next().unwrap_or("").trim(); - if !first_line.is_empty() { - return Some(PathBuf::from(first_line)); - } - } - } +#[tauri::command] +fn start_runtime_command( + app: AppHandle, + state: State, +) -> Result { + start_runtime(app, state) +} - None +#[tauri::command] +fn stop_runtime_command(state: State) -> Result { + stop_runtime(state) } #[tauri::command] fn start_server( - state: State, + app: AppHandle, + state: State, model_path: String, addr: String, gpu_layers: i32, ctx_size: u32, ) -> Result { - let mut guard = state.0.lock().map_err(|e| e.to_string())?; - if guard.is_some() { - return Err("Server is already running".into()); - } - - let binary_path = find_localllm_binary() - .ok_or_else(|| { - "localllm binary not found. Build it with: cargo build --release -p inference-server\n\ - Then ensure the binary is in one of: target/release/, PATH, or next to this app." - .to_string() - })?; - - let child = Command::new(&binary_path) - .env("LOCALLLM_MODEL", &model_path) - .env("LOCALLLM_ADDR", &addr) - .env("LOCALLLM_GPU_LAYERS", gpu_layers.to_string()) - .env("LOCALLLM_CTX", ctx_size.to_string()) - .spawn() - .map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))?; - - let pid = child.id(); - *guard = Some(child); - Ok(format!("Server started (pid={pid}) on {addr}")) + let mut config = load_runtime_config(&app).unwrap_or_default(); + config.connection_mode = runtime::types::ConnectionMode::Local; + config.model_path = Some(model_path); + config.addr = addr; + config.gpu_layers = gpu_layers; + config.ctx_size = ctx_size; + save_runtime_config(&app, &config)?; + let snapshot = start_runtime(app, state)?; + Ok(format!("Local API {}", snapshot.resolved_url)) } #[tauri::command] -fn stop_server(state: State) -> Result { - let mut guard = state.0.lock().map_err(|e| e.to_string())?; - match guard.as_mut() { - None => Err("Server is not running".into()), - Some(child) => { - child.kill().map_err(|e| format!("Failed to kill server: {e}"))?; - let _ = child.wait(); - *guard = None; - Ok("Server stopped".into()) - } - } +fn stop_server(state: State) -> Result { + let snapshot = stop_runtime(state)?; + Ok(format!("Local API stopped ({})", snapshot.resolved_url)) } #[tauri::command] -fn server_status(state: State) -> String { - let guard = state.0.lock().unwrap(); - if guard.is_some() { "running".into() } else { "stopped".into() } +fn server_status(state: State) -> String { + match get_runtime_snapshot(state) { + Ok(snapshot) if snapshot.process_running => "running".into(), + _ => "stopped".into(), + } } // ─── Model files ────────────────────────────────────────────────────────────── @@ -288,46 +258,15 @@ fn import_model(src_path: String, dest_dir: String) -> Result // ─── Persistent config ──────────────────────────────────────────────────────── /// All user-configurable settings, persisted to app data dir as config.json. -#[derive(serde::Serialize, serde::Deserialize, Clone, Default)] -pub struct AppConfig { - pub models_dir: Option, - pub model_path: Option, - pub addr: Option, - pub gpu_layers: Option, - pub ctx_size: Option, - pub api_key: Option, - pub server_url: Option, - /// Extra model paths added via file picker (not necessarily in models_dir) - pub extra_model_paths: Option>, -} - -fn config_path(app: &AppHandle) -> Result { - let data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("Cannot resolve app data dir: {e}"))?; - std::fs::create_dir_all(&data_dir) - .map_err(|e| format!("Cannot create app data dir: {e}"))?; - Ok(data_dir.join("config.json")) -} - #[tauri::command] -fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { - let path = config_path(&app)?; - let json = serde_json::to_string_pretty(&config) - .map_err(|e| format!("Serialization error: {e}"))?; - std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?; +fn save_config(app: AppHandle, config: RuntimeConfig) -> Result<(), String> { + save_runtime_config(&app, &config)?; Ok(()) } #[tauri::command] -fn load_config(app: AppHandle) -> Result { - let path = config_path(&app)?; - if !path.exists() { - return Ok(AppConfig::default()); - } - let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?; - serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}")) +fn load_config(app: AppHandle) -> Result { + load_runtime_config(&app) } /// Returns the resolved path to the localllm binary (useful for debugging). @@ -357,13 +296,18 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) - .manage(ServerState(Mutex::new(None))) + .manage(RuntimeState::new()) .invoke_handler(tauri::generate_handler![ query_gpu, scan_models, pick_models_dir, pick_model_files, import_model, + bootstrap_runtime_command, + get_runtime_snapshot_command, + save_runtime_config_command_handler, + start_runtime_command, + stop_runtime_command, start_server, stop_server, server_status, diff --git a/tauri-app/src-tauri/src/runtime/config.rs b/tauri-app/src-tauri/src/runtime/config.rs new file mode 100644 index 0000000..c7723a9 --- /dev/null +++ b/tauri-app/src-tauri/src/runtime/config.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use tauri::{AppHandle, Manager}; + +use super::types::RuntimeConfig; + +pub fn config_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Cannot resolve app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("Cannot create app data dir: {e}"))?; + Ok(data_dir.join("config.json")) +} + +pub fn load_runtime_config(app: &AppHandle) -> Result { + let path = config_path(app)?; + if !path.exists() { + return Ok(RuntimeConfig::default()); + } + + let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?; + serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}")) +} + +pub fn save_runtime_config(app: &AppHandle, config: &RuntimeConfig) -> Result<(), String> { + let path = config_path(app)?; + let json = + serde_json::to_string_pretty(config).map_err(|e| format!("Serialization error: {e}"))?; + std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?; + Ok(()) +} diff --git a/tauri-app/src-tauri/src/runtime/health.rs b/tauri-app/src-tauri/src/runtime/health.rs new file mode 100644 index 0000000..c25d546 --- /dev/null +++ b/tauri-app/src-tauri/src/runtime/health.rs @@ -0,0 +1,145 @@ +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::thread; +use std::time::{Duration, Instant}; + +fn resolve_socket_addr(addr: &str) -> Option { + match addr.to_socket_addrs() { + Ok(mut addrs) => addrs.next(), + Err(_) => None, + } +} + +fn health_response_is_ready(response: &str) -> bool { + let mut sections = response.splitn(2, "\r\n\r\n"); + let headers = sections.next().unwrap_or_default(); + let body = sections.next().unwrap_or_default(); + + if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") { + return false; + } + + let Ok(json) = serde_json::from_str::(body) else { + return false; + }; + + json.get("status") + .and_then(|value| value.as_str()) + .map(|status| status == "ok") + .unwrap_or(false) +} + +pub fn wait_for_health(addr: &str, timeout: Duration) -> bool { + let Some(socket_addr) = resolve_socket_addr(addr) else { + return false; + }; + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)) { + Ok(mut stream) => { + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); + + let request = + format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + + if stream.write_all(request.as_bytes()).is_ok() { + let mut response = String::new(); + if stream.read_to_string(&mut response).is_ok() + && health_response_is_ready(&response) + { + return true; + } + } + } + Err(_) => {} + } + + thread::sleep(Duration::from_millis(250)); + } + + false +} + +pub fn check_health(addr: &str, timeout: Duration) -> bool { + let Some(socket_addr) = resolve_socket_addr(addr) else { + return false; + }; + + match TcpStream::connect_timeout(&socket_addr, timeout) { + Ok(mut stream) => { + let _ = stream.set_read_timeout(Some(timeout)); + let _ = stream.set_write_timeout(Some(timeout)); + + let request = + format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + + if stream.write_all(request.as_bytes()).is_ok() { + let mut response = String::new(); + return stream.read_to_string(&mut response).is_ok() + && health_response_is_ready(&response); + } + + false + } + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::wait_for_health; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + use std::time::Duration; + + fn spawn_http_server(response: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + addr.to_string() + } + + #[test] + fn wait_for_health_accepts_healthy_http_response() { + let addr = spawn_http_server( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}", + ); + + assert!(wait_for_health(&addr, Duration::from_secs(1))); + } + + #[test] + fn wait_for_health_rejects_non_healthy_payloads() { + let addr = spawn_http_server( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 21\r\n\r\n{\"status\":\"booting\"}", + ); + + assert!(!wait_for_health(&addr, Duration::from_secs(1))); + } + + #[test] + fn wait_for_health_rejects_plain_open_ports() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind bare port"); + let addr = listener.local_addr().expect("local addr").to_string(); + + thread::spawn(move || { + if let Ok((_stream, _)) = listener.accept() { + thread::sleep(Duration::from_millis(250)); + } + }); + + assert!(!wait_for_health(&addr, Duration::from_millis(500))); + } +} diff --git a/tauri-app/src-tauri/src/runtime/mod.rs b/tauri-app/src-tauri/src/runtime/mod.rs new file mode 100644 index 0000000..bcd06c5 --- /dev/null +++ b/tauri-app/src-tauri/src/runtime/mod.rs @@ -0,0 +1,389 @@ +pub mod config; +pub mod health; +pub mod process; +pub mod types; + +use std::process::Child; +use std::sync::Mutex; +use std::time::Duration; + +use tauri::{AppHandle, State}; + +use self::config::{load_runtime_config, save_runtime_config}; +use self::health::{check_health, wait_for_health}; +use self::process::{find_localllm_binary, spawn_localllm}; +use self::types::{ + ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, StartPolicy, +}; + +pub struct RuntimeInner { + pub child: Option, + pub status: RuntimeStatus, + pub last_error: Option, + pub config: RuntimeConfig, +} + +pub struct RuntimeState(pub Mutex); + +impl RuntimeState { + pub fn new() -> Self { + Self(Mutex::new(RuntimeInner { + child: None, + status: RuntimeStatus::Stopped, + last_error: None, + config: RuntimeConfig::default(), + })) + } +} + +fn runtime_error(code: &str, message: impl Into) -> RuntimeErrorInfo { + RuntimeErrorInfo { + code: code.to_string(), + message: message.into(), + } +} + +fn resolve_runtime_url(config: &RuntimeConfig) -> String { + match config.connection_mode { + ConnectionMode::Local => format!("http://{}", config.addr.trim()), + ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(), + } +} + +fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool { + current.connection_mode != next.connection_mode + || current.model_path != next.model_path + || current.addr != next.addr + || current.gpu_layers != next.gpu_layers + || current.ctx_size != next.ctx_size +} + +fn refresh_process_state(inner: &mut RuntimeInner) { + let Some(child) = inner.child.as_mut() else { + if matches!( + inner.status, + RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded + ) { + inner.status = RuntimeStatus::Stopped; + } + return; + }; + + match child.try_wait() { + Ok(Some(status)) => { + inner.child = None; + inner.status = RuntimeStatus::Error; + inner.last_error = Some(runtime_error( + "unexpected_exit", + format!("Local API exited unexpectedly with status {status}"), + )); + } + Ok(None) => { + if inner.status == RuntimeStatus::Stopped { + inner.status = RuntimeStatus::Ready; + } + + if inner.config.connection_mode == ConnectionMode::Local + && matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded) + { + if check_health(&inner.config.addr, Duration::from_millis(400)) { + inner.status = RuntimeStatus::Ready; + if inner.last_error.as_ref().map(|error| error.code.as_str()) + == Some("health_check_failed") + { + inner.last_error = None; + } + } else { + inner.status = RuntimeStatus::Degraded; + inner.last_error = Some(runtime_error( + "health_check_failed", + format!( + "Local API process is still running, but /health is no longer ready on {}.", + inner.config.addr + ), + )); + } + } + } + Err(error) => { + inner.child = None; + inner.status = RuntimeStatus::Error; + inner.last_error = Some(runtime_error("process_probe_failed", error.to_string())); + } + } +} + +fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot { + RuntimeSnapshot { + status: inner.status, + ready: inner.status == RuntimeStatus::Ready, + process_running: inner.child.is_some(), + resolved_url: resolve_runtime_url(&inner.config), + binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()), + last_error: inner.last_error.clone(), + config: inner.config.clone(), + } +} + +pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + refresh_process_state(&mut inner); + Ok(build_snapshot(&inner)) +} + +pub fn save_runtime_config_command( + app: AppHandle, + state: State<'_, RuntimeState>, + config: RuntimeConfig, +) -> Result { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + refresh_process_state(&mut inner); + + if inner.child.is_some() && requires_runtime_restart(&inner.config, &config) { + inner.last_error = Some(runtime_error( + "restart_required", + "Stop the local API before changing connection mode, model, address, or runtime sizing.", + )); + return Ok(build_snapshot(&inner)); + } + + save_runtime_config(&app, &config)?; + inner.config = config; + inner.last_error = None; + Ok(build_snapshot(&inner)) +} + +fn start_runtime_inner( + app: &AppHandle, + state: &State<'_, RuntimeState>, + override_config: Option, +) -> Result { + let config = { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + refresh_process_state(&mut inner); + + if inner.child.is_some() { + return Ok(build_snapshot(&inner)); + } + + if let Some(config) = override_config { + save_runtime_config(app, &config)?; + inner.config = config; + } + + if inner.config.connection_mode != ConnectionMode::Local { + inner.last_error = Some(runtime_error( + "remote_mode", + "Switch to Local mode before starting the built-in API", + )); + inner.status = RuntimeStatus::Error; + return Ok(build_snapshot(&inner)); + } + + inner.status = RuntimeStatus::Starting; + inner.last_error = None; + inner.config.clone() + }; + + let child = match spawn_localllm(&config) { + Ok(child) => child, + Err(message) => { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + inner.status = RuntimeStatus::Error; + inner.last_error = Some(runtime_error("spawn_failed", message)); + return Ok(build_snapshot(&inner)); + } + }; + + { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + inner.child = Some(child); + inner.status = RuntimeStatus::Starting; + } + + if wait_for_health(&config.addr, Duration::from_secs(15)) { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + inner.status = RuntimeStatus::Ready; + inner.last_error = None; + Ok(build_snapshot(&inner)) + } else { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + if let Some(child) = inner.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + inner.child = None; + inner.status = RuntimeStatus::Error; + inner.last_error = Some(runtime_error( + "health_timeout", + format!( + "Local API did not pass /health readiness on {} in time", + config.addr + ), + )); + Ok(build_snapshot(&inner)) + } +} + +pub fn bootstrap_runtime( + app: AppHandle, + state: State<'_, RuntimeState>, +) -> Result { + let config = load_runtime_config(&app).unwrap_or_default(); + let should_autostart = config.connection_mode == ConnectionMode::Local + && config.start_policy == StartPolicy::OnAppLaunch + && config + .model_path + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + + { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + inner.config = config.clone(); + refresh_process_state(&mut inner); + } + + if should_autostart { + start_runtime_inner(&app, &state, Some(config)) + } else { + get_runtime_snapshot(state) + } +} + +pub fn start_runtime( + app: AppHandle, + state: State<'_, RuntimeState>, +) -> Result { + start_runtime_inner(&app, &state, None) +} + +pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result { + let mut inner = state.0.lock().map_err(|e| e.to_string())?; + inner.status = RuntimeStatus::Stopping; + + if let Some(child) = inner.child.as_mut() { + child + .kill() + .map_err(|e| format!("Failed to kill server: {e}"))?; + let _ = child.wait(); + } + + inner.child = None; + inner.status = RuntimeStatus::Stopped; + inner.last_error = None; + Ok(build_snapshot(&inner)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spawn_healthy_server() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port"); + let addr = listener + .local_addr() + .expect("read healthy addr") + .to_string(); + + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + use std::io::{Read, Write}; + + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}", + ); + let _ = stream.flush(); + } + }); + + addr + } + + fn unused_local_addr() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port"); + let addr = listener.local_addr().expect("read local addr").to_string(); + drop(listener); + addr + } + + fn spawn_sleeping_child() -> Child { + std::process::Command::new("python") + .args(["-c", "import time; time.sleep(60)"]) + .spawn() + .expect("spawn sleeping child") + } + + #[test] + fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() { + let mut child = spawn_sleeping_child(); + let addr = unused_local_addr(); + + let (status, error_code) = { + let mut inner = RuntimeInner { + child: Some(child), + status: RuntimeStatus::Ready, + last_error: None, + config: RuntimeConfig { + model_path: Some("D:/models/demo.gguf".to_string()), + addr, + ..RuntimeConfig::default() + }, + }; + + refresh_process_state(&mut inner); + + child = inner.child.take().expect("process remains managed"); + + ( + inner.status, + inner.last_error.as_ref().map(|error| error.code.clone()), + ) + }; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(status, RuntimeStatus::Degraded); + assert_eq!(error_code.as_deref(), Some("health_check_failed")); + } + + #[test] + fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() { + let mut child = spawn_sleeping_child(); + let addr = spawn_healthy_server(); + + let (status, error_code) = { + let mut inner = RuntimeInner { + child: Some(child), + status: RuntimeStatus::Degraded, + last_error: Some(runtime_error( + "health_check_failed", + "Local API process is still running, but /health is no longer ready.", + )), + config: RuntimeConfig { + model_path: Some("D:/models/demo.gguf".to_string()), + addr, + ..RuntimeConfig::default() + }, + }; + + refresh_process_state(&mut inner); + + child = inner.child.take().expect("process remains managed"); + + ( + inner.status, + inner.last_error.as_ref().map(|error| error.code.clone()), + ) + }; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(status, RuntimeStatus::Ready); + assert_eq!(error_code, None); + } +} diff --git a/tauri-app/src-tauri/src/runtime/process.rs b/tauri-app/src-tauri/src/runtime/process.rs new file mode 100644 index 0000000..cd852ee --- /dev/null +++ b/tauri-app/src-tauri/src/runtime/process.rs @@ -0,0 +1,75 @@ +use std::path::PathBuf; +use std::process::{Child, Command}; + +use super::types::RuntimeConfig; + +pub fn find_localllm_binary() -> Option { + let exe_name = if cfg!(target_os = "windows") { + "localllm.exe" + } else { + "localllm" + }; + + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent(); + while let Some(d) = dir { + let candidate = d.join("target").join("release").join(exe_name); + if candidate.exists() { + return Some(candidate); + } + + let sibling_candidate = d.join(exe_name); + if sibling_candidate.exists() { + return Some(sibling_candidate); + } + + dir = d.parent(); + } + } + + let cwd_candidate = PathBuf::from("target").join("release").join(exe_name); + if cwd_candidate.exists() { + return Some(cwd_candidate); + } + + if let Ok(output) = Command::new(if cfg!(target_os = "windows") { + "where" + } else { + "which" + }) + .arg("localllm") + .output() + { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout); + let first_line = path_str.lines().next().unwrap_or("").trim(); + if !first_line.is_empty() { + return Some(PathBuf::from(first_line)); + } + } + } + + None +} + +pub fn spawn_localllm(config: &RuntimeConfig) -> Result { + let model_path = config + .model_path + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Select a local model before starting the API".to_string())?; + + let binary_path = find_localllm_binary().ok_or_else(|| { + "localllm binary not found. Build it with: cargo build --release -p inference-server\n\ + Then ensure the binary is in target/release/, PATH, or next to this app." + .to_string() + })?; + + Command::new(&binary_path) + .env("LOCALLLM_MODEL", model_path) + .env("LOCALLLM_ADDR", &config.addr) + .env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string()) + .env("LOCALLLM_CTX", config.ctx_size.to_string()) + .spawn() + .map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display())) +} diff --git a/tauri-app/src-tauri/src/runtime/types.rs b/tauri-app/src-tauri/src/runtime/types.rs new file mode 100644 index 0000000..e486ff9 --- /dev/null +++ b/tauri-app/src-tauri/src/runtime/types.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionMode { + #[default] + Local, + Remote, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum StartPolicy { + #[default] + Manual, + OnAppLaunch, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeStatus { + #[default] + Stopped, + Starting, + Ready, + Degraded, + Stopping, + Error, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(default)] +pub struct RuntimeConfig { + pub connection_mode: ConnectionMode, + pub start_policy: StartPolicy, + pub server_url: String, + pub api_key: String, + pub model_path: Option, + pub models_dir: Option, + pub addr: String, + pub gpu_layers: i32, + pub ctx_size: u32, + pub extra_model_paths: Vec, +} + +impl RuntimeConfig { + pub fn default_addr() -> String { + "127.0.0.1:8080".to_string() + } +} + +impl Default for RuntimeConfig { + fn default() -> Self { + Self { + connection_mode: ConnectionMode::Local, + start_policy: StartPolicy::Manual, + server_url: String::new(), + api_key: String::new(), + model_path: None, + models_dir: None, + addr: Self::default_addr(), + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: Vec::new(), + } + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct RuntimeErrorInfo { + pub code: String, + pub message: String, +} + +#[derive(Serialize, Clone)] +pub struct RuntimeSnapshot { + pub status: RuntimeStatus, + pub ready: bool, + pub process_running: bool, + pub resolved_url: String, + pub binary_path: Option, + pub last_error: Option, + pub config: RuntimeConfig, +} diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index ff2816e..02fc920 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -3,7 +3,7 @@ import Sidebar from "./components/Sidebar"; import ChatView from "./components/ChatView"; import { useDB } from "./hooks/useDB"; import { useModels } from "./hooks/useModels"; -import { useServerProcess } from "./hooks/useServerProcess"; +import { useRuntimeManager } from "./features/runtime/useRuntimeManager"; import type { Chat } from "./lib/types"; import "./App.css"; @@ -11,22 +11,20 @@ export default function App() { const { db, ready } = useDB(); const [currentChatId, setCurrentChatId] = useState(null); const [chats, setChats] = useState([]); - const [server, setServer] = useState(() => localStorage.getItem("forgelm_server") || "http://127.0.0.1:8080"); - const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || ""); - const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey); - const serverProcess = useServerProcess(); - - // Persist server/key - useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]); - useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]); + const runtime = useRuntimeManager(); + const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels( + runtime.connection.serverUrl, + runtime.connection.apiKey, + Boolean(runtime.connection.serverUrl), + ); // Auto-refresh models when server starts useEffect(() => { - if (serverProcess.status === "running") { + if (runtime.snapshot.status === "ready") { setTimeout(refreshModels, 2000); } - }, [serverProcess.status, refreshModels]); + }, [runtime.snapshot.status, refreshModels]); const loadChats = useCallback(() => { if (!db) return; @@ -70,22 +68,18 @@ export default function App() { onSelectChat={setCurrentChatId} onNewChat={newChat} onDeleteChat={deleteChat} - server={server} - onServerChange={setServer} - apiKey={apiKey} - onApiKeyChange={setApiKey} models={models} selectedModel={selectedModel} onModelChange={setSelectedModel} status={status} onRefreshModels={refreshModels} - serverProcess={serverProcess} + runtime={runtime} /> { setCurrentChatId(id); loadChats(); }} onChatUpdated={loadChats} diff --git a/tauri-app/src/components/Sidebar.tsx b/tauri-app/src/components/Sidebar.tsx index fc471a1..275c307 100644 --- a/tauri-app/src/components/Sidebar.tsx +++ b/tauri-app/src/components/Sidebar.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import type { Chat, ModelInfo, ServerStatus } from "../lib/types"; -import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess"; -import ServerPanel from "./ServerPanel"; +import RuntimeSettingsDrawer from "../features/runtime/components/RuntimeSettingsDrawer"; +import RuntimeStatusCard from "../features/runtime/components/RuntimeStatusCard"; +import type { RuntimeConfig, RuntimeSnapshot } from "../features/runtime/types"; import "./Sidebar.css"; interface Props { @@ -10,33 +11,31 @@ interface Props { onSelectChat: (id: number) => void; onNewChat: () => void; onDeleteChat: (id: number) => void; - server: string; - onServerChange: (v: string) => void; - apiKey: string; - onApiKeyChange: (v: string) => void; models: ModelInfo[]; selectedModel: string; onModelChange: (v: string) => void; status: ServerStatus; onRefreshModels: () => void; - serverProcess: { - status: ProcessStatus; - message: string; - start: (cfg: ServerConfig) => void; - stop: () => void; + runtime: { + snapshot: RuntimeSnapshot; + loading: boolean; + busy: boolean; + saveConfig: (patch: Partial) => Promise; + startLocal: () => Promise; + stopLocal: () => Promise; }; } export default function Sidebar({ chats, currentChatId, onSelectChat, onNewChat, onDeleteChat, - server, onServerChange, apiKey, onApiKeyChange, models, selectedModel, onModelChange, status, onRefreshModels, - serverProcess, + runtime, }: Props) { - const [showSettings, setShowSettings] = useState(false); + const [showRuntimeSettings, setShowRuntimeSettings] = useState(false); return ( - + + setShowRuntimeSettings(false)} + onSave={runtime.saveConfig} + onStart={() => runtime.startLocal()} + onStop={() => runtime.stopLocal()} + /> + ); } diff --git a/tauri-app/src/features/runtime/api.ts b/tauri-app/src/features/runtime/api.ts new file mode 100644 index 0000000..8c25c26 --- /dev/null +++ b/tauri-app/src/features/runtime/api.ts @@ -0,0 +1,120 @@ +import { invoke } from "@tauri-apps/api/core"; +import { createDefaultRuntimeSnapshot } from "./runtime-utils"; +import type { + RuntimeConfig, + RuntimeGpuInfo, + RuntimeModelFile, + RuntimePickDirResult, + RuntimeSnapshot, +} from "./types"; + +const STORAGE_KEY = "forgelm_runtime_snapshot_v1"; + +function canUseLocalStorage(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function readFallbackSnapshot(): RuntimeSnapshot { + if (!canUseLocalStorage()) { + return createDefaultRuntimeSnapshot(); + } + + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return createDefaultRuntimeSnapshot(); + } + + try { + return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot; + } catch { + return createDefaultRuntimeSnapshot(); + } +} + +function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot { + if (canUseLocalStorage()) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); + } + return snapshot; +} + +export async function bootstrapRuntime(): Promise { + try { + return await invoke("bootstrap_runtime_command"); + } catch { + return readFallbackSnapshot(); + } +} + +export async function getRuntimeSnapshot(): Promise { + try { + return await invoke("get_runtime_snapshot_command"); + } catch { + return readFallbackSnapshot(); + } +} + +export async function saveRuntimeConfig(config: RuntimeConfig): Promise { + try { + return await invoke("save_runtime_config_command_handler", { config }); + } catch { + const snapshot = readFallbackSnapshot(); + return writeFallbackSnapshot({ ...snapshot, config }); + } +} + +export async function startRuntime(): Promise { + try { + return await invoke("start_runtime_command"); + } catch { + const snapshot = readFallbackSnapshot(); + return writeFallbackSnapshot({ + ...snapshot, + status: "error", + last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." }, + }); + } +} + +export async function stopRuntime(): Promise { + try { + return await invoke("stop_runtime_command"); + } catch { + const snapshot = readFallbackSnapshot(); + return writeFallbackSnapshot({ ...snapshot, status: "stopped", process_running: false, ready: false }); + } +} + +export async function queryRuntimeGpu(): Promise { + try { + return await invoke("query_gpu"); + } catch { + return null; + } +} + +export async function scanRuntimeModels(dir: string): Promise { + return invoke("scan_models", { dir }); +} + +export async function pickRuntimeModelsDir(): Promise { + try { + return await invoke("pick_models_dir"); + } catch (error) { + if (String(error) === "cancelled") { + return null; + } + throw error; + } +} + +export async function pickRuntimeModelFiles(): Promise { + try { + return await invoke("pick_model_files"); + } catch (error) { + if (String(error) === "cancelled") { + return []; + } + throw error; + } +} diff --git a/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.css b/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.css new file mode 100644 index 0000000..db8f9b7 --- /dev/null +++ b/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.css @@ -0,0 +1,355 @@ +.runtime-drawer-shell { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + justify-content: flex-end; +} + +.runtime-drawer-backdrop { + flex: 1; + border: 0; + background: rgba(5, 6, 10, 0.58); + backdrop-filter: blur(6px); +} + +.runtime-drawer { + width: min(560px, 100%); + height: 100%; + display: flex; + flex-direction: column; + background: + radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%), + linear-gradient(180deg, #10161f, #0b1017 72%); + border-left: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48); +} + +.runtime-drawer-header, +.runtime-drawer-footer { + padding: 20px 22px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.runtime-drawer-footer { + border-bottom: 0; + border-top: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + gap: 10px; +} + +.runtime-drawer-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #78bdf4; + margin-bottom: 6px; +} + +.runtime-drawer-header h2 { + font-size: 28px; + line-height: 1.1; + letter-spacing: -0.03em; +} + +.runtime-drawer-copy { + margin-top: 10px; + max-width: 34ch; + color: var(--text2); + line-height: 1.55; +} + +.runtime-drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.runtime-drawer-close { + width: 36px; + height: 36px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + font-size: 26px; + line-height: 1; + cursor: pointer; +} + +.runtime-drawer-body { + flex: 1; + overflow-y: auto; + padding: 20px 22px 28px; +} + +.runtime-drawer-section { + margin-bottom: 18px; +} + +.runtime-section-card, +.runtime-overview-card { + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 18px; + background: rgba(255, 255, 255, 0.035); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.runtime-section-card { + padding: 16px; +} + +.runtime-overview-card { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + padding: 18px; + margin-bottom: 18px; +} + +.runtime-overview-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text3); + margin-bottom: 8px; +} + +.runtime-overview-value { + color: var(--text); + font-size: 14px; + line-height: 1.45; +} + +.runtime-overview-mono, +.runtime-static-value-mono { + font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; + font-size: 13px; +} + +.runtime-drawer-section-title, +.runtime-field-label { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text3); + margin-bottom: 8px; +} + +.runtime-segmented { + display: flex; + gap: 8px; +} + +.runtime-segmented-btn, +.runtime-footer-btn { + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease; +} + +.runtime-segmented-btn { + flex: 1; + padding: 11px 12px; +} + +.runtime-segmented-btn.active { + border-color: rgba(72, 166, 255, 0.55); + background: rgba(72, 166, 255, 0.16); +} + +.runtime-field-input, +.runtime-static-value { + width: 100%; + min-height: 44px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + padding: 11px 13px; + font-size: 14px; + font-family: inherit; +} + +.runtime-static-value { + display: flex; + align-items: center; +} + +.runtime-field-select { + appearance: none; +} + +.runtime-static-value-path { + word-break: break-all; +} + +.runtime-grid-two { + display: grid; + gap: 12px; +} + +.runtime-field-row { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-end; + margin-bottom: 14px; +} + +.runtime-field-row-top { + align-items: flex-start; +} + +.runtime-field-support { + color: var(--text2); + font-size: 13px; + line-height: 1.5; +} + +.runtime-inline-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.runtime-inline-btn, +.runtime-preset, +.runtime-chip { + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + font-family: inherit; + cursor: pointer; +} + +.runtime-inline-btn { + min-height: 40px; + padding: 0 12px; + font-size: 13px; + font-weight: 700; +} + +.runtime-inline-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.runtime-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.runtime-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + font-size: 12px; +} + +.runtime-chip span { + color: var(--text3); +} + +.runtime-preset-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} + +.runtime-preset { + padding: 8px 12px; + font-size: 12px; + font-weight: 700; +} + +.runtime-preset.active { + border-color: rgba(72, 166, 255, 0.55); + background: rgba(72, 166, 255, 0.16); +} + +.runtime-checkbox-row { + display: flex; + gap: 10px; + align-items: flex-start; + color: var(--text2); + font-size: 14px; + line-height: 1.5; +} + +.runtime-error-banner { + border-radius: 12px; + border: 1px solid rgba(240, 64, 64, 0.18); + background: rgba(240, 64, 64, 0.08); + color: #ffb7b7; + padding: 12px 14px; + line-height: 1.5; +} + +.runtime-warning-banner { + border-radius: 12px; + border: 1px solid rgba(243, 211, 139, 0.18); + background: rgba(243, 211, 139, 0.08); + color: #f3d38b; + padding: 12px 14px; + line-height: 1.5; +} + +.runtime-footer-btn { + flex: 1; + padding: 12px 14px; +} + +.runtime-footer-btn:active:not(:disabled), +.runtime-segmented-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.runtime-footer-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.runtime-footer-btn-secondary:hover:not(:disabled) { + border-color: var(--accent); +} + +.runtime-footer-btn-primary { + color: #fff; + border-color: rgba(72, 166, 255, 0.55); + background: linear-gradient(135deg, #2c8ce8, #49c0ff); +} + +@media (max-width: 720px) { + .runtime-drawer { + width: 100%; + } + + .runtime-overview-card, + .runtime-grid-two, + .runtime-field-row { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .runtime-drawer-footer { + flex-direction: column; + } +} diff --git a/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx b/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx new file mode 100644 index 0000000..4de891b --- /dev/null +++ b/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx @@ -0,0 +1,480 @@ +import { useEffect, useMemo, useState } from "react"; +import { + pickRuntimeModelFiles, + pickRuntimeModelsDir, + queryRuntimeGpu, + scanRuntimeModels, +} from "../api"; +import type { + RuntimeConfig, + RuntimeGpuInfo, + RuntimeModelFile, + RuntimeSnapshot, +} from "../types"; +import "./RuntimeSettingsDrawer.css"; + +const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536]; + +function pathName(path: string | null | undefined): string { + if (!path) return ""; + const parts = path.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] || path; +} + +function toModelFile(path: string): RuntimeModelFile { + return { name: pathName(path) || path, path }; +} + +function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] { + const seen = new Set(); + return models.filter((model) => { + if (seen.has(model.path)) return false; + seen.add(model.path); + return true; + }); +} + +function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string { + const nextChoices = [...scanned, ...extra].map((model) => model.path); + if (current && (nextChoices.length === 0 || nextChoices.includes(current))) { + return current; + } + return scanned[0]?.path || extra[0]?.path || ""; +} + +interface Props { + open: boolean; + snapshot: RuntimeSnapshot; + busy: boolean; + onClose: () => void; + onSave: (patch: Partial) => Promise; + onStart: () => Promise; + onStop: () => Promise; +} + +export default function RuntimeSettingsDrawer({ + open, + snapshot, + busy, + onClose, + onSave, + onStart, + onStop, +}: Props) { + const [mode, setMode] = useState(snapshot.config.connection_mode); + const [startPolicy, setStartPolicy] = useState(snapshot.config.start_policy); + const [serverUrl, setServerUrl] = useState(snapshot.config.server_url); + const [apiKey, setApiKey] = useState(snapshot.config.api_key); + const [modelsDir, setModelsDir] = useState(snapshot.config.models_dir || ""); + const [selectedModel, setSelectedModel] = useState(snapshot.config.model_path || ""); + const [addr, setAddr] = useState(snapshot.config.addr); + const [gpuLayers, setGpuLayers] = useState(snapshot.config.gpu_layers); + const [ctxSize, setCtxSize] = useState(snapshot.config.ctx_size); + const [scannedModels, setScannedModels] = useState([]); + const [extraModels, setExtraModels] = useState([]); + const [gpuInfo, setGpuInfo] = useState(null); + const [gpuError, setGpuError] = useState(""); + const [localError, setLocalError] = useState(""); + const [scanning, setScanning] = useState(false); + + useEffect(() => { + if (!open) return; + + const extra = snapshot.config.extra_model_paths.map(toModelFile); + + setMode(snapshot.config.connection_mode); + setStartPolicy(snapshot.config.start_policy); + setServerUrl(snapshot.config.server_url); + setApiKey(snapshot.config.api_key); + setModelsDir(snapshot.config.models_dir || ""); + setSelectedModel(snapshot.config.model_path || ""); + setAddr(snapshot.config.addr); + setGpuLayers(snapshot.config.gpu_layers); + setCtxSize(snapshot.config.ctx_size); + setExtraModels(extra); + setScannedModels([]); + setLocalError(""); + setGpuError(""); + + if (snapshot.config.models_dir?.trim()) { + setScanning(true); + scanRuntimeModels(snapshot.config.models_dir) + .then((models) => { + setScannedModels(models); + setSelectedModel((current) => chooseModel(current || snapshot.config.model_path || "", models, extra)); + }) + .catch((error) => setLocalError(String(error))) + .finally(() => setScanning(false)); + } + + queryRuntimeGpu() + .then((info) => { + setGpuInfo(info); + if (info && snapshot.config.gpu_layers === 0) { + setGpuLayers(info.recommended_layers); + } + }) + .catch((error) => setGpuError(String(error))); + }, [open, snapshot]); + + const allModels = useMemo(() => { + const merged = dedupeModels([...scannedModels, ...extraModels]); + if (selectedModel && !merged.some((model) => model.path === selectedModel)) { + return [toModelFile(selectedModel), ...merged]; + } + return merged; + }, [extraModels, scannedModels, selectedModel]); + + const savedLocalModel = snapshot.config.model_path?.trim() || ""; + const hasPendingModeChange = mode !== snapshot.config.connection_mode; + const maxLayers = gpuInfo?.recommended_layers ?? 200; + const startReason = + mode !== "local" + ? "Save mode before starting the local runtime." + : !snapshot.binary_path + ? "Build or bundle the local API binary before starting." + : !selectedModel.trim() + ? "Choose a model before starting the local runtime." + : ""; + + if (!open) return null; + + const buildPatch = (): Partial => ({ + connection_mode: mode, + start_policy: startPolicy, + server_url: serverUrl.trim(), + api_key: apiKey.trim(), + models_dir: mode === "local" ? (modelsDir.trim() || null) : snapshot.config.models_dir, + model_path: mode === "local" ? (selectedModel.trim() || null) : snapshot.config.model_path, + addr: mode === "local" ? addr.trim() || snapshot.config.addr : snapshot.config.addr, + gpu_layers: mode === "local" ? Math.max(0, Math.min(gpuLayers, maxLayers)) : snapshot.config.gpu_layers, + ctx_size: mode === "local" ? Math.max(512, ctxSize || 4096) : snapshot.config.ctx_size, + extra_model_paths: mode === "local" ? extraModels.map((model) => model.path) : snapshot.config.extra_model_paths, + }); + + const handleSave = async () => { + setLocalError(""); + try { + await onSave(buildPatch()); + } catch (error) { + setLocalError(String(error)); + } + }; + + const handleSaveAndStart = async () => { + setLocalError(""); + + if (snapshot.process_running) { + try { + await onStop(); + } catch (error) { + setLocalError(String(error)); + } + return; + } + + try { + const next = await onSave(buildPatch()); + if (next.config.connection_mode !== "local") return; + if (!next.config.model_path?.trim()) return; + if (next.last_error?.code === "restart_required") return; + await onStart(); + } catch (error) { + setLocalError(String(error)); + } + }; + + const handleRescan = async (dir = modelsDir) => { + if (!dir.trim()) return; + setScanning(true); + setLocalError(""); + try { + const models = await scanRuntimeModels(dir.trim()); + setScannedModels(models); + setSelectedModel((current) => chooseModel(current, models, extraModels)); + } catch (error) { + setLocalError(String(error)); + setScannedModels([]); + } finally { + setScanning(false); + } + }; + + const handlePickDir = async () => { + setLocalError(""); + try { + const result = await pickRuntimeModelsDir(); + if (!result) return; + setModelsDir(result.dir); + setScannedModels(result.models); + setSelectedModel((current) => chooseModel(current, result.models, extraModels)); + } catch (error) { + setLocalError(String(error)); + } + }; + + const handlePickFiles = async () => { + setLocalError(""); + try { + const picked = await pickRuntimeModelFiles(); + if (picked.length === 0) return; + setExtraModels((current) => { + const next = dedupeModels([...current, ...picked]); + if (!selectedModel) { + setSelectedModel(next[0]?.path || ""); + } + return next; + }); + } catch (error) { + setLocalError(String(error)); + } + }; + + const handleRemoveExtra = (path: string) => { + setExtraModels((current) => { + const next = current.filter((model) => model.path !== path); + setSelectedModel((selected) => (selected === path ? chooseModel("", scannedModels, next) : selected)); + return next; + }); + }; + + return ( +
+ +
+ +
+
+
+
Current status
+
{snapshot.status === "ready" ? "Local API ready" : snapshot.config.connection_mode === "remote" ? "Remote API mode" : "Local API setup"}
+
+
+
Endpoint
+
{mode === "remote" ? (serverUrl.trim() || "Not configured") : `http://${addr.trim() || snapshot.config.addr}`}
+
+
+
Saved model
+
{savedLocalModel ? pathName(savedLocalModel) : "No model saved"}
+
+
+ +
+
Connection mode
+
+ + +
+
+ + {mode === "remote" ? ( +
+ + setServerUrl(event.target.value)} + placeholder="https://api.example.com" + type="text" + /> + + + setApiKey(event.target.value)} + placeholder="sk-..." + type="password" + /> +
+ ) : ( + <> +
+
+
Managed endpoint
+
http://{addr.trim() || snapshot.config.addr}
+
+
+
Binary discovery
+
{snapshot.binary_path || "Binary not found yet"}
+
+
+ +
+
+
+
Models folder
+
{modelsDir || "Choose a folder or add individual files"}
+
+
+ + + +
+
+ + + + + {extraModels.length > 0 && ( +
+ {extraModels.map((model) => ( + + ))} +
+ )} +
+ +
+
+ + setAddr(event.target.value)} + placeholder="127.0.0.1:8080" + type="text" + /> +
+
+ + setCtxSize(Math.max(512, parseInt(event.target.value, 10) || 4096))} + type="number" + /> +
+
+ +
+
+
+
GPU layers
+
+ {gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"} +
+
+ {gpuInfo && ( + + )} +
+ + setGpuLayers(Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)))} + type="number" + /> + +
+ {CTX_PRESETS.map((preset) => ( + + ))} +
+
+ +
+ +
+ + )} + + {(localError || snapshot.last_error) && ( +
+
{localError || snapshot.last_error?.message}
+
+ )} + + {!snapshot.process_running && mode === "local" && startReason && ( +
+
{startReason}
+
+ )} +
+ +
+ + + {mode === "local" && ( + + )} +
+ + + ); +} diff --git a/tauri-app/src/features/runtime/components/RuntimeStatusCard.css b/tauri-app/src/features/runtime/components/RuntimeStatusCard.css new file mode 100644 index 0000000..49865db --- /dev/null +++ b/tauri-app/src/features/runtime/components/RuntimeStatusCard.css @@ -0,0 +1,159 @@ +.runtime-card { + margin: 10px 12px 8px; + padding: 15px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%), + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28); +} + +.runtime-card-success { + border-color: rgba(48, 209, 88, 0.24); +} + +.runtime-card-warning { + border-color: rgba(255, 214, 10, 0.24); +} + +.runtime-card-danger { + border-color: rgba(240, 64, 64, 0.24); +} + +.runtime-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.runtime-card-kicker { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text3); + margin-bottom: 4px; +} + +.runtime-card-title { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.runtime-card-mode { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 5px 8px; + border-radius: 999px; + border: 1px solid var(--border2); + color: var(--text2); + background: rgba(255, 255, 255, 0.04); +} + +.runtime-card-mode-local { + color: #71c5ff; + border-color: rgba(113, 197, 255, 0.28); +} + +.runtime-card-mode-remote { + color: #92e5c7; + border-color: rgba(146, 229, 199, 0.24); +} + +.runtime-card-copy { + margin-top: 10px; + color: var(--text2); + font-size: 12px; + line-height: 1.5; +} + +.runtime-card-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.runtime-card-meta span, +.runtime-card-note { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text2); + font-size: 11px; + line-height: 1.3; + padding: 7px 10px; +} + +.runtime-card-note { + display: block; + margin-top: 10px; + border-radius: 12px; + color: #f3d38b; + border-color: rgba(243, 211, 139, 0.16); + background: rgba(243, 211, 139, 0.08); +} + +.runtime-card-error { + margin-top: 10px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(240, 64, 64, 0.2); + background: rgba(240, 64, 64, 0.08); + color: #ffb4b4; + font-size: 11px; + line-height: 1.5; +} + +.runtime-card-actions { + display: flex; + gap: 8px; + margin-top: 14px; +} + +.runtime-card-btn { + flex: 1; + border-radius: 10px; + border: 1px solid var(--border2); + padding: 9px 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + font-family: inherit; + transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease; +} + +.runtime-card-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.runtime-card-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.runtime-card-btn-secondary { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} + +.runtime-card-btn-secondary:hover:not(:disabled) { + border-color: var(--accent); +} + +.runtime-card-btn-primary { + color: #fff; + border-color: rgba(72, 166, 255, 0.6); + background: linear-gradient(135deg, #2b88e6, #46c2ff); +} + +.runtime-card-btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, #3193f5, #55cbff); +} diff --git a/tauri-app/src/features/runtime/components/RuntimeStatusCard.tsx b/tauri-app/src/features/runtime/components/RuntimeStatusCard.tsx new file mode 100644 index 0000000..58dbff8 --- /dev/null +++ b/tauri-app/src/features/runtime/components/RuntimeStatusCard.tsx @@ -0,0 +1,72 @@ +import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils"; +import type { RuntimeSnapshot } from "../types"; +import "./RuntimeStatusCard.css"; + +function summarizePath(path: string | null): string { + if (!path) return "No model selected"; + const segments = path.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] || path; +} + +interface Props { + snapshot: RuntimeSnapshot; + loading: boolean; + busy: boolean; + onOpenSettings: () => void; + onStart: () => void; + onStop: () => void; +} + +export default function RuntimeStatusCard({ snapshot, loading, busy, onOpenSettings, onStart, onStop }: Props) { + const tone = getRuntimeStatusTone(snapshot); + const isLocal = snapshot.config.connection_mode === "local"; + const startGuard = getRuntimeStartGuard(snapshot); + const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart; + const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop; + const primaryLabel = needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop"; + const primaryDisabled = busy || (isLocal && snapshot.process_running && snapshot.status === "stopping"); + + return ( +
+
+
+
Runtime Manager
+
{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}
+
+ + {snapshot.config.connection_mode === "local" ? "Local" : "Remote"} + +
+ +

+ {snapshot.config.connection_mode === "local" + ? `Managed endpoint ${snapshot.resolved_url}` + : snapshot.config.server_url || "Configure a remote API endpoint"} +

+ +
+ {isLocal ? summarizePath(snapshot.config.model_path) : "External provider"} + {snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"} +
+ + {needsSetup &&
{startGuard.reason}
} + + {snapshot.last_error && ( +
+ {snapshot.last_error.message} +
+ )} + +
+ + {snapshot.config.connection_mode === "local" && ( + + )} +
+
+ ); +} diff --git a/tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx b/tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx new file mode 100644 index 0000000..af44e6e --- /dev/null +++ b/tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx @@ -0,0 +1,425 @@ +/* @vitest-environment jsdom */ + +import { useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + bootstrapRuntime, + getRuntimeSnapshot, + pickRuntimeModelsDir, + saveRuntimeConfig, + startRuntime, + stopRuntime, +} from "../api"; +import RuntimeSettingsDrawer from "./RuntimeSettingsDrawer"; +import RuntimeStatusCard from "./RuntimeStatusCard"; +import { useRuntimeManager } from "../useRuntimeManager"; +import { createDefaultRuntimeSnapshot } from "../runtime-utils"; +import type { RuntimeConfig, RuntimeSnapshot } from "../types"; + +type SnapshotOverrides = Partial> & { + config?: Partial; +}; + +vi.mock("../api", () => ({ + bootstrapRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()), + getRuntimeSnapshot: vi.fn(async () => createDefaultRuntimeSnapshot()), + pickRuntimeModelFiles: vi.fn(async () => []), + pickRuntimeModelsDir: vi.fn(async () => null), + queryRuntimeGpu: vi.fn(async () => null), + saveRuntimeConfig: vi.fn(async (config) => ({ ...createDefaultRuntimeSnapshot(), config })), + scanRuntimeModels: vi.fn(async () => []), + startRuntime: vi.fn(async () => ({ ...createDefaultRuntimeSnapshot(), status: "ready", ready: true, process_running: true })), + stopRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()), +})); + +function makeSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot { + return { + ...createDefaultRuntimeSnapshot(), + ...overrides, + config: { + ...createDefaultRuntimeSnapshot().config, + ...overrides.config, + }, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + + return { promise, resolve }; +} + +function RuntimeManagerHarness({ startOpen = false }: { startOpen?: boolean }) { + const runtime = useRuntimeManager(); + const [drawerOpen, setDrawerOpen] = useState(startOpen); + + return ( + <> + setDrawerOpen(true)} + onStart={() => { + void runtime.startLocal(); + }} + onStop={() => { + void runtime.stopLocal(); + }} + /> + setDrawerOpen(false)} + onSave={runtime.saveConfig} + onStart={runtime.startLocal} + onStop={runtime.stopLocal} + /> + + ); +} + +describe("Runtime app surface", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + // @ts-expect-error React test environment flag + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + vi.useRealTimers(); + // @ts-expect-error React test environment flag + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("routes incomplete local runtime setup to the settings drawer instead of trying to start", async () => { + const onOpenSettings = vi.fn(); + const onStart = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Set up")); + expect(button).toBeTruthy(); + + await act(async () => { + button?.click(); + }); + + expect(onOpenSettings).toHaveBeenCalledTimes(1); + expect(onStart).not.toHaveBeenCalled(); + }); + + it("saves local runtime settings before starting from the unified drawer", async () => { + const onSave = vi.fn(async (patch) => makeSnapshot({ + binary_path: "D:/bin/localllm.exe", + config: { + ...patch, + connection_mode: "local", + model_path: "D:/models/demo.gguf", + }, + })); + const onStart = vi.fn(async () => makeSnapshot({ status: "starting" })); + + await act(async () => { + root.render( + , + ); + }); + + const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API")); + expect(action).toBeTruthy(); + expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false); + + await act(async () => { + action?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave.mock.calls[0]?.[0]).toMatchObject({ + connection_mode: "local", + model_path: "D:/models/demo.gguf", + models_dir: "D:/models", + }); + expect(onStart).toHaveBeenCalledTimes(1); + }); + + it("stops the local runtime from the same drawer action when the process is already running", async () => { + const onSave = vi.fn(); + const onStop = vi.fn(async () => makeSnapshot({ status: "stopping", process_running: false })); + + await act(async () => { + root.render( + , + ); + }); + + const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Stop local API")); + expect(action).toBeTruthy(); + + await act(async () => { + action?.click(); + await Promise.resolve(); + }); + + expect(onStop).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("keeps the local primary CTA visible while switching from remote to local mode", async () => { + await act(async () => { + root.render( + makeSnapshot())} + onStart={vi.fn(async () => makeSnapshot({ status: "starting" }))} + onStop={vi.fn(async () => makeSnapshot({ status: "stopped" }))} + />, + ); + }); + + const localTab = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Local API")); + expect(localTab).toBeTruthy(); + + await act(async () => { + localTab?.click(); + }); + + const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save mode before applying")); + expect(action).toBeTruthy(); + expect((action as HTMLButtonElement | undefined)?.disabled).toBe(true); + }); + + it("covers the saved local startup lifecycle from stopped to starting to ready", async () => { + const initialSnapshot = makeSnapshot({ + binary_path: "D:/bin/localllm.exe", + config: { + connection_mode: "local", + model_path: "D:/models/demo.gguf", + start_policy: "on_app_launch", + }, + }); + const startDeferred = createDeferred(); + + vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot); + vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot); + vi.mocked(startRuntime).mockReturnValue(startDeferred.promise); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Local API offline"); + expect(container.textContent).toContain("demo.gguf"); + + const startButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Start")); + expect(startButton).toBeTruthy(); + + await act(async () => { + startButton?.click(); + }); + + expect(container.textContent).toContain("Starting local API"); + expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1); + + await act(async () => { + startDeferred.resolve(makeSnapshot({ + ...initialSnapshot, + status: "ready", + process_running: true, + ready: true, + })); + await startDeferred.promise; + }); + + expect(container.textContent).toContain("Ready"); + expect(container.textContent).toContain("Stop"); + }); + + it("supports local onboarding from browsing a models folder through save and start", async () => { + const initialSnapshot = makeSnapshot({ + binary_path: "D:/bin/localllm.exe", + config: { + connection_mode: "local", + model_path: null, + models_dir: null, + }, + }); + + vi.mocked(pickRuntimeModelsDir).mockResolvedValue({ + dir: "D:/models", + models: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }], + }); + + vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot); + vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot); + vi.mocked(saveRuntimeConfig).mockImplementation(async (config: RuntimeConfig) => makeSnapshot({ + ...initialSnapshot, + config, + })); + vi.mocked(startRuntime).mockResolvedValue(makeSnapshot({ + ...initialSnapshot, + status: "ready", + process_running: true, + ready: true, + config: { + ...initialSnapshot.config, + models_dir: "D:/models", + model_path: "D:/models/demo.gguf", + }, + })); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const browseButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Browse")); + expect(browseButton).toBeTruthy(); + + await act(async () => { + browseButton?.click(); + await Promise.resolve(); + }); + + const select = container.querySelector("#runtime-model-select") as HTMLSelectElement | null; + expect(select?.value).toBe("D:/models/demo.gguf"); + expect(container.textContent).toContain("D:/models"); + + const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API")); + expect(action).toBeTruthy(); + expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false); + + await act(async () => { + action?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(vi.mocked(saveRuntimeConfig)).toHaveBeenCalledTimes(1); + expect(vi.mocked(saveRuntimeConfig).mock.calls[0]?.[0]).toMatchObject({ + connection_mode: "local", + models_dir: "D:/models", + model_path: "D:/models/demo.gguf", + }); + expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Local API ready"); + expect(container.textContent).toContain("Stop local API"); + }); + + it("surfaces degraded local runtime state after periodic health revalidation fails", async () => { + vi.useFakeTimers(); + + const readySnapshot = makeSnapshot({ + status: "ready", + ready: true, + process_running: true, + binary_path: "D:/bin/localllm.exe", + config: { + connection_mode: "local", + model_path: "D:/models/demo.gguf", + }, + }); + + const degradedSnapshot = makeSnapshot({ + ...readySnapshot, + status: "degraded", + ready: false, + last_error: { + code: "health_check_failed", + message: "Local API process is still running, but /health is no longer ready.", + }, + }); + + vi.mocked(bootstrapRuntime).mockResolvedValue(readySnapshot); + vi.mocked(getRuntimeSnapshot).mockResolvedValue(degradedSnapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Ready"); + expect(container.textContent).toContain("Stop"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(vi.mocked(getRuntimeSnapshot)).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Attention needed"); + expect(container.textContent).toContain("Local API process is still running, but /health is no longer ready."); + expect(container.textContent).toContain("Stop"); + }); +}); diff --git a/tauri-app/src/features/runtime/runtime-utils.test.ts b/tauri-app/src/features/runtime/runtime-utils.test.ts new file mode 100644 index 0000000..2b252c4 --- /dev/null +++ b/tauri-app/src/features/runtime/runtime-utils.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils"; +import type { RuntimeSnapshot } from "./types"; + +function createSnapshot(overrides: Partial = {}): RuntimeSnapshot { + return { + status: "stopped", + ready: false, + process_running: false, + resolved_url: "http://127.0.0.1:8080", + binary_path: null, + last_error: null, + config: { + connection_mode: "local", + start_policy: "manual", + server_url: "", + api_key: "", + model_path: null, + models_dir: null, + addr: "127.0.0.1:8080", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + ...overrides, + }; +} + +describe("getRuntimeConnection", () => { + it("prefers the resolved local runtime url in local mode", () => { + const connection = getRuntimeConnection(createSnapshot({ + resolved_url: "http://127.0.0.1:9000", + config: { + connection_mode: "local", + start_policy: "manual", + server_url: "http://example.com:9999", + api_key: "", + model_path: null, + models_dir: null, + addr: "127.0.0.1:9000", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + })); + + expect(connection.serverUrl).toBe("http://127.0.0.1:9000"); + expect(connection.mode).toBe("local"); + }); + + it("uses remote settings in remote mode", () => { + const connection = getRuntimeConnection(createSnapshot({ + config: { + connection_mode: "remote", + start_policy: "manual", + server_url: "https://api.example.com", + api_key: "sk-demo", + model_path: null, + models_dir: null, + addr: "127.0.0.1:8080", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + })); + + expect(connection.serverUrl).toBe("https://api.example.com"); + expect(connection.apiKey).toBe("sk-demo"); + expect(connection.mode).toBe("remote"); + }); +}); + +describe("getRuntimeStateLabel", () => { + it("reports ready snapshots as ready", () => { + expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready"); + }); + + it("reports local background start progress", () => { + expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API"); + }); + + it("reports remote mode without runtime ownership", () => { + expect(getRuntimeStateLabel(createSnapshot({ + status: "stopped", + config: { + connection_mode: "remote", + start_policy: "manual", + server_url: "https://api.example.com", + api_key: "", + model_path: null, + models_dir: null, + addr: "127.0.0.1:8080", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + }))).toBe("Remote API"); + }); +}); + +describe("getRuntimeStartGuard", () => { + it("requires a saved model before starting the local runtime", () => { + const guard = getRuntimeStartGuard(createSnapshot({ + binary_path: "D:/bin/localllm.exe", + config: { + connection_mode: "local", + start_policy: "manual", + server_url: "", + api_key: "", + model_path: null, + models_dir: null, + addr: "127.0.0.1:8080", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + })); + + expect(guard.canStart).toBe(false); + expect(guard.reason.toLowerCase()).toContain("model"); + }); + + it("allows starting when the saved local runtime config is complete", () => { + const guard = getRuntimeStartGuard(createSnapshot({ + binary_path: "D:/bin/localllm.exe", + config: { + connection_mode: "local", + start_policy: "manual", + server_url: "", + api_key: "", + model_path: "D:/models/demo.gguf", + models_dir: "D:/models", + addr: "127.0.0.1:8080", + gpu_layers: 24, + ctx_size: 8192, + extra_model_paths: [], + }, + })); + + expect(guard.canStart).toBe(true); + expect(guard.reason).toBe(""); + }); + + it("never offers managed start controls in remote mode", () => { + const guard = getRuntimeStartGuard(createSnapshot({ + binary_path: "D:/bin/localllm.exe", + config: { + connection_mode: "remote", + start_policy: "manual", + server_url: "https://api.example.com", + api_key: "sk-demo", + model_path: null, + models_dir: null, + addr: "127.0.0.1:8080", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + })); + + expect(guard.canStart).toBe(false); + expect(guard.reason.toLowerCase()).toContain("remote"); + }); +}); diff --git a/tauri-app/src/features/runtime/runtime-utils.ts b/tauri-app/src/features/runtime/runtime-utils.ts new file mode 100644 index 0000000..887c552 --- /dev/null +++ b/tauri-app/src/features/runtime/runtime-utils.ts @@ -0,0 +1,119 @@ +import type { RuntimeConnection, RuntimeSnapshot } from "./types"; + +const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080"; + +function normalizeUrl(value: string | null | undefined): string { + const trimmed = (value || "").trim(); + return trimmed ? trimmed.replace(/\/+$/, "") : ""; +} + +export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection { + if (snapshot.config.connection_mode === "remote") { + return { + mode: "remote", + serverUrl: normalizeUrl(snapshot.config.server_url), + apiKey: snapshot.config.api_key || "", + managed: false, + }; + } + + return { + mode: "local", + serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL, + apiKey: snapshot.config.api_key || "", + managed: true, + }; +} + +export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string { + if (snapshot.config.connection_mode === "remote") { + return "Remote API"; + } + + switch (snapshot.status) { + case "ready": + return "Ready"; + case "starting": + return "Starting local API"; + case "stopping": + return "Stopping local API"; + case "error": + return "Local API error"; + case "degraded": + return "Attention needed"; + default: + return "Local API offline"; + } +} + +export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" { + if (snapshot.config.connection_mode === "remote") { + return "neutral"; + } + + switch (snapshot.status) { + case "ready": + return "success"; + case "starting": + case "stopping": + case "degraded": + return "warning"; + case "error": + return "danger"; + default: + return "neutral"; + } +} + +export interface RuntimeStartGuard { + canStart: boolean; + reason: string; +} + +export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard { + if (snapshot.config.connection_mode === "remote") { + return { + canStart: false, + reason: "Remote mode uses an external API and does not launch the local runtime.", + }; + } + + if (!snapshot.binary_path) { + return { + canStart: false, + reason: "Build or bundle the local API binary before starting the managed runtime.", + }; + } + + if (!snapshot.config.model_path?.trim()) { + return { + canStart: false, + reason: "Choose a local model before starting the managed runtime.", + }; + } + + return { canStart: true, reason: "" }; +} + +export function createDefaultRuntimeSnapshot(): RuntimeSnapshot { + return { + status: "stopped", + ready: false, + process_running: false, + resolved_url: DEFAULT_LOCAL_URL, + binary_path: null, + last_error: null, + config: { + connection_mode: "local", + start_policy: "manual", + server_url: "", + api_key: "", + model_path: null, + models_dir: null, + addr: "127.0.0.1:8080", + gpu_layers: 0, + ctx_size: 4096, + extra_model_paths: [], + }, + }; +} diff --git a/tauri-app/src/features/runtime/types.ts b/tauri-app/src/features/runtime/types.ts new file mode 100644 index 0000000..3e3f39f --- /dev/null +++ b/tauri-app/src/features/runtime/types.ts @@ -0,0 +1,54 @@ +export type ConnectionMode = "local" | "remote"; +export type StartPolicy = "manual" | "on_app_launch"; +export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error"; + +export interface RuntimeConfig { + connection_mode: ConnectionMode; + start_policy: StartPolicy; + server_url: string; + api_key: string; + model_path: string | null; + models_dir: string | null; + addr: string; + gpu_layers: number; + ctx_size: number; + extra_model_paths: string[]; +} + +export interface RuntimeErrorInfo { + code: string; + message: string; +} + +export interface RuntimeSnapshot { + status: RuntimeStatus; + ready: boolean; + process_running: boolean; + resolved_url: string; + binary_path: string | null; + last_error: RuntimeErrorInfo | null; + config: RuntimeConfig; +} + +export interface RuntimeConnection { + mode: ConnectionMode; + serverUrl: string; + apiKey: string; + managed: boolean; +} + +export interface RuntimeModelFile { + name: string; + path: string; +} + +export interface RuntimePickDirResult { + dir: string; + models: RuntimeModelFile[]; +} + +export interface RuntimeGpuInfo { + name: string; + vram_mib: number; + recommended_layers: number; +} diff --git a/tauri-app/src/features/runtime/useRuntimeManager.ts b/tauri-app/src/features/runtime/useRuntimeManager.ts new file mode 100644 index 0000000..08ccb22 --- /dev/null +++ b/tauri-app/src/features/runtime/useRuntimeManager.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, startRuntime, stopRuntime } from "./api"; +import { createDefaultRuntimeSnapshot, getRuntimeConnection } from "./runtime-utils"; +import type { RuntimeConfig, RuntimeSnapshot } from "./types"; + +export function useRuntimeManager() { + const [snapshot, setSnapshot] = useState(createDefaultRuntimeSnapshot()); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + const next = await getRuntimeSnapshot(); + setSnapshot(next); + return next; + }, []); + + useEffect(() => { + let active = true; + + bootstrapRuntime() + .then((next) => { + if (active) { + setSnapshot(next); + setLoading(false); + } + }) + .catch(() => { + if (active) { + setLoading(false); + } + }); + + const id = window.setInterval(() => { + refresh().catch(() => undefined); + }, 3000); + + return () => { + active = false; + window.clearInterval(id); + }; + }, [refresh]); + + const saveConfig = useCallback(async (patch: Partial) => { + setBusy(true); + try { + const next = await saveRuntimeConfig({ ...snapshot.config, ...patch }); + setSnapshot(next); + return next; + } finally { + setBusy(false); + } + }, [snapshot.config]); + + const startLocal = useCallback(async () => { + setBusy(true); + setSnapshot((current) => ({ + ...current, + status: "starting", + process_running: true, + ready: false, + last_error: null, + })); + try { + const next = await startRuntime(); + setSnapshot(next); + return next; + } finally { + setBusy(false); + } + }, []); + + const stopLocal = useCallback(async () => { + setBusy(true); + setSnapshot((current) => ({ + ...current, + status: "stopping", + ready: false, + last_error: null, + })); + try { + const next = await stopRuntime(); + setSnapshot(next); + return next; + } finally { + setBusy(false); + } + }, []); + + const connection = useMemo(() => getRuntimeConnection(snapshot), [snapshot]); + + return { + snapshot, + connection, + loading, + busy, + refresh, + saveConfig, + startLocal, + stopLocal, + }; +} diff --git a/tauri-app/src/hooks/useModels.ts b/tauri-app/src/hooks/useModels.ts index b5af774..28733b2 100644 --- a/tauri-app/src/hooks/useModels.ts +++ b/tauri-app/src/hooks/useModels.ts @@ -1,26 +1,40 @@ import { useState, useEffect, useCallback } from "react"; import type { ModelInfo, ServerStatus } from "../lib/types"; -export function useModels(server: string, apiKey: string) { +export function useModels(server: string, apiKey: string, enabled = true) { const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); const [status, setStatus] = useState("loading"); const refresh = useCallback(async () => { + if (!enabled || !server.trim()) { + setModels([]); + setStatus("error"); + return; + } + setStatus("loading"); try { const headers: Record = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(`${server}/v1/models`, { headers }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } const data = await res.json(); const list: ModelInfo[] = data.data || []; setModels(list); - if (list.length > 0 && !selectedModel) setSelectedModel(list[0].id); + if (list.length === 0) { + setSelectedModel(""); + } else if (!selectedModel || !list.some((item) => item.id === selectedModel)) { + setSelectedModel(list[0].id); + } setStatus("ok"); } catch { + setModels([]); setStatus("error"); } - }, [server, apiKey, selectedModel]); + }, [enabled, server, apiKey, selectedModel]); useEffect(() => { refresh(); diff --git a/tauri-app/src/index.css b/tauri-app/src/index.css index 88a3b7b..d1e7973 100644 --- a/tauri-app/src/index.css +++ b/tauri-app/src/index.css @@ -2,23 +2,23 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --bg: #0c0c0e; - --bg2: #141416; - --bg3: #1c1c1f; - --bg4: #242428; - --bg5: #2c2c31; - --border: #2a2a30; - --border2: #38383f; - --text: #e8e8ed; - --text2: #8e8e99; - --text3: #5a5a66; - --accent: #5b6af0; - --accent2: #4a58e0; - --accent-glow: rgba(91,106,240,0.15); - --user-bg: #1a2040; - --user-border: #2a3560; - --asst-bg: #141416; - --asst-border: #2a2a30; + --bg: #0a1016; + --bg2: #0f1721; + --bg3: #14202d; + --bg4: #182636; + --bg5: #203246; + --border: #243241; + --border2: #344759; + --text: #edf5ff; + --text2: #93a8bc; + --text3: #5d7288; + --accent: #48a6ff; + --accent2: #2f8ae3; + --accent-glow: rgba(72,166,255,0.16); + --user-bg: #10263f; + --user-border: #214468; + --asst-bg: #101924; + --asst-border: #243241; --danger: #f04040; --success: #30d158; --warning: #ffd60a; @@ -36,7 +36,7 @@ html, body, #root { overflow: hidden; background: var(--bg); color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-family: "Segoe UI Variable", "SF Pro Display", "IBM Plex Sans", "Inter", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }