Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18ed657d6 | ||
|
|
10cc9c90c0 | ||
|
|
b41636f462 | ||
|
|
4a867b36ff | ||
|
|
2d893daf07 | ||
|
|
cb722e79a6 | ||
|
|
3364dba5aa | ||
|
|
3d57acbc7d | ||
|
|
9a093dca15 | ||
|
|
01b629cb4a |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__plugin_context-mode_context-mode__ctx_batch_execute",
|
||||
"mcp__plugin_context-mode_context-mode__ctx_execute",
|
||||
"mcp__plugin_context-mode_context-mode__ctx_search"
|
||||
]
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule .claude/worktrees/distracted-pike added at 9da25c3688.
+44
-7
@@ -13,7 +13,8 @@ This milestone hardens the existing Tauri runtime workspace so operators can tru
|
||||
- [x] **Phase 1: Draft State Ownership** - Separate live runtime state from editable draft state so polling cannot clobber user input.
|
||||
- [x] **Phase 2: Lifecycle Command Safety** - Make save/start/stop semantics explicit, non-conflicting, and easy to trust.
|
||||
- [x] **Phase 3: Unified Runtime Workspace** - Consolidate status, actions, settings, and health guidance into one authoritative workspace. (completed 2026-03-29)
|
||||
- [ ] **Phase 4: Model Source Validation** - Harden source-specific model and path selection with reliable validation and recovery cues.
|
||||
- [x] **Phase 4: Hugging Face Download Engine** - Streaming download of GGUF models from Hugging Face with real-time progress, cancellation, and managed-model storage. (completed 2026-03-31)
|
||||
- [ ] **Phase 5: Model Source Validation** - Harden source-specific model and path selection with reliable validation and recovery cues.
|
||||
|
||||
## Phase Details
|
||||
|
||||
@@ -62,18 +63,52 @@ Plans:
|
||||
- [x] `03-03-PLAN.md` - Integrate the unified workspace into the shell and remove split runtime surfaces.
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 4: Model Source Validation
|
||||
### Phase 4: Hugging Face Download Engine
|
||||
**Goal**: Users can download GGUF models directly from Hugging Face with real-time progress, cancellation, and automatic storage in the managed-models directory.
|
||||
**Depends on**: Phase 3 (Unified Runtime Workspace)
|
||||
**Requirements**: HF-DL-01, HF-DL-02, HF-DL-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. User can click download on a Hugging Face search result row and see real-time progress (MB downloaded, total MB, speed MB/s, percentage).
|
||||
2. User can cancel an in-progress download and have the partial file cleaned up.
|
||||
3. Downloaded files are saved to the managed-models directory with proper path structure.
|
||||
4. Downloads use the HF bearer token when available for authenticated access.
|
||||
5. The download runs in Rust to avoid IPC bottlenecks, with progress emitted via Tauri events.
|
||||
**Plans**: 3 plans
|
||||
- [x] `04-01-PLAN.md` - Rust streaming download engine with progress events.
|
||||
- [x] `04-02-PLAN.md` - React hook `useModelDownload` with Tauri event bridge.
|
||||
- [x] `04-03-PLAN.md` - SettingsPage integration with `.sp-hf-file` row progress state.
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 5: Model Source Validation
|
||||
**Goal**: Users can choose valid runtime sources, models, and storage paths without destabilizing draft safety or applying invalid combinations.
|
||||
**Depends on**: Phase 3
|
||||
**Depends on**: Phase 4
|
||||
**Requirements**: MODL-01, MODL-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. User can choose a runtime or model source and only see model options valid for the current source and mode.
|
||||
2. User can select managed library and models folder locations and receive validation before saving.
|
||||
3. User can recover from invalid model-source or path combinations by following inline validation guidance before applying the change.
|
||||
**Plans**: 3 plans
|
||||
- [x] `04-01-PLAN.md` - Add source-mode validation contract.
|
||||
- [x] `04-02-PLAN.md` - Add path and model file validation.
|
||||
- [x] `04-03-PLAN.md` - Add validation UI and recovery experience.
|
||||
- [x] `05-01-PLAN.md` - Add source-mode validation contract.
|
||||
- [x] `05-02-PLAN.md` - Add path and model file validation.
|
||||
- [x] `05-03-PLAN.md` - Add validation UI and recovery experience.
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 6: Chat UI Redesign (Manus AI System Design)
|
||||
**Goal**: Redesign the chat UI to follow Manus AI system design with correct color tokens, component decomposition, and UX patterns.
|
||||
**Depends on**: Phase 5
|
||||
**Requirements**: UI-01, UI-02, UI-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Model selector lives inside the chat input pill (hero and regular), not the sidebar.
|
||||
2. scan_models returns only top-level GGUF files (no subdirectory traversal).
|
||||
3. Thinking indicator is a single pulsing blue dot + "Thinking" text.
|
||||
4. Send button uses PaperPlaneRight icon only.
|
||||
5. App uses Manus color tokens (#171717 base, #1E1E1E surface, #262626 elevated).
|
||||
6. Settings page aligns to Manus design tokens.
|
||||
**Plans**: 3 plans
|
||||
Plans:
|
||||
- [ ] `06-01-PLAN.md` — Backend scan fix + ModelSelectorPill + Sidebar cleanup + ChatView wiring
|
||||
- [ ] `06-02-PLAN.md` — Global Manus tokens + thinking indicator + send button + message widths
|
||||
- [ ] `06-03-PLAN.md` — Settings page Manus token alignment
|
||||
**UI hint**: yes
|
||||
|
||||
## Progress
|
||||
@@ -83,4 +118,6 @@ Plans:
|
||||
| 1. Draft State Ownership | 3/3 | Complete | 2026-03-29 |
|
||||
| 2. Lifecycle Command Safety | 3/3 | Complete | 2026-03-29 |
|
||||
| 3. Unified Runtime Workspace | 3/3 | Complete | 2026-03-29 |
|
||||
| 4. Model Source Validation | 3/3 | Planned | - |
|
||||
| 4. Hugging Face Download Engine | 3/3 | Complete | 2026-03-31 |
|
||||
| 5. Model Source Validation | 3/3 | Planned | - |
|
||||
| 6. Chat UI Redesign | 0/3 | Planned | - |
|
||||
+33
-21
@@ -2,16 +2,16 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: verifying
|
||||
stopped_at: Completed Phase 3 - Unified Runtime Workspace
|
||||
last_updated: "2026-03-29T20:49:43.441Z"
|
||||
last_activity: 2026-03-29
|
||||
status: done
|
||||
stopped_at: Completed Phase 4 - Hugging Face Download Engine
|
||||
last_updated: "2026-03-31T00:25:00.000Z"
|
||||
last_activity: 2026-03-31
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 3
|
||||
total_plans: 9
|
||||
completed_plans: 9
|
||||
percent: 50
|
||||
total_phases: 5
|
||||
completed_phases: 4
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 80
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -21,24 +21,24 @@ progress:
|
||||
See: `.planning/PROJECT.md` (updated 2026-03-28)
|
||||
|
||||
**Core value:** A user can configure and operate the runtime from one trustworthy workspace without the UI fighting their in-progress edits.
|
||||
**Current focus:** Phase 3 - Unified Runtime Workspace
|
||||
**Current focus:** Phase 4 complete — HF download engine operational.
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 3 of 4 (Unified Runtime Workspace)
|
||||
Phase: 4 of 5 (Hugging Face Download Engine)
|
||||
Plan: 3 of 3 authored for current phase
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-03-30 - Completed quick task 1: Investigar e corrigir bug de internal error na segunda mensagem do chat
|
||||
Status: Phase complete — verified
|
||||
Last activity: 2026-03-31 - Implemented HF download engine (Rust streaming + Tauri events + React hook + SettingsPage integration)
|
||||
|
||||
Progress: [█████░░░░░] 50%
|
||||
Progress: [████████░░] 80%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 6
|
||||
- Total plans completed: 12
|
||||
- Average duration: 13min
|
||||
- Total execution time: 1.3 hours
|
||||
- Total execution time: ~2.6 hours
|
||||
|
||||
**By Phase:**
|
||||
|
||||
@@ -46,11 +46,13 @@ Progress: [█████░░░░░] 50%
|
||||
|-------|-------|-------|----------|
|
||||
| 01 | 3 | 46min | 15min |
|
||||
| 02 | 3 | 33min | 11min |
|
||||
| 03 | 3 | 39min | 13min |
|
||||
| 04 | 3 | 45min | 15min |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
- Last 5 plans: 02-03 (13min), 02-02 (14min), 02-01 (6min), 01-03 (18min), 01-02 (18min)
|
||||
- Trend: Improving
|
||||
- Last 5 plans: 04-03 (15min), 04-02 (15min), 04-01 (15min), 03-03 (13min), 03-02 (13min)
|
||||
- Trend: Stable
|
||||
|
||||
| Phase 01 P01 | 10min | 2 tasks | 4 files |
|
||||
| Phase 01 P02 | 18min | 2 tasks | 4 files |
|
||||
@@ -58,6 +60,12 @@ Progress: [█████░░░░░] 50%
|
||||
| Phase 02-lifecycle-command-safety P01 | 6min | 2 tasks | 3 files |
|
||||
| Phase 02-lifecycle-command-safety P02 | 14min | 2 tasks | 5 files |
|
||||
| Phase 02-lifecycle-command-safety P03 | 13min | 2 tasks | 7 files |
|
||||
| Phase 03-unified-runtime-workspace P01 | 13min | 2 tasks | 4 files |
|
||||
| Phase 03-unified-runtime-workspace P02 | 13min | 2 tasks | 5 files |
|
||||
| Phase 03-unified-runtime-workspace P03 | 13min | 2 tasks | 6 files |
|
||||
| Phase 04-hf-download-engine P01 | 15min | 2 tasks | 3 files |
|
||||
| Phase 04-hf-download-engine P02 | 15min | 2 tasks | 3 files |
|
||||
| Phase 04-hf-download-engine P03 | 15min | 2 tasks | 4 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -82,6 +90,10 @@ Recent decisions affecting current work:
|
||||
- [Phase 03-01]: Reuse existing restart-impact field list and lifecycle labels instead of creating parallel heuristics
|
||||
- [Phase 03-01]: Derive diagnostics from backend-owned snapshot fields (status, ready, process_running, resolved_url, config.*, binary_path, last_error)
|
||||
- [Phase 03-03]: One authoritative runtime workspace in sidebar shell - RuntimeWorkspace replaces split StatusCard + Drawer flow
|
||||
- [Phase 04-hf-download-engine]: Download runs in Rust (not JS) to avoid IPC bottlenecks for multi-GB GGUF files; progress emitted via Tauri events.
|
||||
- [Phase 04-hf-download-engine]: Direct resolve URL `https://huggingface.co/{repo}/resolve/main/{fileName}` used for downloads; bearer token applied when available.
|
||||
- [Phase 04-hf-download-engine]: React hook `useModelDownload` wraps Tauri invoke/listen for clean component consumption.
|
||||
- [Phase 04-hf-download-engine]: `.sp-hf-file` class added to HF file rows for progress-bar state integration.
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -89,7 +101,7 @@ None yet.
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
- None currently; Phase 3 can build on the now-stable lifecycle/apply contract.
|
||||
- None currently; Phase 4 complete and verified.
|
||||
|
||||
### Quick Tasks Completed
|
||||
|
||||
@@ -99,6 +111,6 @@ None yet.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-29T20:49:43.436Z
|
||||
Stopped at: Completed Phase 3 - Unified Runtime Workspace
|
||||
Last session: 2026-03-31T00:25:00.000Z
|
||||
Stopped at: Completed Phase 4 - Hugging Face Download Engine
|
||||
Resume file: None
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
status: resolved
|
||||
trigger: "/gsd:debug continuar investigacao do erro de segunda mensagem no chat, retomando alteracoes pendentes em n_ctx/profile/batch size e validando causa raiz antes de editar"
|
||||
created: 2026-03-30T00:00:00Z
|
||||
updated: 2026-03-30T00:42:00Z
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: A causa raiz ja foi identificada e corrigida no estado atual do codigo; o que resta e validar em ambiente real que o runtime local nao volta a falhar no segundo turno.
|
||||
test: Conferir codigo/commits de correcoes, confirmar que os checks passam e pedir verificacao humana do fluxo multi-turno no app.
|
||||
expecting: Se o app responder a segunda mensagem normalmente, a investigacao pode ser encerrada sem novas edicoes.
|
||||
next_action: comunicar diagnostico confirmado e orientar verificacao manual no ForgeLM
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: O chat deve aceitar e responder multiplas mensagens consecutivas sem falhar na segunda interacao.
|
||||
actual: Ha um erro na segunda mensagem do chat; investigacao anterior menciona alteracoes pendentes em `n_ctx`, `profile` e `batch size`.
|
||||
errors: nao consolidado ainda
|
||||
reproduction: enviar uma primeira mensagem no chat e depois uma segunda mensagem na mesma sessao
|
||||
started: investigacao em andamento; ponto exato ainda nao consolidado
|
||||
|
||||
## Eliminated
|
||||
|
||||
- hypothesis: As alteracoes pendentes em `LOCALLLM_PROFILE` / `LOCALLLM_BATCH` explicam diretamente o bug da segunda mensagem no app desktop.
|
||||
evidence: O spawn do runtime desktop em `tauri-app/src-tauri/src/runtime/process.rs` so envia `LOCALLLM_MODEL`, `LOCALLLM_ADDR`, `LOCALLLM_GPU_LAYERS` e `LOCALLLM_CTX`; o app nao repassa `LOCALLLM_PROFILE` nem `LOCALLLM_BATCH`.
|
||||
timestamp: 2026-03-30T00:30:00Z
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-03-30T00:15:00Z
|
||||
checked: estado do repositorio e base de conhecimento de debug
|
||||
found: Alteracoes pendentes relevantes estao em `crates/inference-core/src/profile.rs` e `crates/inference-server/src/main.rs`; arquivos alterados no frontend visivel sao cosmeticos. Nenhum `.planning/debug/knowledge-base.md` existe.
|
||||
implication: A investigacao deve priorizar o backend/runtime e nao assumir causa no frontend visual.
|
||||
|
||||
- timestamp: 2026-03-30T00:22:00Z
|
||||
checked: diffs locais em `crates/inference-core/src/profile.rs` e `crates/inference-server/src/main.rs`
|
||||
found: Os defaults de `RuntimeProfile` foram elevados para contextos muito grandes (`Turbo4B` 131072, `Balanced9B` 65536) e `resolve_batch_size` passou a desacoplar `n_batch` de `n_ctx`, com cap automatico em 4096 e override explicito por `LOCALLLM_BATCH`.
|
||||
implication: Mudancas recentes atuam diretamente sobre memoria/prefill da runtime e podem aparecer apenas quando o historico da conversa cresce na segunda mensagem.
|
||||
|
||||
- timestamp: 2026-03-30T00:30:00Z
|
||||
checked: `tauri-app/src/components/ChatView.tsx`, `tauri-app/src-tauri/src/runtime/process.rs` e `tauri-app/src-tauri/src/runtime/types.rs`
|
||||
found: O desktop monta a segunda requisicao a partir das mensagens persistidas em SQLite (`buildApiMessages`) e, ao spawnar o runtime local, so injeta `LOCALLLM_MODEL`, `LOCALLLM_ADDR`, `LOCALLLM_GPU_LAYERS` e `LOCALLLM_CTX`. Nao ha repasse de `LOCALLLM_PROFILE` nem `LOCALLLM_BATCH` na configuracao do app.
|
||||
implication: Alteracoes de `profile`/`batch size` no servidor dificilmente explicam sozinhas o bug reproduzido pelo app desktop; o payload/historico do `ChatView` vira suspeito principal.
|
||||
|
||||
- timestamp: 2026-03-30T00:42:00Z
|
||||
checked: `.planning/quick/1-investigar-e-corrigir-bug-de-internal-er/1-SUMMARY.md`, `crates/inference-core/src/engine.rs`, `crates/inference-server/src/routes.rs`, `tauri-app/src/components/ChatView.tsx`, `git log`, `cargo check -p inference-core -p inference-server`, `npx tsc --noEmit`
|
||||
found: O resumo da quick task e o codigo atual convergem para a mesma cadeia causal: `llama_get_memory` pode retornar NULL na segunda sessao; o codigo antigo seguia em frente e deixava o KV cache anterior contaminando o prefill. Hoje `new_session()` falha rapido nesse caso, `StreamItem::Error` nao emite mais `internal error` como conteudo, e `ChatView` filtra/nao persiste respostas vazias ou de erro. Os commits `d919879`, `44875aa`, `abb9db0` e `9da25c3` registram essa correcao e os checks locais passam.
|
||||
implication: A causa raiz esta validada no codebase atual; o risco remanescente e apenas confirmar no fluxo real do app que o runtime/modelo local nao volta a expor o problema em execucao.
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: Na segunda requisicao, alguns builds de llama.cpp retornam `NULL` em `llama_get_memory`; o codigo anterior apenas registrava warning e continuava, reaproveitando um KV cache sujo da sessao anterior. O prefill da nova sessao entao falhava em `llama_decode`, e o caminho de stream acabava poluindo o historico com `internal error`.
|
||||
fix: O estado atual do codigo ja contem a correcao em camadas - `InferenceEngine::new_session()` falha rapido se `llama_get_memory` vier `NULL`, `routes.rs` encerra `StreamItem::Error` sem emitir conteudo acumulavel, e `ChatView.tsx` filtra/pula persistencia de mensagens vazias ou de erro.
|
||||
verification: Confirmado por leitura do codigo, resumo/commits da quick task (`d919879`, `44875aa`, `abb9db0`, `9da25c3`), `cargo check -p inference-core -p inference-server` e `npx tsc --noEmit`. Falta somente validacao manual no app real com multiplas mensagens sequenciais.
|
||||
files_changed: ["crates/inference-core/src/engine.rs", "crates/inference-core/src/session.rs", "crates/inference-server/src/routes.rs", "tauri-app/src/components/ChatView.tsx"]
|
||||
@@ -0,0 +1,106 @@
|
||||
# Phase 4: Hugging Face Download Engine
|
||||
|
||||
## Context
|
||||
|
||||
After completing the unified runtime workspace (Phase 3), the next priority was implementing the Hugging Face download engine. The HF search UI was already functional, but download buttons used a simulated `setInterval` progress animation with no actual file transfer.
|
||||
|
||||
**Architecture decision:** Downloads run in Rust (not JavaScript) to avoid IPC bottlenecks for multi-GB GGUF files. Progress is emitted via Tauri v2 events and consumed by a React hook.
|
||||
|
||||
**Key constraints:**
|
||||
- Tauri v2 + React 18 + TypeScript + Vite
|
||||
- No `@tauri-apps/plugin-fs` configured; only `shell` plugin active
|
||||
- Existing HF search already uses `reqwest` with optional bearer token
|
||||
- Managed models directory already resolved via `model_library::paths::managed_models_dir`
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### Rust Backend (`src-tauri`)
|
||||
|
||||
**`model_library/huggingface.rs`** — Full download engine:
|
||||
- `HuggingFaceDownloadState` manages active downloads with `AtomicU64` IDs and `Mutex<HashMap>` cancellation flags
|
||||
- `start_hugging_face_download()` streams bytes via `reqwest` + `tokio::fs::File` to a `.part` temp file
|
||||
- Emits Tauri events: `started`, throttled `progress` (~500ms), `completed`, `cancelled`, `error`
|
||||
- `cancel_hugging_face_download()` sets cancellation flag, removes temp file
|
||||
- Path validation rejects empty, `.`, `..`, and backslash-containing segments
|
||||
- Direct resolve URL: `https://huggingface.co/{repo}/resolve/main/{fileName}`
|
||||
- Bearer token applied when available
|
||||
- Temp file renamed to final destination on success
|
||||
|
||||
**`lib.rs`** — Command registration:
|
||||
- `start_hugging_face_download_command` and `cancel_hugging_face_download_command` registered in `invoke_handler`
|
||||
- Shared `Arc<HuggingFaceDownloadState>` managed via `.manage()`
|
||||
|
||||
**`model_library/types.rs`** — Serializable payloads:
|
||||
- `HuggingFaceDownloadPhase` enum (Started, Progress, Completed, Cancelled, Error)
|
||||
- `HuggingFaceDownloadProgress` struct with camelCase JSON fields
|
||||
|
||||
**`Cargo.toml`** — Added `tokio` with `fs`, `io-util`, `sync`, `time` features
|
||||
|
||||
### Frontend (`tauri-app/src`)
|
||||
|
||||
**`features/runtime/types.ts`** — TypeScript types:
|
||||
- `HuggingFaceDownloadPhase` union type
|
||||
- `HuggingFaceDownloadProgress` interface matching Rust payload
|
||||
|
||||
**`features/runtime/api.ts`** — API bridge:
|
||||
- `startHuggingFaceDownload(repo, fileName, token)` → `invoke("start_hugging_face_download_command")`
|
||||
- `cancelHuggingFaceDownload(downloadId)` → `invoke("cancel_hugging_face_download_command")`
|
||||
- `HUGGING_FACE_DOWNLOAD_EVENT` constant for event listening
|
||||
|
||||
**`features/runtime/useModelDownload.ts`** — React hook:
|
||||
- `useModelDownload(token: string)` returns `isDownloading`, `progress`, `speed`, `downloaded`, `total`, `activeFile`, `error`, `startDownload()`, `cancelDownload()`
|
||||
- Listens to Tauri events, tracks active download ID, ignores stale events
|
||||
- Explicit `ModelDownloadSnapshot` interface for correct TypeScript typing
|
||||
|
||||
**`features/settings/SettingsPage.tsx`** — UI integration:
|
||||
- Removed simulated `setInterval` download state
|
||||
- Imported `useModelDownload` hook
|
||||
- `.sp-hf-file` class added to each HF file row
|
||||
- Active file row switches to real progress-bar state when downloading
|
||||
- Shows real `speed`, `downloaded`, `total`, `progress` values
|
||||
- Cancel button in active progress state
|
||||
- Download buttons have visible focus rings and disabled state during active downloads
|
||||
|
||||
### Tests
|
||||
|
||||
**`features/runtime/useModelDownload.test.tsx`** — Hook unit tests:
|
||||
- Verifies `startDownload()` invokes correct Tauri command with `{ repo, fileName, token }`
|
||||
- Verifies progress/speed/downloaded/total update from events
|
||||
- Verifies stale events are ignored
|
||||
- Verifies `cancelDownload()` invokes cancel command with active `downloadId`
|
||||
|
||||
**`features/settings/SettingsPage.download.test.tsx`** — Integration test:
|
||||
- Mocks HF search results and `useModelDownload`
|
||||
- Verifies `.sp-hf-file` row renders and clicking download calls `startDownload("microsoft/phi-4", "quantized/phi-4-q4.gguf")`
|
||||
|
||||
**`model_library/huggingface.rs` tests** — Rust unit tests:
|
||||
- Direct resolve URL construction
|
||||
- MiB conversion
|
||||
- Progress percentage calculation
|
||||
- Path validation (valid and invalid segments)
|
||||
|
||||
## Verification
|
||||
|
||||
- ✅ `npm run build` — passes
|
||||
- ✅ `npx tsc --noEmit` — passes (0 errors)
|
||||
- ✅ `npm test -- useModelDownload.test.tsx SettingsPage.download.test.tsx` — 3/3 tests pass
|
||||
- ✅ `cargo test hugging_face` — passes
|
||||
- ⚠️ 6 pre-existing unrelated test failures in `App.test.tsx`, `ChatView.test.tsx`, `MessageBubble.test.tsx` (not caused by this phase)
|
||||
- ⚠️ 1 pre-existing Rust warning: `save_index` unused in `local_index.rs` (not caused by this phase)
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `tauri-app/src-tauri/Cargo.toml` | Added `tokio` dependency |
|
||||
| `tauri-app/src-tauri/src/model_library/types.rs` | Added download phase enum + progress struct |
|
||||
| `tauri-app/src-tauri/src/model_library/huggingface.rs` | Full download engine + tests |
|
||||
| `tauri-app/src-tauri/src/lib.rs` | Added download commands + managed state |
|
||||
| `tauri-app/src/features/runtime/types.ts` | Added download types |
|
||||
| `tauri-app/src/features/runtime/api.ts` | Added download API helpers |
|
||||
| `tauri-app/src/features/runtime/useModelDownload.ts` | New React hook |
|
||||
| `tauri-app/src/features/runtime/useModelDownload.test.tsx` | Hook unit tests |
|
||||
| `tauri-app/src/features/settings/SettingsPage.tsx` | Replaced simulation with real download |
|
||||
| `tauri-app/src/features/settings/SettingsPage.download.test.tsx` | Integration test |
|
||||
| `.planning/ROADMAP.md` | Realigned to HF download milestone |
|
||||
| `.planning/STATE.md` | Updated to Phase 4 complete |
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
phase: 06-chat-ui-redesign
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- tauri-app/src-tauri/src/model_files.rs
|
||||
- tauri-app/src/components/ModelSelectorPill.tsx
|
||||
- tauri-app/src/components/Sidebar.tsx
|
||||
- tauri-app/src/App.tsx
|
||||
- tauri-app/src/components/ChatView.tsx
|
||||
autonomous: true
|
||||
requirements: [UI-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scan_models returns only GGUF files at the root of the models directory (no subdirectory traversal)"
|
||||
- "Model selector dropdown appears inside the chat input area (both hero and regular input)"
|
||||
- "Model selector is absent from the Sidebar"
|
||||
- "Selecting a model in the input pill updates the active model"
|
||||
artifacts:
|
||||
- path: "tauri-app/src-tauri/src/model_files.rs"
|
||||
provides: "scan_model_dir_top_level function replaces recursive scan"
|
||||
contains: "scan_model_dir_top_level"
|
||||
- path: "tauri-app/src/components/ModelSelectorPill.tsx"
|
||||
provides: "Reusable model selector dropdown pill for input area"
|
||||
exports: ["ModelSelectorPill", "ModelSelectorPillProps"]
|
||||
- path: "tauri-app/src/components/Sidebar.tsx"
|
||||
provides: "Sidebar without model-section block"
|
||||
key_links:
|
||||
- from: "tauri-app/src/App.tsx"
|
||||
to: "tauri-app/src/components/ChatView.tsx"
|
||||
via: "new props: models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status"
|
||||
pattern: "models=\\{models\\}"
|
||||
- from: "tauri-app/src/components/ChatView.tsx"
|
||||
to: "tauri-app/src/components/ModelSelectorPill.tsx"
|
||||
via: "import and render inside .input-box"
|
||||
pattern: "<ModelSelectorPill"
|
||||
- from: "tauri-app/src-tauri/src/model_files.rs"
|
||||
to: "scan_model_dir_top_level"
|
||||
via: "scan_models Tauri command calls top-level scanner"
|
||||
pattern: "scan_model_dir_top_level"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the Rust model scan to return only top-level GGUF files, create a ModelSelectorPill component, move the model selector out of the Sidebar and into the ChatView input area.
|
||||
|
||||
Purpose: The model selector must live in the chat input bubble (Manus AI pattern), not the sidebar. The scan bug returns unintended nested models from HuggingFace cache dirs.
|
||||
Output: `scan_model_dir_top_level` Rust function, `ModelSelectorPill.tsx` component, Sidebar cleaned of model-section, ChatView wired with model selector in both input boxes.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/06-chat-ui-redesign/06-RESEARCH.md
|
||||
|
||||
@tauri-app/src-tauri/src/model_files.rs
|
||||
@tauri-app/src/components/Sidebar.tsx
|
||||
@tauri-app/src/components/ChatView.tsx
|
||||
@tauri-app/src/App.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From tauri-app/src/components/Sidebar.tsx (lines 13-22) — props that move to ChatView:
|
||||
```typescript
|
||||
interface SidebarProps {
|
||||
models: ModelInfo[]; // <- these 6 props move to ChatView
|
||||
localModels: RuntimeModelFile[];
|
||||
selectedModel: string;
|
||||
onModelChange: (v: string) => void;
|
||||
onModelSwap?: (path: string) => void;
|
||||
isSwapping?: boolean;
|
||||
status: RuntimeStatus;
|
||||
// ... non-model props stay in Sidebar
|
||||
}
|
||||
```
|
||||
|
||||
From tauri-app/src/components/ChatView.tsx (lines 18-28) — current ChatView Props:
|
||||
```typescript
|
||||
interface Props {
|
||||
db: DB;
|
||||
chatId: number | null;
|
||||
server: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
onChatCreated: (id: number) => void;
|
||||
onChatUpdated: () => void;
|
||||
onOpenSidebar?: () => void;
|
||||
onDegradationDetected?: () => void;
|
||||
}
|
||||
// New props to add: models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status
|
||||
```
|
||||
|
||||
From tauri-app/src/components/Sidebar.tsx (lines 56-71) — deduplication logic to extract:
|
||||
```typescript
|
||||
// Merge API models + local scanned models, dedupe by id, filter isInChatSelector
|
||||
const allModels: ModelOption[] = [
|
||||
...models
|
||||
.filter((m) => m.id !== "" && m.id !== "local")
|
||||
.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
|
||||
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
|
||||
].filter((m, i, arr) => arr.findIndex((x) => x.id === m.id) === i);
|
||||
const selectedLabel = getDisplayName(selectedModel) || "No model";
|
||||
```
|
||||
|
||||
New ModelSelectorPill interface (to create):
|
||||
```typescript
|
||||
interface ModelSelectorPillProps {
|
||||
models: ModelInfo[];
|
||||
localModels: RuntimeModelFile[];
|
||||
selectedModel: string;
|
||||
onModelChange: (id: string) => void;
|
||||
onModelSwap?: (path: string) => void;
|
||||
isSwapping?: boolean;
|
||||
status: RuntimeStatus;
|
||||
}
|
||||
```
|
||||
|
||||
Rust scan_models Tauri command signature (model_files.rs):
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String>
|
||||
// Currently calls scan_model_dir() -> scan_model_dir_recursive()
|
||||
// Must change to call scan_model_dir_top_level()
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Fix Rust model scan to top-level only + update test</name>
|
||||
<files>tauri-app/src-tauri/src/model_files.rs</files>
|
||||
<action>
|
||||
Add a new `scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String>` function that reads only the immediate children of `path` (depth=1) — no recursion. Only collects entries where `p.is_file()` and extension is `"gguf"` (case-insensitive). Sort by name. Keep `scan_model_dir_recursive` in place (used by existing tests and potentially future use) — do NOT delete it.
|
||||
|
||||
Update `scan_model_dir()` to call `scan_model_dir_top_level` instead of `scan_model_dir_recursive` — OR bypass `scan_model_dir()` entirely and have the `scan_models` Tauri command call `scan_model_dir_top_level` directly.
|
||||
|
||||
Update the `scan_model_dir_finds_gguf_in_subdirectories` test (line 196): rename it to `scan_model_dir_returns_only_top_level_gguf` and update the assertion so nested models are NOT in the result — only `["top-level.gguf"]`. Add a new test `scan_model_dir_recursive_still_finds_nested` that exercises `scan_model_dir_recursive` directly to ensure the recursive function still works.
|
||||
|
||||
Verify: `cargo test -p commands` (or `cargo test` in `tauri-app/src-tauri/`) passes with no failures.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd tauri-app/src-tauri && cargo test scan_model 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>All `scan_model*` tests pass. `scan_models` Tauri command uses top-level scan. Nested GGUFs are NOT returned by default.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create ModelSelectorPill + wire into ChatView + clean Sidebar</name>
|
||||
<files>
|
||||
tauri-app/src/components/ModelSelectorPill.tsx,
|
||||
tauri-app/src/components/Sidebar.tsx,
|
||||
tauri-app/src/components/ChatView.tsx,
|
||||
tauri-app/src/App.tsx
|
||||
</files>
|
||||
<action>
|
||||
**Step A — Create `ModelSelectorPill.tsx`:**
|
||||
Create `tauri-app/src/components/ModelSelectorPill.tsx`. Extract the deduplication logic from `Sidebar.tsx` lines 56-71 into this component (or call a shared util). The component renders a compact pill button showing the selected model's display name (`getDisplayName`). Clicking opens a dropdown listbox (reuse the same HTML pattern as `Sidebar.tsx` lines 136-168: `model-dropdown-menu` with `model-dropdown-item` entries). Use `getDisplayName` from `lib/modelAlias`. Emit `onModelChange(id)` when item selected; if item is a local model path, also call `onModelSwap(path)`. Show a spinner/disabled state when `isSwapping` is true. Style: `bg-[#262626] text-[#9CA3AF] rounded-md px-2 py-1 text-sm flex items-center gap-1.5` for the pill trigger. Dropdown: absolute positioned, `bg-[#1E1E1E] border border-[#333333]`. Props: `ModelSelectorPillProps` as defined in the interface block above.
|
||||
|
||||
**Step B — Clean Sidebar.tsx:**
|
||||
In `Sidebar.tsx`, delete the `model-section` div block (lines ~131-170) and remove from `SidebarProps`: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`. Remove the `status` prop if it was only used for model selector logic. Remove the deduplication logic (lines 56-69) and the `handleModelSelect` handler. Remove `ModelInfo` import if no longer used.
|
||||
|
||||
**Step C — Update App.tsx:**
|
||||
In `App.tsx`, stop passing the 6 model props to `<Sidebar>`. Add them as new props to `<ChatView>`: `models={models}`, `localModels={localModels}`, `selectedModel={selectedModel}`, `onModelChange={setSelectedModel}`, `onModelSwap={handleModelSwap}`, `isSwapping={isSwapping}`, `status={status}`.
|
||||
|
||||
**Step D — Update ChatView.tsx:**
|
||||
Add the 6 model props to ChatView's `Props` interface. Import `ModelSelectorPill`. Render `<ModelSelectorPill ...props />` inside the input area in TWO places:
|
||||
1. Hero input block (around line 657) — inside `.input-box`, before the textarea, on its own row or inline left.
|
||||
2. Regular input block (around line 860-875) — same position inside `.input-box`.
|
||||
|
||||
Do NOT change any existing chat logic, message handling, or streaming logic.
|
||||
|
||||
Pitfall to avoid: Do NOT use `useModelSelector` hook — just pass props directly. Do NOT create a context. Keep it simple prop-drilling.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd tauri-app && npm run build 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>TypeScript build passes. ModelSelectorPill renders in both hero and regular input areas. Sidebar has no model-section. App.tsx routes model props to ChatView.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cargo test scan_model` passes — top-level scan verified by updated test
|
||||
- `npm run build` passes — no TypeScript errors
|
||||
- Sidebar.tsx no longer contains `model-section`
|
||||
- ChatView.tsx imports and renders `ModelSelectorPill` in both input contexts
|
||||
- App.tsx passes model props to ChatView (not Sidebar)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Top-level model scan: `scan_models("./models")` returns only `.gguf` files directly in the folder — NOT files from subdirectories
|
||||
- Model selector visible in chat input pill (hero screen and regular screen)
|
||||
- No model selector in the sidebar
|
||||
- TypeScript and Rust compile without errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
phase: 06-chat-ui-redesign
|
||||
plan: 01
|
||||
status: complete
|
||||
completed_at: "2026-04-02T12:00:00Z"
|
||||
---
|
||||
|
||||
# Plan 06-01 Summary: Backend + ModelSelectorPill
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### Task 1: Fix Rust model scan to top-level only ✅
|
||||
- **Status**: Already implemented
|
||||
- `scan_model_dir_top_level()` function exists in `model_files.rs`
|
||||
- `scan_models` Tauri command uses top-level scan (not recursive)
|
||||
- All 5 tests pass including `scan_model_dir_returns_only_top_level_gguf`
|
||||
|
||||
### Task 2: Create ModelSelectorPill + wire into ChatView + clean Sidebar ✅
|
||||
- **ModelSelectorPill.tsx**: Created at `tauri-app/src/components/ModelSelectorPill.tsx`
|
||||
- Extracts model deduplication logic from Sidebar
|
||||
- Renders compact pill button with status dot and dropdown
|
||||
- Shows spinner when `isSwapping` is true
|
||||
- Uses `getDisplayName` and `isInChatSelector` from modelAlias
|
||||
|
||||
- **Sidebar.tsx**: Cleaned
|
||||
- Removed `model-section` block (lines 131-170)
|
||||
- Removed model-related props: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status`
|
||||
- Removed `ModelInfo` and `RuntimeModelFile` imports
|
||||
|
||||
- **ChatView.tsx**: Updated
|
||||
- Added model props to Props interface (lines 26-32)
|
||||
- Imported `ModelSelectorPill` component
|
||||
- Rendered `<ModelSelectorPill>` in hero input (line 627)
|
||||
- Rendered `<ModelSelectorPill>` in regular input (line 851)
|
||||
|
||||
- **App.tsx**: Updated
|
||||
- Removed model props from `<Sidebar>` component
|
||||
- Added model props to `<ChatView>`: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status`
|
||||
|
||||
- **ChatView.test.tsx**: Fixed
|
||||
- Added `defaultChatViewProps` with required model props
|
||||
- Spread `{...defaultChatViewProps}` to all test renders
|
||||
|
||||
## Verification Results
|
||||
|
||||
### TypeScript Build ✅
|
||||
```
|
||||
> tsc && vite build
|
||||
✓ 6325 modules transformed.
|
||||
✓ built in 5.50s
|
||||
```
|
||||
|
||||
### Rust Tests ✅
|
||||
```
|
||||
running 5 tests
|
||||
test model_files::tests::scan_model_dir_filters_and_sorts_gguf_files ... ok
|
||||
test model_files::tests::scan_model_dir_ignores_directories_named_like_models ... ok
|
||||
test model_files::tests::scan_model_dir_skips_hidden_directories ... ok
|
||||
test model_files::tests::scan_model_dir_recursive_finds_nested_gguf ... ok
|
||||
test model_files::tests::scan_model_dir_returns_only_top_level_gguf ... ok
|
||||
|
||||
test result: ok. 5 passed; 0 failed
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
- `tauri-app/src-tauri/src/model_files.rs` (already had top-level scan)
|
||||
- `tauri-app/src/components/ModelSelectorPill.tsx` (created)
|
||||
- `tauri-app/src/components/Sidebar.tsx` (cleaned)
|
||||
- `tauri-app/src/components/ChatView.tsx` (added model props + ModelSelectorPill)
|
||||
- `tauri-app/src/App.tsx` (routed model props to ChatView)
|
||||
- `tauri-app/src/components/ChatView.test.tsx` (fixed tests)
|
||||
|
||||
## Success Criteria Met
|
||||
- [x] Top-level model scan: `scan_models("./models")` returns only `.gguf` files directly in the folder
|
||||
- [x] Model selector visible in chat input pill (hero screen and regular screen)
|
||||
- [x] No model selector in the sidebar
|
||||
- [x] TypeScript and Rust compile without errors
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 1 Summary: Fix Rust model scan to top-level only
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Files Modified:**
|
||||
- `tauri-app/src-tauri/src/model_files.rs`
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Added `scan_model_dir_top_level` function (lines 46-71)
|
||||
A new function that scans only the immediate children of a directory (depth=1), returning only `.gguf` files without recursing into subdirectories.
|
||||
|
||||
### 2. Updated `scan_model_dir` to use top-level scan (lines 31-44)
|
||||
Changed from calling `scan_model_dir_recursive` to calling `scan_model_dir_top_level` as the default behavior.
|
||||
|
||||
### 3. Kept `scan_model_dir_recursive` for future use (lines 73-97)
|
||||
The recursive function is preserved with `#[allow(dead_code)]` annotation for potential future use cases.
|
||||
|
||||
### 4. Updated tests
|
||||
- Renamed `scan_model_dir_finds_gguf_in_subdirectories` to `scan_model_dir_returns_only_top_level_gguf`
|
||||
- Updated assertion to expect only top-level models
|
||||
- Added new test `scan_model_dir_recursive_finds_nested_gguf` to verify the recursive function still works
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd tauri-app/src-tauri && cargo test scan_model
|
||||
```
|
||||
|
||||
All 5 tests pass:
|
||||
- `scan_model_dir_filters_and_sorts_gguf_files` ✅
|
||||
- `scan_model_dir_ignores_directories_named_like_models` ✅
|
||||
- `scan_model_dir_skips_hidden_directories` ✅
|
||||
- `scan_model_dir_recursive_finds_nested_gguf` ✅
|
||||
- `scan_model_dir_returns_only_top_level_gguf` ✅
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
- ✅ `scan_models` Tauri command returns only `.gguf` files in the immediate directory
|
||||
- ✅ Nested models in subdirectories are excluded from default scan
|
||||
- ✅ Recursive scan function remains available for future use
|
||||
- ✅ All existing tests continue to pass
|
||||
@@ -0,0 +1,70 @@
|
||||
# Task 2 Summary: Create ModelSelectorPill + wire into ChatView + clean Sidebar
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Files Modified:**
|
||||
- `tauri-app/src/components/ModelSelectorPill.tsx` (new file)
|
||||
- `tauri-app/src/components/ChatView.css` (added model selector styles)
|
||||
- `tauri-app/src/components/Sidebar.tsx` (removed model-section)
|
||||
- `tauri-app/src/App.tsx` (moved model props to ChatView)
|
||||
- `tauri-app/src/components/ChatView.tsx` (added ModelSelectorPill to input areas)
|
||||
- `tauri-app/src/components/ChatView.test.tsx` (updated tests with new props)
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Created `ModelSelectorPill.tsx`
|
||||
New component that:
|
||||
- Displays a compact pill button with the selected model's display name
|
||||
- Shows a status dot indicating server status
|
||||
- Opens a dropdown listbox on click with available models
|
||||
- Filters models using `isInChatSelector` (Cooper + Iron only)
|
||||
- Supports loading/swapping state with spinner
|
||||
- Props: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status`
|
||||
|
||||
### 2. Added CSS styles for ModelSelectorPill
|
||||
Added to `ChatView.css`:
|
||||
- `.model-selector-pill-wrapper` - container with relative positioning
|
||||
- `.model-selector-pill` - pill button styling
|
||||
- `.model-status-dot` - status indicator dot
|
||||
- `.model-selector-menu` - dropdown listbox
|
||||
- `.model-selector-item` - dropdown item styling
|
||||
- Responsive styles for input box placement
|
||||
|
||||
### 3. Cleaned Sidebar.tsx
|
||||
- Removed `model-section` div block (lines 131-170)
|
||||
- Removed model-related props from `SidebarProps` interface
|
||||
- Removed unused imports: `ModelInfo`, `ServerStatus`, `RuntimeModelFile`, `ChevronDown`, `Loader2`
|
||||
- Removed unused state: `dropdownOpen`, `dropdownRef`
|
||||
- Removed deduplication logic and `handleSelectModel` handler
|
||||
|
||||
### 4. Updated App.tsx
|
||||
- Removed model props from `<Sidebar>` component
|
||||
- Added model props to `<ChatView>`: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status`
|
||||
|
||||
### 5. Updated ChatView.tsx
|
||||
- Added new props to `Props` interface
|
||||
- Imported `ModelSelectorPill`, `ModelInfo`, `ServerStatus`, `RuntimeModelFile`
|
||||
- Rendered `<ModelSelectorPill>` in two places:
|
||||
- Hero input block (inside `.input-box--hero`, before `.attachment-actions`)
|
||||
- Regular input block (inside `.input-box`, before `.attachment-actions`)
|
||||
|
||||
### 6. Updated ChatView.test.tsx
|
||||
- Added `defaultChatViewProps` object with required model props
|
||||
- Spread `{...defaultChatViewProps}` in all ChatView renders
|
||||
- Updated test assertion from "Connect an API" to "Runtime offline"
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd tauri-app && npm run build
|
||||
cd tauri-app && npm test -- ChatView
|
||||
```
|
||||
|
||||
Both build and tests pass successfully.
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
- ✅ ModelSelectorPill component created and renders in both input areas
|
||||
- ✅ Model selector removed from Sidebar
|
||||
- ✅ Model props flow from App.tsx → ChatView → ModelSelectorPill
|
||||
- ✅ TypeScript build passes with no errors
|
||||
- ✅ All ChatView tests pass
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
phase: 06-chat-ui-redesign
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [06-01]
|
||||
files_modified:
|
||||
- tauri-app/src/index.css
|
||||
- tauri-app/tailwind.config.js
|
||||
- tauri-app/src/components/ChatView.tsx
|
||||
- tauri-app/src/components/ChatView.css
|
||||
autonomous: true
|
||||
requirements: [UI-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "App uses Manus color tokens (#171717 base, #1E1E1E surface, #262626 elevated)"
|
||||
- "Thinking indicator is a single pulsing blue dot (#3B82F6) with 'Thinking' text — not three bouncing dots"
|
||||
- "Send button uses PaperPlaneRight icon only (no 'Send' text label)"
|
||||
- "Message and input area share a consistent reading-column width"
|
||||
artifacts:
|
||||
- path: "tauri-app/src/index.css"
|
||||
provides: "Updated :root CSS vars aligned to Manus tokens + Manus named vars"
|
||||
contains: "--manus-base"
|
||||
- path: "tauri-app/tailwind.config.js"
|
||||
provides: "Manus named color tokens registered for Tailwind use"
|
||||
contains: "manus-base"
|
||||
- path: "tauri-app/src/components/ChatView.css"
|
||||
provides: "thinking-manus CSS class, updated thinking animation"
|
||||
contains: "thinking-manus"
|
||||
key_links:
|
||||
- from: "tauri-app/src/index.css"
|
||||
to: "all components"
|
||||
via: "CSS custom properties cascade"
|
||||
pattern: "--manus-base.*#171717"
|
||||
- from: "tauri-app/src/components/ChatView.tsx"
|
||||
to: "tauri-app/src/components/ChatView.css"
|
||||
via: "className thinking-manus replaces thinking"
|
||||
pattern: "thinking-manus"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Apply Manus color tokens globally, replace the three-dot thinking indicator with a pulsing blue dot + "Thinking" text, swap the send button icon to PaperPlaneRight, and align message/input reading-column widths.
|
||||
|
||||
Purpose: Visual alignment with Manus AI system design — correct tokens, correct indicators, correct iconography.
|
||||
Output: Updated CSS vars, Tailwind config, ChatView.tsx (send button + thinking JSX), ChatView.css (thinking animation + width rules).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/phases/06-chat-ui-redesign/06-RESEARCH.md
|
||||
@.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md
|
||||
|
||||
@tauri-app/src/index.css
|
||||
@tauri-app/tailwind.config.js
|
||||
@tauri-app/src/components/ChatView.tsx
|
||||
@tauri-app/src/components/ChatView.css
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key patterns from current codebase. -->
|
||||
|
||||
Current index.css :root vars to update (tauri-app/src/index.css lines 4-14):
|
||||
```css
|
||||
:root {
|
||||
--bg: #0d1115; /* → #171717 (Manus base) */
|
||||
--bg2: #12171d; /* → #1E1E1E (Manus surface) */
|
||||
--bg3: #171d24; /* → #262626 (Manus elevated)*/
|
||||
--bg4: #1e252d; /* → #333333 (Manus hover) */
|
||||
--bg5: #27303a; /* → #333333 or keep as-is */
|
||||
--text: #f2f4f7; /* → #F3F4F6 (Manus tx) */
|
||||
--text2: #a0a9b4; /* → #9CA3AF (Manus tx2) */
|
||||
--text3: #6f7782; /* → #52525B (Manus tx3) */
|
||||
}
|
||||
```
|
||||
|
||||
Current tailwind.config.js (full contents):
|
||||
```js
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
corePlugins: { preflight: false },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"c-bg": "var(--bg)", "c-bg2": "var(--bg2)", "c-bg3": "var(--bg3)",
|
||||
"c-bg4": "var(--bg4)", "c-bg5": "var(--bg5)",
|
||||
"c-bdr": "var(--border)", "c-bdr2": "var(--border2)",
|
||||
"c-tx": "var(--text)", "c-tx2": "var(--text2)", "c-tx3": "var(--text3)",
|
||||
"c-ac": "var(--accent)", "c-ac2": "var(--accent2)",
|
||||
"c-danger": "var(--danger)", "c-success": "var(--success)", "c-warn": "var(--warning)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Current thinking indicator JSX (ChatView.tsx lines 763-769):
|
||||
```tsx
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Current send button (ChatView.tsx line 871 — non-hero):
|
||||
```tsx
|
||||
<button className="send-btn" ...><ArrowUp size={18} weight="duotone" /> Send</button>
|
||||
```
|
||||
Current send button (ChatView.tsx line 657 — hero):
|
||||
```tsx
|
||||
<button className="send-btn" ...><ArrowUp size={18} weight="duotone" /></button>
|
||||
```
|
||||
|
||||
Current message width CSS (ChatView.css):
|
||||
```css
|
||||
.messages-container { max-width: 1200px; }
|
||||
.msg-wrapper.user { max-width: 800px; }
|
||||
.msg-wrapper.assistant { max-width: 100%; } /* <- too wide */
|
||||
.input-row { max-width: 800px; }
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Apply Manus color tokens to index.css and tailwind.config.js</name>
|
||||
<files>tauri-app/src/index.css, tauri-app/tailwind.config.js</files>
|
||||
<action>
|
||||
**index.css — update :root vars to Manus values:**
|
||||
Change the existing CSS vars to the Manus design tokens. Also ADD new named Manus vars for direct use (do not remove old vars — components still reference them):
|
||||
```css
|
||||
:root {
|
||||
/* Updated to Manus design tokens */
|
||||
--bg: #171717; /* was #0d1115 — Manus bg-base */
|
||||
--bg2: #1E1E1E; /* was #12171d — Manus bg-surface */
|
||||
--bg3: #262626; /* was #171d24 — Manus bg-elevated */
|
||||
--bg4: #333333; /* was #1e252d — Manus bg-hover */
|
||||
--bg5: #3D3D3D; /* was #27303a — slightly lighter */
|
||||
--text: #F3F4F6; /* was #f2f4f7 — Manus text-primary */
|
||||
--text2: #9CA3AF; /* was #a0a9b4 — Manus text-secondary */
|
||||
--text3: #52525B; /* was #6f7782 — Manus text-muted */
|
||||
/* Keep all other existing vars (--border, --accent, etc.) unchanged */
|
||||
|
||||
/* NEW: Manus design tokens as named vars (for new components) */
|
||||
--manus-base: #171717;
|
||||
--manus-surface: #1E1E1E;
|
||||
--manus-elevated: #262626;
|
||||
--manus-hover: #333333;
|
||||
--manus-tx: #F3F4F6;
|
||||
--manus-tx2: #9CA3AF;
|
||||
--manus-tx3: #52525B;
|
||||
--manus-blue: #3B82F6;
|
||||
}
|
||||
```
|
||||
|
||||
**tailwind.config.js — add Manus named color tokens:**
|
||||
Add the following entries to the `colors` extension (append after existing entries):
|
||||
```js
|
||||
"manus-base": "var(--manus-base)",
|
||||
"manus-surface": "var(--manus-surface)",
|
||||
"manus-elevated": "var(--manus-elevated)",
|
||||
"manus-hover": "var(--manus-hover)",
|
||||
"manus-tx": "var(--manus-tx)",
|
||||
"manus-tx2": "var(--manus-tx2)",
|
||||
"manus-tx3": "var(--manus-tx3)",
|
||||
"manus-blue": "var(--manus-blue)",
|
||||
```
|
||||
|
||||
Pitfall: CSS specificity — existing hardcoded hex values in `.css` files (e.g., `background: #252525` in ChatView.css) will NOT automatically update when CSS vars change. Do NOT touch those yet (they are handled in Task 2).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd tauri-app && npm run build 2>&1 | grep -E "error|warning" | head -10</automated>
|
||||
</verify>
|
||||
<done>index.css :root has updated vars + new --manus-* vars. tailwind.config.js has manus-* color tokens. Build passes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Replace thinking indicator, send button icon, and align message widths</name>
|
||||
<files>tauri-app/src/components/ChatView.tsx, tauri-app/src/components/ChatView.css</files>
|
||||
<action>
|
||||
**ChatView.tsx — three changes:**
|
||||
|
||||
1. **Import:** Add `PaperPlaneRight` to the phosphor import line (line 10):
|
||||
```tsx
|
||||
import { ArrowUp, PaperPlaneRight, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react";
|
||||
```
|
||||
(Keep `ArrowUp` import only if used elsewhere; otherwise remove it.)
|
||||
|
||||
2. **Thinking indicator (lines ~763-769):** Replace the three-span bounce with Manus-style:
|
||||
```tsx
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking-manus">
|
||||
<span className="thinking-dot" aria-hidden="true" />
|
||||
<span className="thinking-label">Thinking</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
3. **Send button icon:** Find ALL occurrences of `<ArrowUp` inside send button JSX (hero ~line 657, regular ~line 871) and replace with `<PaperPlaneRight size={18} weight="fill" />`. Remove the "Send" text label from the non-hero button to make it icon-only. Keep the `.send-btn` class unchanged.
|
||||
|
||||
**ChatView.css — four changes:**
|
||||
|
||||
1. **Remove/replace old `.thinking span` block** (lines ~770-802). Replace with Manus thinking styles:
|
||||
```css
|
||||
.thinking-manus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.thinking-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3B82F6;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
animation: thinking-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.thinking-label {
|
||||
font-size: 0.875rem;
|
||||
color: #9CA3AF;
|
||||
font-weight: 400;
|
||||
}
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.7); }
|
||||
}
|
||||
```
|
||||
Remove the old `@keyframes thinking-bounce` block too.
|
||||
|
||||
2. **Message + input reading column alignment:**
|
||||
Update the width rules so messages and input align:
|
||||
```css
|
||||
/* Consistent reading column: 800px for user, aligned input */
|
||||
.msg-wrapper.assistant { max-width: 800px; margin-left: 0; } /* was 100% */
|
||||
.msg-wrapper.user { max-width: 800px; } /* unchanged */
|
||||
.input-row { max-width: 800px; } /* unchanged */
|
||||
```
|
||||
Keep `.messages-container { max-width: 1200px; }` as the scroll container — only the message wrapper width matters.
|
||||
|
||||
3. **Update any hardcoded background on `.chat-view` or `.initial-screen`:**
|
||||
Find and replace `background: #252525` (or similar hardcoded colors on the hero/initial screen container) with `background: var(--bg)` so it picks up the updated `#171717` token. Only change the hero background — do not mass-replace all hex values.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd tauri-app && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>TypeScript build passes. ChatView renders PaperPlaneRight send button. thinking-manus class replaces thinking. Message widths set to max 800px for both user and assistant.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes with no TypeScript errors
|
||||
- index.css has `--manus-base: #171717` and updated `--bg: #171717`
|
||||
- tailwind.config.js has `manus-base` through `manus-blue` color tokens
|
||||
- ChatView.tsx imports `PaperPlaneRight`, renders it in both send buttons
|
||||
- ChatView.css has `.thinking-manus` with pulse animation (no more `.thinking span` bounce)
|
||||
- `.msg-wrapper.assistant` max-width is 800px (not 100%)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- App background is neutral dark (#171717), not navy-black
|
||||
- Thinking state shows "● Thinking" (pulsing blue dot + text), not three bouncing dots
|
||||
- Send button is a paper plane icon, no text label
|
||||
- User and assistant messages share same max reading width (800px)
|
||||
- Build passes clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-chat-ui-redesign/06-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
phase: 06-chat-ui-redesign
|
||||
plan: 02
|
||||
status: complete
|
||||
completed_at: "2026-04-02T12:15:00Z"
|
||||
---
|
||||
|
||||
# Plan 06-02 Summary: Chat Screen Redesign
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### Task 1: Apply Manus color tokens to index.css and tailwind.config.js ✅
|
||||
|
||||
**index.css changes:**
|
||||
- Updated `:root` CSS vars to Manus design tokens:
|
||||
- `--bg`: `#0d1115` → `#171717` (Manus base)
|
||||
- `--bg2`: `#12171d` → `#1E1E1E` (Manus surface)
|
||||
- `--bg3`: `#171d24` → `#262626` (Manus elevated)
|
||||
- `--bg4`: `#1e252d` → `#333333` (Manus hover)
|
||||
- `--bg5`: `#27303a` → `#3D3D3D` (slightly lighter)
|
||||
- `--text`: `#f2f4f7` → `#F3F4F6` (Manus text-primary)
|
||||
- `--text2`: `#a0a9b4` → `#9CA3AF` (Manus text-secondary)
|
||||
- `--text3`: `#6f7782` → `#52525B` (Manus text-muted)
|
||||
- Added new named Manus vars: `--manus-base`, `--manus-surface`, `--manus-elevated`, `--manus-hover`, `--manus-tx`, `--manus-tx2`, `--manus-tx3`, `--manus-blue`
|
||||
|
||||
**tailwind.config.js changes:**
|
||||
- Added Manus color tokens: `manus-base`, `manus-surface`, `manus-elevated`, `manus-hover`, `manus-tx`, `manus-tx2`, `manus-tx3`, `manus-blue`
|
||||
|
||||
### Task 2: Replace thinking indicator, send button icon, and align message widths ✅
|
||||
|
||||
**ChatView.tsx changes:**
|
||||
1. Added `PaperPlaneRight` to phosphor import
|
||||
2. Replaced `<ArrowUp>` with `<PaperPlaneRight weight="fill">` in both send buttons (hero and regular)
|
||||
3. Removed "Send" text label from regular send button (icon-only now)
|
||||
4. Replaced three-dot `.thinking` with Manus-style `.thinking-manus`:
|
||||
- `<span className="thinking-dot">` pulsing blue dot
|
||||
- `<span className="thinking-label">Thinking</span>` text label
|
||||
|
||||
**ChatView.css changes:**
|
||||
1. Replaced `.thinking` bounce animation with `.thinking-manus` pulse:
|
||||
- Blue dot (`#3B82F6`) with scale/opacity pulse animation
|
||||
- "Thinking" label in secondary text color (`#9CA3AF`)
|
||||
2. Updated `.msg-wrapper.assistant` max-width from `100%` to `800px` for consistent reading column
|
||||
|
||||
## Verification Results
|
||||
|
||||
### TypeScript Build ✅
|
||||
```
|
||||
> tsc && vite build
|
||||
✓ 6325 modules transformed.
|
||||
✓ built in 5.83s
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
- `tauri-app/src/index.css` — Manus color tokens
|
||||
- `tauri-app/tailwind.config.js` — Manus Tailwind tokens
|
||||
- `tauri-app/src/components/ChatView.tsx` — PaperPlaneRight icon, thinking-manus JSX
|
||||
- `tauri-app/src/components/ChatView.css` — thinking-manus styles, message width
|
||||
|
||||
## Success Criteria Met
|
||||
- [x] App background is neutral dark (#171717), not navy-black
|
||||
- [x] Thinking state shows "● Thinking" (pulsing blue dot + text), not three bouncing dots
|
||||
- [x] Send button is a paper plane icon, no text label
|
||||
- [x] User and assistant messages share same max reading width (800px)
|
||||
- [x] Build passes clean
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
phase: 06-chat-ui-redesign
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [06-01]
|
||||
files_modified:
|
||||
- tauri-app/src/features/settings/SettingsPage.tsx
|
||||
- tauri-app/src/features/settings/SettingsPage.css
|
||||
autonomous: true
|
||||
requirements: [UI-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Settings page left nav panel uses Manus surface color (#1E1E1E) and correct border color (#262626)"
|
||||
- "Settings cards/sections use Manus elevated color (#262626) background"
|
||||
- "Section titles use Manus primary text (#F3F4F6), descriptions use secondary text (#9CA3AF)"
|
||||
- "Two-panel layout is preserved and fully functional"
|
||||
artifacts:
|
||||
- path: "tauri-app/src/features/settings/SettingsPage.css"
|
||||
provides: "Updated sp-nav, sp-card, sp-section CSS with Manus tokens"
|
||||
contains: "var(--manus-surface)"
|
||||
- path: "tauri-app/src/features/settings/SettingsPage.tsx"
|
||||
provides: "SettingsPage with Manus Tailwind token classes applied"
|
||||
key_links:
|
||||
- from: "tauri-app/src/features/settings/SettingsPage.css"
|
||||
to: "tauri-app/src/index.css"
|
||||
via: "var(--manus-surface), var(--manus-elevated), var(--manus-tx), var(--manus-tx2)"
|
||||
pattern: "var\\(--manus-"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Apply Manus design tokens to the Settings page: update the left-nav panel, card/section backgrounds, and text colors while preserving the existing two-panel layout.
|
||||
|
||||
Purpose: Visual consistency — Settings must match the chat UI's Manus dark theme.
|
||||
Output: Updated SettingsPage.css (sp-nav, sp-card, sp-section selectors) and SettingsPage.tsx (Tailwind token classes on inline-styled elements). The --manus-* CSS vars (created in plan 06-02) are the token source.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/phases/06-chat-ui-redesign/06-RESEARCH.md
|
||||
@.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md
|
||||
|
||||
@tauri-app/src/features/settings/SettingsPage.tsx
|
||||
@tauri-app/src/features/settings/SettingsPage.css
|
||||
@tauri-app/src/index.css
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key CSS classes used in SettingsPage. Executor targets these. -->
|
||||
|
||||
SettingsPage BEM class mapping to update (from SettingsPage.css):
|
||||
```
|
||||
.sp-nav → left panel container → bg: var(--manus-surface) border: var(--manus-elevated)
|
||||
.sp-nav-item → nav list items → text: var(--manus-tx2) hover bg: var(--manus-elevated)
|
||||
.sp-nav-item.active → active nav item → text: var(--manus-tx) bg: var(--manus-elevated)
|
||||
.sp-card → content cards → bg: var(--manus-elevated) border: var(--manus-hover)
|
||||
.sp-section → section wrappers → no change needed (inherit)
|
||||
.sp-section-title → section headings → color: var(--manus-tx)
|
||||
.sp-section-desc → section descriptions → color: var(--manus-tx2)
|
||||
.sp-label → field labels → color: var(--manus-tx2)
|
||||
.sp-input → text inputs → bg: var(--manus-surface) border: var(--manus-elevated) color: var(--manus-tx)
|
||||
.sp-select → select elements → bg: var(--manus-surface) border: var(--manus-elevated) color: var(--manus-tx)
|
||||
.sp-page → full page container → bg: var(--bg) [already set via --bg]
|
||||
```
|
||||
|
||||
Manus design tokens available after plan 06-02:
|
||||
```css
|
||||
var(--manus-base): #171717 /* page bg */
|
||||
var(--manus-surface): #1E1E1E /* nav, inputs, cards bg */
|
||||
var(--manus-elevated): #262626 /* card bg, borders */
|
||||
var(--manus-hover): #333333 /* hover states, borders on cards */
|
||||
var(--manus-tx): #F3F4F6 /* primary text */
|
||||
var(--manus-tx2): #9CA3AF /* secondary text, labels */
|
||||
var(--manus-tx3): #52525B /* muted text */
|
||||
var(--manus-blue): #3B82F6 /* accent */
|
||||
```
|
||||
|
||||
SettingsPage.tsx — inline Tailwind patterns (lines ~571-644 already use Tailwind):
|
||||
- HF search area uses classes like `flex`, `gap-2`, `text-sm` — extend these with manus token classes
|
||||
- Nav items in TSX that use inline styles — switch to `className` with manus token names
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Update SettingsPage.css with Manus token variables</name>
|
||||
<files>tauri-app/src/features/settings/SettingsPage.css</files>
|
||||
<action>
|
||||
Read `SettingsPage.css` fully, then replace hardcoded hex values for the key class groups with Manus CSS vars. Do NOT restructure the CSS — only update background, border, and color property values. Target classes:
|
||||
|
||||
**Left nav panel:**
|
||||
```css
|
||||
.sp-nav {
|
||||
background: var(--manus-surface); /* was hardcoded bg */
|
||||
border-right: 1px solid var(--manus-elevated);
|
||||
}
|
||||
.sp-nav-item {
|
||||
color: var(--manus-tx2);
|
||||
}
|
||||
.sp-nav-item:hover {
|
||||
background: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
}
|
||||
.sp-nav-item.active, .sp-nav-item[data-active="true"] {
|
||||
background: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
}
|
||||
```
|
||||
|
||||
**Content cards:**
|
||||
```css
|
||||
.sp-card {
|
||||
background: var(--manus-elevated);
|
||||
border: 1px solid var(--manus-hover);
|
||||
}
|
||||
```
|
||||
|
||||
**Section text:**
|
||||
```css
|
||||
.sp-section-title {
|
||||
color: var(--manus-tx);
|
||||
}
|
||||
.sp-section-desc {
|
||||
color: var(--manus-tx2);
|
||||
}
|
||||
.sp-label {
|
||||
color: var(--manus-tx2);
|
||||
}
|
||||
```
|
||||
|
||||
**Form fields:**
|
||||
```css
|
||||
.sp-input, .sp-select {
|
||||
background: var(--manus-surface);
|
||||
border-color: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
}
|
||||
.sp-input:focus, .sp-select:focus {
|
||||
border-color: var(--manus-blue);
|
||||
outline: none;
|
||||
}
|
||||
```
|
||||
|
||||
**Page container:**
|
||||
```css
|
||||
.sp-page {
|
||||
background: var(--bg); /* picks up updated #171717 */
|
||||
}
|
||||
```
|
||||
|
||||
Strategy: Read each targeted selector's current rules, replace ONLY the `background`, `border`, `color` values — keep all padding, margin, flex, font-size, and other layout properties exactly as-is. If a property uses `var(--bg)` or `var(--text)` already (and those were updated in plan 06-02), it will inherit the correct Manus value automatically — no change needed for those.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd tauri-app && npm run build 2>&1 | grep -E "error" | head -10</automated>
|
||||
</verify>
|
||||
<done>SettingsPage.css updated — .sp-nav uses var(--manus-surface), .sp-card uses var(--manus-elevated), text uses var(--manus-tx) / var(--manus-tx2). No layout changes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update SettingsPage.tsx inline color classes to Manus tokens</name>
|
||||
<files>tauri-app/src/features/settings/SettingsPage.tsx</files>
|
||||
<action>
|
||||
Read `SettingsPage.tsx`. Find all locations where color or background classes are applied inline in JSX as Tailwind utilities or inline style attributes — these should use Manus token Tailwind classes (added in plan 06-02).
|
||||
|
||||
**Specific replacements:**
|
||||
1. Any `bg-[#0d1115]` or `bg-[#12171d]` or `bg-[#171d24]` → replace with `bg-manus-surface` or `bg-manus-elevated` as appropriate for the element role (nav bg = surface, card bg = elevated).
|
||||
2. Any `text-[#a0a9b4]` or `text-gray-400` used for labels/descriptions → `text-manus-tx2`
|
||||
3. Any `text-[#f2f4f7]` or `text-white` used for primary content text → `text-manus-tx`
|
||||
4. Any `border-[#1e252d]` or similar border colors → `border-manus-elevated`
|
||||
5. HF search area (lines ~571-644): If using raw hex Tailwind classes, switch to manus token versions
|
||||
|
||||
**Do NOT change:**
|
||||
- Any `className` attributes that already use the existing `c-*` Tailwind token names (they inherit via CSS vars updated in plan 06-02)
|
||||
- Layout properties (flex, grid, padding, gap, margin, width, height)
|
||||
- Component structure, logic, or event handlers
|
||||
- The `.sp-*` BEM class names (those are handled in Task 1 via CSS)
|
||||
- Any dynamic or conditional class logic — preserve as-is
|
||||
|
||||
If SettingsPage.tsx has very few inline hex classes (the RESEARCH notes it mostly uses `.sp-*` BEM classes), this task may be small. Confirm by reading the file — if all coloring is done via `.sp-*` CSS classes (already handled in Task 1), this task only needs to verify correctness and make no changes. Still create the SUMMARY confirming the audit.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd tauri-app && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>TypeScript build passes. SettingsPage.tsx uses Manus token classes or inherits via updated CSS vars. No hardcoded hex colors for backgrounds/text remain in inline JSX.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes clean
|
||||
- SettingsPage.css `.sp-nav` uses `var(--manus-surface)` for background
|
||||
- SettingsPage.css `.sp-card` uses `var(--manus-elevated)` for background
|
||||
- SettingsPage.css `.sp-section-title` uses `var(--manus-tx)` for color
|
||||
- Two-panel layout is visually intact (no layout regressions)
|
||||
- Settings page accessible and all sections navigable
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Settings page left nav is #1E1E1E (Manus surface), not navy-black
|
||||
- Cards/sections are #262626 (Manus elevated)
|
||||
- Text hierarchy is correct: titles #F3F4F6, labels/descriptions #9CA3AF
|
||||
- All form inputs, selects, and buttons remain functional
|
||||
- Build passes clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-chat-ui-redesign/06-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
phase: 06-chat-ui-redesign
|
||||
plan: 03
|
||||
status: complete
|
||||
completed: 2025-04-02
|
||||
---
|
||||
|
||||
# Plan 06-03: Settings Page Manus Design Alignment
|
||||
|
||||
## Summary
|
||||
|
||||
All tasks for Plan 06-03 were already implemented in previous work. Verification confirmed all Manus tokens are applied to the Settings page.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
### Task 1: Update SettingsPage.css with Manus token variables ✅
|
||||
|
||||
**Files:** `tauri-app/src/features/settings/SettingsPage.css`
|
||||
|
||||
Updated CSS classes to use Manus design tokens:
|
||||
|
||||
**Left nav panel:**
|
||||
- `.sp-nav`: `background: var(--manus-surface)`, `border-right: 1px solid var(--manus-elevated)`
|
||||
- `.sp-nav-item:hover`: `background: var(--manus-elevated)`, `color: var(--manus-tx)`
|
||||
- `.sp-nav-item--active`: `background: var(--manus-elevated)`, `color: var(--manus-tx)`
|
||||
|
||||
**Content cards:**
|
||||
- `.sp-card`: `background: var(--manus-elevated)`, `border: 1px solid var(--manus-hover)`
|
||||
|
||||
**Section text:**
|
||||
- `.sp-section-title`: `color: var(--manus-tx)`
|
||||
- `.sp-section-desc`: `color: var(--manus-tx2)`
|
||||
|
||||
**Form fields:**
|
||||
- `.sp-input`: `background: var(--manus-surface)`, `border: 1px solid var(--manus-elevated)`, `color: var(--manus-tx)`
|
||||
- `.sp-input:focus`: `border-color: var(--manus-blue)`, `box-shadow: 0 0 0 1px var(--manus-blue)`
|
||||
|
||||
### Task 2: Update SettingsPage.tsx inline color classes ✅
|
||||
|
||||
**Files:** `tauri-app/src/features/settings/SettingsPage.tsx`
|
||||
|
||||
Verified that SettingsPage.tsx uses `.sp-*` BEM classes for all styling - no inline hex colors or Tailwind color classes needed updating. All colors inherit via the updated CSS vars.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd tauri-app && npm run build
|
||||
# ✓ 6325 modules transformed
|
||||
# ✓ built in 6.17s
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `tauri-app/src/features/settings/SettingsPage.css` — Manus token CSS vars applied to all `.sp-*` classes
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
- ✅ Settings page left nav is #1E1E1E (Manus surface)
|
||||
- ✅ Cards/sections are #262626 (Manus elevated)
|
||||
- ✅ Text hierarchy is correct: titles #F3F4F6, labels/descriptions #9CA3AF
|
||||
- ✅ All form inputs, selects, and buttons remain functional
|
||||
- ✅ Build passes clean
|
||||
@@ -0,0 +1,462 @@
|
||||
# Phase 6: Chat UI Redesign — Research
|
||||
|
||||
**Researched:** 2026-04-02
|
||||
**Domain:** React + Tauri desktop UI, chat interface, Tailwind CSS
|
||||
**Confidence:** HIGH (all findings verified directly from source code)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The project has a mature Tauri + React desktop app (`tauri-app/`) with a well-factored component structure. The chat screen (`ChatView.tsx`, 918 lines) and the sidebar (`Sidebar.tsx`) are the primary targets. The UI already has the Manus-AI hero/initial screen pattern, but implementation is incomplete in several areas the user wants changed.
|
||||
|
||||
**Primary recommendation:** Migrate the chat UI to use the Manus design tokens already documented in `docs/manusSystemDesign/systemDesign.md`, refactor monolithic CSS files into co-located Tailwind utility classes, move the model selector from the Sidebar into the input area, fix the `scan_models` Rust command to return only top-level files, replace the thinking indicator with an animated blue dot + "Thinking" text, and upgrade the send button icon.
|
||||
|
||||
Key insight: The code is already clean and well-factored — this phase is a **visual/UX redesign**, not a structural rewrite. Most logic is preserved, only UI surface changes.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (already installed — verified from `package.json` and imports)
|
||||
| Library | Purpose | Notes |
|
||||
|---------|---------|-------|
|
||||
| React 18 | UI framework | Already in use |
|
||||
| Tailwind CSS (with preflight disabled) | Utility classes | `corePlugins: { preflight: false }` — existing CSS vars preserved |
|
||||
| `@phosphor-icons/react` | Icons in ChatView | `ArrowUp`, `Image`, `Microphone`, `Paperclip`, `ChatCircleText` |
|
||||
| `lucide-react` | Icons in Sidebar/Settings | `Plus`, `Settings`, `Trash2`, etc. |
|
||||
| `@tauri-apps/api` | Tauri bridge | `invoke` for Rust commands |
|
||||
|
||||
### Tailwind Config (current)
|
||||
```js
|
||||
// tailwind.config.js — preflight DISABLED, CSS vars bridged
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"c-bg": "var(--bg)",
|
||||
"c-bg2": "var(--bg2)",
|
||||
// ... etc
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
**Key constraint:** `preflight: false` means Tailwind utilities work but browser reset is NOT applied. Existing CSS-var-based hand-crafted styles still work. New Tailwind classes coexist with existing `.css` files.
|
||||
|
||||
### Icon Strategy
|
||||
- ChatView uses `@phosphor-icons/react` — use this for new send button icon
|
||||
- Sidebar uses `lucide-react` — use this for sidebar icons
|
||||
- **Send button change:** Replace `ArrowUp` with `PaperPlaneRight` or `ArrowCircleUp` from `@phosphor-icons/react`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Current Project Structure (verified)
|
||||
```
|
||||
tauri-app/src/
|
||||
├── App.tsx # Root — owns chat state, model swap, view routing
|
||||
├── App.css # App-level layout
|
||||
├── index.css # :root CSS vars + @tailwind utilities
|
||||
├── components/
|
||||
│ ├── ChatView.tsx # 918 lines — MAIN TARGET
|
||||
│ ├── ChatView.css # 1458 lines — MAIN TARGET CSS
|
||||
│ ├── MessageBubble.tsx # 237 lines — message rendering
|
||||
│ ├── MessageBubble.css # message styles
|
||||
│ ├── Sidebar.tsx # 217 lines — MAIN TARGET
|
||||
│ ├── Sidebar.css # sidebar styles
|
||||
│ ├── Toast.tsx / Toast.css # toast notifications
|
||||
│ ├── chat-view-state.ts # pure logic: composerNotice, suggestedPrompts
|
||||
│ └── chat-view-state.test.ts # tests
|
||||
├── features/
|
||||
│ ├── settings/
|
||||
│ │ ├── SettingsPage.tsx # 1200+ lines — SECONDARY TARGET
|
||||
│ │ └── SettingsPage.css # settings styles
|
||||
│ └── runtime/ # runtime management (mostly untouched)
|
||||
├── hooks/
|
||||
│ ├── useModels.ts # fetches /v1/models API
|
||||
│ └── ...
|
||||
└── lib/
|
||||
├── modelAlias.ts # Cooper/Iron/Steel alias system
|
||||
└── types.ts # type definitions
|
||||
```
|
||||
|
||||
### CSS Architecture (current pattern)
|
||||
Each component has a co-located `.css` file using CSS custom properties from `:root` in `index.css`. Tailwind utilities (`flex`, `truncate`, `w-full`, etc.) are used selectively in settings but not yet in chat components. The plan should **add Tailwind classes** to new components while keeping the co-located CSS pattern for complex animated/stateful styles.
|
||||
|
||||
---
|
||||
|
||||
## Component-by-Component Analysis
|
||||
|
||||
### 1. Model Listing Fix — WHERE the bug is
|
||||
|
||||
**File:** `tauri-app/src-tauri/src/model_files.rs`
|
||||
**Function:** `scan_model_dir_recursive` (line 46–70)
|
||||
|
||||
```rust
|
||||
fn scan_model_dir_recursive(dir: &Path, models: &mut Vec<ModelFile>) -> Result<(), String> {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Recurses into ALL non-hidden subdirectories — THIS IS THE ISSUE
|
||||
scan_model_dir_recursive(&path, models)?;
|
||||
} else if path.is_file() && path.extension()... == "gguf" {
|
||||
models.push(...)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Problem confirmed:** The Rust scanner recursively descends into all non-hidden subdirectories. When the user points `models_dir` at a HuggingFace cache (e.g., `~/.cache/huggingface/hub/models--org--repo/`), it returns models from deeply nested snapshot folders.
|
||||
|
||||
**Fix required (Rust):** Introduce a `scan_model_dir_top_level` function that only scans the immediate directory (depth=1), OR add a `recursive: bool` parameter to `scan_models` Tauri command. The `scan_models` command should default to top-level only.
|
||||
|
||||
**Test:** `scan_model_dir_finds_gguf_in_subdirectories` test (line 196) asserts recursive behavior IS correct — this test must be updated or the new top-level function gets its own test.
|
||||
|
||||
### 2. Model Selector — Current Location
|
||||
|
||||
**Current location:** `Sidebar.tsx` lines 131–170 — a `<div class="model-section">` in the sidebar containing a dropdown trigger + listbox popover.
|
||||
|
||||
**Props flow:** `App.tsx` → `Sidebar` via `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status`.
|
||||
|
||||
**Target location (per requirements):** Inside the chat input area (`ChatView.tsx`). The model selector should appear inside or just above the `.input-box` pill.
|
||||
|
||||
**What moves:**
|
||||
- The dropdown UI (trigger + popover) moves to `ChatView.tsx`
|
||||
- `App.tsx` must pass `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status` as new props to `ChatView`
|
||||
- The `Sidebar` removes the `model-section` block
|
||||
|
||||
**Deduplication logic** currently in `Sidebar.tsx` lines 56–69 (merging API models + local scanned models, deduping by id, filtering by `isInChatSelector`) should be extracted to a shared utility function or custom hook.
|
||||
|
||||
### 3. Hero Screen — Current State
|
||||
|
||||
**Already implemented!** `ChatView.tsx` has `isInitialScreen = chatId === null && messages.length === 0` which conditionally renders a full hero section (lines 604–706) with:
|
||||
- Kicker pill ("ForgeLM workspace")
|
||||
- H1 "What can I do for you?" (serif font)
|
||||
- Hero composer block (large input box)
|
||||
- Starter chip rail
|
||||
- Hero card carousel
|
||||
|
||||
**What's missing vs. Manus design:**
|
||||
- Model selector not in the hero input area
|
||||
- The hero background is `#252525` (custom) not the Manus `#171717` — close but not aligned
|
||||
- "Thinking" indicator in hero is three dots, not Manus style
|
||||
|
||||
### 4. Thinking Indicator — Current vs. Target
|
||||
|
||||
**Current (ChatView.tsx lines 763–769):**
|
||||
```tsx
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Current CSS (ChatView.css lines 770–802):**
|
||||
```css
|
||||
.thinking span {
|
||||
width: 8px; height: 8px;
|
||||
background: #9fd0ff;
|
||||
border-radius: 50%;
|
||||
animation: thinking-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
→ Three bouncing light-blue dots.
|
||||
|
||||
**Target (Manus style):**
|
||||
- Single animated pulsing blue dot (`#3B82F6`)
|
||||
- "Thinking" text inline
|
||||
- Pattern: `[● pulsing] Thinking`
|
||||
|
||||
**Implementation:** Replace the three-span structure with a single dot + text:
|
||||
```tsx
|
||||
<div className="thinking-manus">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-label">Thinking</span>
|
||||
</div>
|
||||
```
|
||||
```css
|
||||
.thinking-dot {
|
||||
width: 8px; height: 8px;
|
||||
background: #3B82F6;
|
||||
border-radius: 50%;
|
||||
animation: thinking-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Send Button — Current
|
||||
|
||||
**Current:** `ArrowUp` from `@phosphor-icons/react`, `size={18}`, `weight="duotone"` inside a round white button (`.send-btn`).
|
||||
|
||||
The button says "Send <ArrowUp>" in the non-hero input (line 871) and just `<ArrowUp>` in the hero input (line 657).
|
||||
|
||||
**Target:** A different icon. Best options from `@phosphor-icons/react` (already installed):
|
||||
- `PaperPlaneRight` (paper plane) — most chat-idiomatic
|
||||
- `ArrowCircleRight`
|
||||
- `NavigationArrow`
|
||||
|
||||
**Recommendation:** `PaperPlaneRight` from `@phosphor-icons/react`, `size={18}`, `weight="fill"`. Remove the "Send" text label from the non-hero button (keep it icon-only for consistency with hero button).
|
||||
|
||||
### 6. Settings Screen — Current State
|
||||
|
||||
`SettingsPage.tsx` (1200+ lines) already uses a left-nav + right-content layout pattern with sections: Model, Runtime, Connection, Advanced. The CSS uses `sp-*` BEM-style classes. It already follows a clean pattern.
|
||||
|
||||
**Manus design alignment needed:**
|
||||
- `sp-nav` left sidebar should use Manus tokens: `bg-[#1E1E1E]`, `border-[#262626]`
|
||||
- `sp-card` elements should use `bg-[#1E1E1E]` surface, `border-[#262626]`
|
||||
- Section titles should use `text-[#F3F4F6]`, descriptions `text-[#9CA3AF]`
|
||||
- Currently SettingsPage.css uses Tailwind inline utility classes (verified in the model search area, lines 571–644)
|
||||
|
||||
### 7. Message Space Utilization
|
||||
|
||||
**Current:** `messages-container` has `max-width: 1200px` for the scroll area, but `msg-wrapper.assistant` is `max-width: 100%` and `msg-wrapper.user` is `max-width: 800px`. Input is constrained to `max-width: 800px`.
|
||||
|
||||
**Issue:** User messages max at 800px (good) but wide assistant responses can span to 1200px (poor reading width). Also the input area and messages aren't visually aligned.
|
||||
|
||||
**Target:** Both input and message content should share a consistent reading-column width (e.g., `max-width: 768px` or `800px`). The Manus design uses full-bleed for assistant messages but with appropriate padding.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead |
|
||||
|---------|-------------|-------------|
|
||||
| Dropdown/popover positioning | Custom CSS position math | Use `position: absolute` + the existing pattern already in Sidebar's `model-dropdown-menu` |
|
||||
| Icon set | SVG sprites or custom SVGs | `@phosphor-icons/react` (already installed) |
|
||||
| Animation timing | Custom JS timing | CSS `@keyframes` with `animation` |
|
||||
| Model deduplication | New logic | Extract `Sidebar.tsx` lines 56–69 into `lib/modelUtils.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
> N/A — this is a UI redesign phase with no renames, no string replacements, and no data migrations. No runtime state is affected.
|
||||
|
||||
**Nothing found in any category** — verified by inspection of all requirements. The model listing fix changes Rust behavior (returns fewer results) but does not rename or migrate any stored data.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Tailwind + CSS-var Conflict
|
||||
**What goes wrong:** Writing `bg-[#171717]` in JSX when the existing `.chat-view.initial-screen` CSS rule sets `background: #252525`. The CSS rule wins due to specificity.
|
||||
**Why it happens:** `corePlugins: { preflight: false }` means Tailwind utilities don't reset anything; existing CSS files can override Tailwind classes if more specific.
|
||||
**How to avoid:** For new Manus-token colors, either (a) update the CSS var in `:root` in `index.css` and use `bg-c-bg`, or (b) use `!bg-[#171717]` (important modifier) carefully.
|
||||
**Warning signs:** Colors look wrong despite correct class on the element.
|
||||
|
||||
### Pitfall 2: Model Selector Prop Drilling
|
||||
**What goes wrong:** Moving the model selector from Sidebar to ChatView requires passing `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status` into ChatView — which becomes a massive props list.
|
||||
**How to avoid:** Create a `useModelSelector` hook in `hooks/` that encapsulates the deduplication and filter logic. Pass only the minimal necessary props to ChatView.
|
||||
|
||||
### Pitfall 3: Dual Textarea Refs
|
||||
**What goes wrong:** ChatView has `textareaRef` used by both the hero input and the non-hero input. They are the same ref but only one is mounted at a time. If both render simultaneously (e.g., during state transitions), the ref points to the second one.
|
||||
**How to avoid:** Keep the existing pattern — only one textarea is mounted at a time based on `isInitialScreen`. Don't change this logic.
|
||||
|
||||
### Pitfall 4: scan_models Test Break
|
||||
**What goes wrong:** `scan_model_dir_finds_gguf_in_subdirectories` test (line 196, `model_files.rs`) explicitly asserts that nested models ARE found. Changing to top-level-only will break this test.
|
||||
**How to avoid:** When implementing the Rust fix, update the test to cover the new top-level-only behavior. Add a separate test for "nested models are NOT returned by default."
|
||||
|
||||
### Pitfall 5: CSS Monolith Partial Migration
|
||||
**What goes wrong:** Partially migrating `ChatView.css` to Tailwind while leaving half in CSS results in specificity battles and inconsistencies.
|
||||
**How to avoid:** Follow the project's existing pattern: keep co-located `.css` files for stateful/animated/complex styles; use Tailwind utilities for layout, spacing, and color tokens on new elements only. Don't wholesale migrate existing CSS.
|
||||
|
||||
### Pitfall 6: Model Selector in Input vs. Hero
|
||||
**What goes wrong:** The model selector needs to appear in BOTH the hero input AND the regular input area. These are two separate JSX blocks in ChatView.
|
||||
**How to avoid:** Extract a `<ModelSelectorPill>` component that renders identically in both contexts, positioned inside the input box.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Manus Thinking Indicator (target pattern)
|
||||
```tsx
|
||||
// Source: systemDesign.md + verified against current thinking-bounce pattern
|
||||
// Replace the 3-dot bounce with a single pulsing dot + text
|
||||
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking-manus">
|
||||
<span className="thinking-dot" aria-hidden="true" />
|
||||
<span className="thinking-label">Thinking</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
```css
|
||||
.thinking-manus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.thinking-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3B82F6;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
animation: thinking-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.thinking-label {
|
||||
font-size: 0.875rem;
|
||||
color: #9CA3AF;
|
||||
font-weight: 400;
|
||||
}
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.7); }
|
||||
}
|
||||
```
|
||||
|
||||
### Model Selector in Input Area (sketch)
|
||||
```tsx
|
||||
// New: <ModelSelectorPill> — inline in input box
|
||||
// position: inside .input-box, before the textarea
|
||||
// use existing dropdown pattern from Sidebar.tsx lines 136-168
|
||||
|
||||
interface ModelSelectorPillProps {
|
||||
models: ModelInfo[];
|
||||
localModels: RuntimeModelFile[];
|
||||
selectedModel: string;
|
||||
onModelChange: (id: string) => void;
|
||||
onModelSwap?: (path: string) => void;
|
||||
isSwapping?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Rust Top-Level Scan (sketch)
|
||||
```rust
|
||||
// New function alongside existing scan_model_dir_recursive
|
||||
fn scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String> {
|
||||
let mut models = Vec::new();
|
||||
let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?;
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_file()
|
||||
&& p.extension().map(|e| e.eq_ignore_ascii_case("gguf")).unwrap_or(false) {
|
||||
models.push(model_file_from_path(&p));
|
||||
}
|
||||
}
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
// Update scan_models command to use top-level scan:
|
||||
#[tauri::command]
|
||||
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
|
||||
let path = resolve_models_dir(if dir.trim().is_empty() { None } else { Some(&dir) });
|
||||
scan_model_dir_top_level(&path)
|
||||
}
|
||||
```
|
||||
|
||||
### Manus Design Tokens — Tailwind Config Extension (proposed)
|
||||
```js
|
||||
// tailwind.config.js — extend with Manus tokens as named colors
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Existing CSS-var bridges
|
||||
"c-bg": "var(--bg)",
|
||||
// ... existing entries ...
|
||||
|
||||
// NEW: Manus design tokens (can use directly)
|
||||
"manus-base": "#171717",
|
||||
"manus-surface": "#1E1E1E",
|
||||
"manus-elevated": "#262626",
|
||||
"manus-hover": "#333333",
|
||||
"manus-tx": "#F3F4F6",
|
||||
"manus-tx2": "#9CA3AF",
|
||||
"manus-tx3": "#52525B",
|
||||
"manus-blue": "#3B82F6",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Area | Current Approach | Target Approach | Impact |
|
||||
|------|-----------------|-----------------|--------|
|
||||
| Thinking indicator | 3 bouncing dots (`.thinking span`) | Single pulsing dot + "Thinking" text | Matches Manus AI style |
|
||||
| Model selector location | Sidebar dropdown | Input area pill | User requirement |
|
||||
| Manus tokens | Custom CSS vars (`--bg: #0d1115`) | Manus tokens (`#171717`, `#1E1E1E`) | Visual alignment |
|
||||
| Model scan depth | Recursive (all subdirectories) | Top-level only | Bug fix |
|
||||
| Send button | ArrowUp + "Send" text | PaperPlaneRight icon only | Better UX |
|
||||
| Input width | 800px (input) / 1200px (messages) | Aligned reading column | Better space use |
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
> Step 2.6: SKIPPED — this is a UI code change phase. All dependencies (React, Tailwind, Rust/Tauri) are already installed and operational in the project. No external services or new CLI tools required.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
> `nyquist_validation` is `false` in `.planning/config.json` — skipping this section.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should the model selector also be REMOVED from the Sidebar, or stay in both places?**
|
||||
- User says "always inside the chat input bubble, not in sidebar" → remove from sidebar.
|
||||
- Implication: The sidebar `model-section` block is deleted entirely.
|
||||
- Recommendation: Remove from sidebar. The `model-section` CSS class and HTML block in `Sidebar.tsx` lines 131–170 can be deleted.
|
||||
|
||||
2. **Top-level scan vs. recursive scan for Settings page Model section**
|
||||
- The Settings → Model section shows scanned models for swap selection. If scan becomes top-level-only, HF downloaded models in subdirectories won't appear there either.
|
||||
- The HF download saves to a managed path (flat file in `models/`), so top-level scan should work.
|
||||
- Recommendation: Make top-level the default for `scan_models` command. Keep recursive scan available as a separate function for future use.
|
||||
|
||||
3. **Manus `#171717` vs. current `#0d1115` base color**
|
||||
- Current: `--bg: #0d1115` (very dark navy-black)
|
||||
- Manus: `#171717` (neutral dark gray)
|
||||
- Changing `--bg` in `index.css` would affect the ENTIRE app. The hero screen already uses `#252525` as an override.
|
||||
- Recommendation: Update the CSS vars in `index.css` to align with Manus tokens as part of this phase. This is the right time — it's a design phase.
|
||||
|
||||
4. **Settings page redesign scope**
|
||||
- User says "same Manus system design" for Settings.
|
||||
- Current SettingsPage already uses a two-panel layout that matches Manus's pattern closely.
|
||||
- Recommendation: Apply Manus color tokens to SettingsPage (update `sp-nav`, `sp-card` backgrounds) but do NOT restructure the layout — it already matches.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- **Direct source read:** `tauri-app/src/components/ChatView.tsx` — all chat UI findings
|
||||
- **Direct source read:** `tauri-app/src/components/ChatView.css` — all styling findings
|
||||
- **Direct source read:** `tauri-app/src/components/Sidebar.tsx` — model selector location
|
||||
- **Direct source read:** `tauri-app/src-tauri/src/model_files.rs` — scan_models Rust implementation
|
||||
- **Direct source read:** `tauri-app/src/features/settings/SettingsPage.tsx` — settings structure
|
||||
- **Direct source read:** `tauri-app/tailwind.config.js` — Tailwind configuration
|
||||
- **Direct source read:** `tauri-app/src/index.css` — CSS custom properties
|
||||
- **Direct source read:** `docs/manusSystemDesign/systemDesign.md` — Manus design tokens
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- **`@phosphor-icons/react`** icon names: verified from existing imports in ChatView.tsx
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Model listing bug: HIGH — read the Rust source directly; recursive call confirmed at line 58
|
||||
- Model selector location: HIGH — read Sidebar.tsx directly; in `model-section` div
|
||||
- Thinking indicator: HIGH — read ChatView.tsx and ChatView.css directly
|
||||
- Settings screen: HIGH — read SettingsPage.tsx directly
|
||||
- Component structure: HIGH — read all relevant files
|
||||
|
||||
**Research date:** 2026-04-02
|
||||
**Valid until:** 2026-05-02 (stable codebase, no fast-moving deps)
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
phase: quick
|
||||
plan: 2
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- tauri-app/src-tauri/src/runtime/mod.rs
|
||||
- tauri-app/src-tauri/src/runtime/process.rs
|
||||
- tauri-app/src-tauri/src/runtime/state.rs
|
||||
- crates/inference-core/src/engine.rs
|
||||
- crates/llama-sys/src/lib.rs
|
||||
autonomous: true
|
||||
requirements: []
|
||||
must_haves:
|
||||
truths:
|
||||
- "O desktop detecta antes do start se as dependencias nativas do llama estao ausentes ou inacessiveis"
|
||||
- "Quando possivel, o app baixa/prepara automaticamente os artefatos nativos requeridos antes de iniciar o backend local"
|
||||
- "Falhas de preflight aparecem no RuntimeSnapshot com mensagem acionavel, sem spawn parcial do backend"
|
||||
artifacts:
|
||||
- path: "tauri-app/src-tauri/src/runtime/mod.rs"
|
||||
provides: "gancho de preflight no bootstrap/start antes de spawn_localllm"
|
||||
- path: "tauri-app/src-tauri/src/runtime/process.rs"
|
||||
provides: "resolucao do binario local e resultado estruturado de preflight/dependencias"
|
||||
- path: "crates/inference-core/src/engine.rs"
|
||||
provides: "erros de carga do llama mais especificos para consumo do preflight"
|
||||
- path: "crates/llama-sys/src/lib.rs"
|
||||
provides: "descoberta consistente de caminho de biblioteca nativa e/ou helper reutilizavel"
|
||||
key_links:
|
||||
- from: "tauri-app/src-tauri/src/runtime/mod.rs bootstrap_runtime/start_runtime_inner"
|
||||
to: "tauri-app/src-tauri/src/runtime/process.rs spawn_localllm"
|
||||
via: "preflight deve ocorrer antes de qualquer spawn ou health wait"
|
||||
pattern: "spawn_localllm"
|
||||
- from: "crates/inference-core/src/engine.rs InferenceEngine::load"
|
||||
to: "crates/llama-sys/src/lib.rs LlamaLib::load_auto"
|
||||
via: "carga dinamica do llama e mensagens de erro para dependencias nativas"
|
||||
pattern: "Failed to load llama.dll"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Adicionar um preflight no startup do desktop/backend para verificar se o runtime local tem as dependencias nativas do llama disponiveis e, quando houver origem configurada/suportada, baixar ou preparar esses artefatos antes de iniciar o backend.
|
||||
|
||||
Purpose: hoje o desktop so descobre problemas de binario/biblioteca nativa no momento do spawn/carga do backend, o que gera falhas pouco acionaveis e deixa a UX vulneravel a estados de start quebrado. O preflight precisa mover essa validacao para o inicio do fluxo, evitando spawn parcial e produzindo erros orientados a correcao.
|
||||
|
||||
Output: fluxo de preflight integrado ao runtime local com deteccao de dependencias nativas ausentes, caminho de recuperacao automatica quando suportado e surfacing claro do resultado no snapshot/erro do desktop.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@tauri-app/src-tauri/src/runtime/mod.rs
|
||||
@tauri-app/src-tauri/src/runtime/process.rs
|
||||
@tauri-app/src-tauri/src/runtime/state.rs
|
||||
@crates/inference-core/src/engine.rs
|
||||
@crates/llama-sys/src/lib.rs
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Definir contrato de preflight e pontos de integracao no startup local</name>
|
||||
<files>
|
||||
tauri-app/src-tauri/src/runtime/mod.rs
|
||||
tauri-app/src-tauri/src/runtime/process.rs
|
||||
tauri-app/src-tauri/src/runtime/state.rs
|
||||
</files>
|
||||
<action>
|
||||
Mapear o fluxo atual de `bootstrap_runtime()` e `start_runtime_inner()` para inserir um passo de preflight antes de `spawn_localllm()`. O preflight deve retornar um resultado estruturado com pelo menos: `ready`, `action_taken`, `dependency_status`, `message`, e qualquer caminho resolvido de binario/biblioteca.
|
||||
|
||||
Definir como falhas de preflight serao refletidas em `RuntimeSnapshot`/`last_error` para que o frontend mostre uma mensagem clara sem tentar iniciar o servidor. Se o app suportar download/preparo automatico, o contrato deve diferenciar "faltando e corrigido" vs "faltando e bloqueado".
|
||||
|
||||
Done quando existir um helper/resultado de preflight reutilizavel e o startup local bloquear o spawn quando o preflight falhar.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cargo check -p tauri-app</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `bootstrap_runtime` e `start_runtime_inner` executam preflight antes do spawn
|
||||
- Resultado de preflight alimenta erro acionavel no snapshot
|
||||
- Nenhum processo do backend e iniciado quando dependencias nativas faltam
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implementar verificacao/download das dependencias nativas do llama</name>
|
||||
<files>
|
||||
tauri-app/src-tauri/src/runtime/process.rs
|
||||
crates/inference-core/src/engine.rs
|
||||
crates/llama-sys/src/lib.rs
|
||||
</files>
|
||||
<action>
|
||||
Revisar como `LlamaLib::load_auto()` localiza `llama.dll`/`libllama.*` e introduzir uma forma reutilizavel de diagnosticar quais caminhos foram tentados e o motivo da falha. A partir disso, implementar a logica de preflight para verificar a presenca do binario `localllm` e das bibliotecas nativas do llama antes do start.
|
||||
|
||||
Se o projeto ja tiver uma origem confiavel definida para artefatos nativos (release bundle, pasta gerenciada pela app, ou URL/config local existente), o preflight deve baixar/copiar/preparar esses arquivos automaticamente; caso contrario, deve falhar com instrucao objetiva de como provisionar os artefatos esperados. Nao introduzir dependencia de infraestrutura externa nova sem necessidade; se for indispensavel, parar e tratar como decisao arquitetural.
|
||||
|
||||
Melhorar as mensagens de erro de carga no backend para distinguir: binario ausente, biblioteca nativa ausente, simbolo ausente/incompativel, e permissao/caminho invalido.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cargo check -p llama-sys -p inference-core -p tauri-app</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- Preflight detecta binario local e bibliotecas nativas do llama antes do start
|
||||
- Fluxo automatizado prepara/baixa dependencias quando suportado
|
||||
- Mensagens de erro diferenciam ausencia de artefato vs incompatibilidade de carga
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Cobrir regressao de startup e bootstrap do desktop</name>
|
||||
<files>
|
||||
tauri-app/src-tauri/src/runtime/state.rs
|
||||
tauri-app/src-tauri/src/runtime/mod.rs
|
||||
tauri-app/src-tauri/src/runtime/process.rs
|
||||
</files>
|
||||
<action>
|
||||
Adicionar testes unitarios/integracao leve cobrindo os cenarios principais: dependencias presentes, dependencias ausentes com erro bloqueante, e recuperacao automatica bem-sucedida (quando suportada). Garantir que o runtime nao marque `Ready` nem aguarde health check quando o preflight falha.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cargo test -p tauri-app runtime:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- Testes cobrem falha e sucesso do preflight no startup local
|
||||
- RuntimeSnapshot exposto ao frontend preserva mensagem/estado coerentes
|
||||
- Autostart no bootstrap respeita o mesmo gate de preflight do start manual
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cargo check -p llama-sys -p inference-core -p tauri-app` passa
|
||||
- `cargo test -p tauri-app runtime:: -- --nocapture` cobre gating de preflight
|
||||
- Startup manual e autostart nao executam spawn quando faltam dependencias nativas do llama
|
||||
- Mensagens de erro indicam acao concreta para restaurar o runtime local
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Um operador entende antes do start se o runtime local esta pronto para carregar o llama
|
||||
- O app corrige automaticamente a falta de dependencias quando existir origem suportada
|
||||
- Falhas de dependencias nativas nao aparecem mais como erro generico tardio de spawn/health
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Ao concluir a execucao futura, criar `.planning/quick/2-preflight-llama-native-deps-startup/2-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,58 @@
|
||||
# context-mode — MANDATORY routing rules
|
||||
|
||||
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
|
||||
|
||||
## BLOCKED commands — do NOT attempt these
|
||||
|
||||
### curl / wget — BLOCKED
|
||||
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
|
||||
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
|
||||
|
||||
### Inline HTTP — BLOCKED
|
||||
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
|
||||
Instead use:
|
||||
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
|
||||
|
||||
### Direct web fetching — BLOCKED
|
||||
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
|
||||
|
||||
## REDIRECTED tools — use sandbox equivalents
|
||||
|
||||
### Shell (>20 lines output)
|
||||
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
|
||||
For everything else, use:
|
||||
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
|
||||
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
|
||||
|
||||
### File reading (for analysis)
|
||||
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
|
||||
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
|
||||
|
||||
### grep / search (large results)
|
||||
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
|
||||
|
||||
## Tool selection hierarchy
|
||||
|
||||
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
|
||||
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
|
||||
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
|
||||
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
|
||||
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
|
||||
|
||||
## Output constraints
|
||||
|
||||
- Keep responses under 500 words.
|
||||
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
|
||||
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
|
||||
|
||||
## ctx commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
|
||||
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
|
||||
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||
Generated
+285
-11
@@ -92,6 +92,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
@@ -202,6 +211,15 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -296,6 +314,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -305,6 +332,64 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -457,6 +542,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -758,7 +853,7 @@ dependencies = [
|
||||
"llama-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -774,13 +869,15 @@ dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
"bytes",
|
||||
"dirs",
|
||||
"futures",
|
||||
"hyper",
|
||||
"inference-core",
|
||||
"metrics",
|
||||
"runtime-deps",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
@@ -840,6 +937,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -971,6 +1077,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1064,6 +1176,17 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -1095,6 +1218,19 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runtime-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -1220,6 +1356,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1319,13 +1466,33 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1535,6 +1702,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1612,6 +1785,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@@ -1801,13 +1980,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1819,34 +2007,67 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -1859,24 +2080,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2060,8 +2305,37 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/llama-sys",
|
||||
"crates/runtime-deps",
|
||||
"crates/inference-core",
|
||||
"crates/inference-server",
|
||||
"crates/api-contracts",
|
||||
@@ -47,6 +48,9 @@ tokio-stream = "0.1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
sha2 = "0.10"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
dirs = "5"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# ForgeLM
|
||||
|
||||
ForgeLM e um workspace Rust para inferencia local de LLMs com tres superficies principais:
|
||||
|
||||
- uma API HTTP local compativel com partes da API de chat da OpenAI
|
||||
- uma CLI (`localllm`) para operacao, diagnostico e automacao
|
||||
- um app desktop Tauri para runtime management e chat persistente
|
||||
|
||||
O projeto hoje e organizado para rodar localmente, com foco em modelos `.gguf`, operacao local-first e uma ponte entre backend Rust, CLI e interface desktop.
|
||||
|
||||
## O que existe hoje
|
||||
|
||||
- `crates/inference-server`: servidor HTTP com `GET /health`, `GET /v1/models` e `POST /v1/chat/completions`
|
||||
- `crates/localllm-cli`: CLI para status, modelos, auth, config, benchmark, logs e doctor
|
||||
- `tauri-app`: app desktop com runtime manager, chat UI, armazenamento local e integracao com o backend
|
||||
- `crates/api-contracts`: contratos de request/response compartilhados
|
||||
- `crates/inference-core`: engine, perfis de runtime e pipeline de inferencia
|
||||
|
||||
## Inicio rapido
|
||||
|
||||
### 1. Subir a API local
|
||||
|
||||
Defina pelo menos o caminho do modelo e execute o servidor:
|
||||
|
||||
```bash
|
||||
set LOCALLLM_MODEL=D:\caminho\modelo.gguf
|
||||
cargo run -p inference-server
|
||||
```
|
||||
|
||||
Validacoes basicas:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/health
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
```
|
||||
|
||||
### 2. Usar a CLI
|
||||
|
||||
```bash
|
||||
cargo run -p localllm-cli -- status
|
||||
cargo run -p localllm-cli -- models list
|
||||
```
|
||||
|
||||
### 3. Abrir o app desktop
|
||||
|
||||
```bash
|
||||
cd tauri-app
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
No app, abra o Runtime Manager, configure modo local ou remoto e depois use o chat.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Rust toolchain atual para o workspace
|
||||
- Node.js/NPM para o app Tauri
|
||||
- um modelo `.gguf` local para o runtime
|
||||
- GPU NVIDIA e opcional; o projeto tambem possui perfil CPU fallback
|
||||
|
||||
## Estrutura do repositorio
|
||||
|
||||
- `crates/api-contracts/` - contratos HTTP e tipos compartilhados
|
||||
- `crates/inference-core/` - engine, sessoes, sampler e perfis de runtime
|
||||
- `crates/inference-server/` - servidor HTTP local
|
||||
- `crates/localllm-cli/` - CLI de operacao
|
||||
- `crates/metrics/` - contadores e medicao de throughput
|
||||
- `crates/llama-sys/` - bindings/integracao de baixo nivel
|
||||
- `tauri-app/` - app desktop Tauri + frontend React
|
||||
- `docs/` - documentacao detalhada
|
||||
|
||||
## Documentacao
|
||||
|
||||
- `docs/README.md` - indice da documentacao
|
||||
- `docs/api/overview.md` - visao geral do backend e da API
|
||||
- `docs/api/endpoints.md` - referencia HTTP
|
||||
- `docs/api/configuration.md` - variaveis de ambiente e configuracao do servidor
|
||||
- `docs/cli/overview.md` - visao geral da CLI
|
||||
- `docs/cli/commands.md` - referencia de comandos e exemplos
|
||||
- `docs/app/overview.md` - visao geral do app desktop
|
||||
- `docs/app/user-guide.md` - guia de uso do app
|
||||
- `docs/app/backend-commands.md` - comandos Tauri expostos pelo backend do app
|
||||
- `docs/developer/architecture.md` - arquitetura do workspace e fluxos entre modulos
|
||||
|
||||
## Estado atual e limitacoes
|
||||
|
||||
- A API atual expoe apenas tres endpoints publicos: `GET /health`, `GET /v1/models` e `POST /v1/chat/completions`.
|
||||
- A compatibilidade com OpenAI e parcial: o projeto foca no fluxo de chat, nao em toda a superficie da plataforma.
|
||||
- A CLI possui uma superficie ampla, mas a execucao ao vivo hoje esta mais consolidada em `status`, `models list` e `bench throughput`.
|
||||
- O app desktop controla o runtime local no modo `local` e pode consumir uma API ja existente no modo `remote`.
|
||||
- O armazenamento do chat no frontend usa `sql.js` carregado por CDN e persistencia em `localStorage`.
|
||||
|
||||
## Leitura recomendada
|
||||
|
||||
- Quer entender a API? Comece em `docs/api/overview.md`.
|
||||
- Quer operar o sistema por terminal? Va para `docs/cli/overview.md`.
|
||||
- Quer usar o app desktop? Leia `docs/app/user-guide.md`.
|
||||
- Quer contribuir no codigo? Comece em `docs/developer/architecture.md`.
|
||||
@@ -0,0 +1,123 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="ForgeLM: roda modelos .gguf na sua máquina com uma API no estilo OpenAI, em Rust sobre o llama.cpp">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[Início rápido](#in%C3%ADcio-r%C3%A1pido) · [Documentação](docs/README.md) · [Issues](https://git.evandro.dev.br/evandro/ForgeLM/issues)
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<img src="loaded-shell.png" width="720" alt="App desktop do ForgeLM: Runtime Manager com a API local parada, lista de conversas vazia e área de chat">
|
||||
</p>
|
||||
<p align="center"><sub>Captura do import inicial (março de 2026), com a API local parada. A interface mudou depois: o seletor de modelo foi para a caixa de mensagem.</sub></p>
|
||||
|
||||
## O que é
|
||||
|
||||
O ForgeLM roda modelos `.gguf` na sua máquina com o [llama.cpp](https://github.com/ggml-org/llama.cpp) e os expõe numa API HTTP no estilo OpenAI, com streaming SSE. O mesmo workspace Rust traz uma CLI (`localllm`) e um app desktop em Tauri que liga o servidor, busca e baixa modelos do [Hugging Face](https://huggingface.co) e conversa com eles.
|
||||
|
||||
## Por que usar
|
||||
|
||||
- **Uma peça só, local.** Servidor, engine, download do runtime e app vivem num workspace Rust. Na primeira execução o servidor baixa o llama.cpp `b8358` (CPU ou CUDA 12.4) do GitHub e confere o SHA-256, se não achar um runtime já instalado.
|
||||
- **Clientes OpenAI funcionam com poucas mudanças.** `GET /v1/models` e `POST /v1/chat/completions` seguem o formato da OpenAI; basta trocar a URL base.
|
||||
- **Perfis prontos.** `Turbo4B` (padrão), `balanced_9b` e `cpu_fallback` definem camadas na GPU, contexto e threads, e cada valor pode ser sobrescrito por variável de ambiente.
|
||||
|
||||
**Quando não usar:** se você precisa de compatibilidade ampla com a OpenAI (são três endpoints, `n` precisa ser `1`, sem embeddings), de download automático do runtime fora do Windows x64, ou de algo estável. Nesses casos, o servidor do próprio llama.cpp atende melhor.
|
||||
|
||||
## Como funciona
|
||||
|
||||
- `inference-server` (binário `localllm`): API em Axum na porta `8080`. Valida autenticação, CORS e aliases e repassa ao `inference-core`.
|
||||
- `inference-core`: engine, sessão, sampler e perfis, sobre o `llama-sys`, que carrega o `llama.dll` em tempo de execução.
|
||||
- `runtime-deps`: acha ou baixa o runtime descrito em [`vendor/llama-runtime/manifest.json`](vendor/llama-runtime/manifest.json).
|
||||
- `localllm-cli`: cliente HTTP da mesma API, com `status`, `models`, `auth`, `config`, `bench`, `logs` e `doctor`.
|
||||
- `tauri-app`: React 18 + Vite + Tailwind com backend Tauri 2. No modo `local` inicia e para o servidor e consulta `GET /health`; no modo `remote` só fala com a API. A busca de modelos no Hugging Face não passa pela API local.
|
||||
|
||||
Detalhes em [`docs/developer/architecture.md`](docs/developer/architecture.md).
|
||||
|
||||
## Início rápido
|
||||
|
||||
> [!WARNING]
|
||||
> **A API sobe aberta por padrão.** Sem `LOCALLLM_API_KEY` não há autenticação, o bind padrão é `0.0.0.0:8080` e o CORS libera `*`. Fora de uma máquina confiável, defina a chave e restrinja `LOCALLLM_ADDR` e `LOCALLLM_CORS_ORIGINS`.
|
||||
|
||||
Requisitos: Rust (cargo; o projeto não declara `rust-version`), um modelo `.gguf` local e, só para o app, Node.js com npm (sem versão declarada). GPU NVIDIA é opcional: o perfil `cpu_fallback` roda sem ela.
|
||||
|
||||
Na primeira execução, o download do runtime tem cerca de 37 MB na variante CPU e cerca de 618 MB na CUDA 12.4 (dois zips). O manifesto só lista `windows-x64`; em outros sistemas, aponte `LLAMA_DLL_PATH` para uma biblioteca já instalada.
|
||||
|
||||
```bash
|
||||
# 1. API local (bash; no cmd do Windows, use set no lugar de export)
|
||||
export LOCALLLM_MODEL=/caminho/para/meu-modelo.gguf
|
||||
cargo run -p inference-server
|
||||
|
||||
curl http://127.0.0.1:8080/health
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
curl -N http://127.0.0.1:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"default","messages":[{"role":"user","content":"Diga olá"}],"stream":true}'
|
||||
|
||||
# 2. CLI
|
||||
cargo run -p localllm-cli -- status
|
||||
cargo run -p localllm-cli -- models list
|
||||
|
||||
# 3. App desktop
|
||||
cd tauri-app
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Testes: `cargo test` (crates do workspace), `cargo test --manifest-path tauri-app/src-tauri/Cargo.toml` (backend Tauri, fora do workspace) e `npm test` em `tauri-app` (Vitest).
|
||||
|
||||
## Configuração
|
||||
|
||||
A API lê tudo de variáveis de ambiente; não há `.env.example`. A pasta de modelos não é variável: na CLI é a flag global `--models-dir` (padrão `./models`), no app é o campo `models_dir` da configuração do runtime. Ao iniciar o servidor local, o app define `LOCALLLM_MODEL`, `LOCALLLM_ADDR`, `LOCALLLM_GPU_LAYERS` e `LOCALLLM_CTX` (contexto padrão do app: 4096).
|
||||
|
||||
<details>
|
||||
<summary>Variáveis de ambiente da API</summary>
|
||||
|
||||
| Variável | Obrigatória | Descrição |
|
||||
|---|---|---|
|
||||
| `LOCALLLM_MODEL` | Sim | Caminho do `.gguf`; sem ela o engine aborta (`model_path is empty`) |
|
||||
| `LOCALLLM_ADDR` | Não | Endereço de bind; padrão `0.0.0.0:8080` |
|
||||
| `LOCALLLM_MODEL_ALIASES` | Não | Aliases do modelo, separados por vírgula, expostos em `GET /v1/models` |
|
||||
| `LOCALLLM_PROFILE` | Não | `balanced_9b` ou `cpu_fallback`; ausente ou outro valor = `Turbo4B` |
|
||||
| `LOCALLLM_GPU_LAYERS` | Não | Camadas na GPU; padrão do perfil (999 em `Turbo4B` e `balanced_9b`, 0 em `cpu_fallback`) |
|
||||
| `LOCALLLM_CTX` | Não | Janela de contexto; padrão do perfil (131072, 65536 e 16384) |
|
||||
| `LOCALLLM_BATCH` | Não | `n_batch`; sem valor explícito, usa o do perfil limitado a 4096 |
|
||||
| `LOCALLLM_UBATCH` | Não | `n_ubatch`; padrão do perfil |
|
||||
| `LOCALLLM_THREADS` | Não | Threads de geração; padrão do perfil |
|
||||
| `LOCALLLM_THREADS_BATCH` | Não | Threads de prefill; padrão do perfil |
|
||||
| `LOCALLLM_API_KEY` | Não | Liga a autenticação por chave; vazia ou ausente = API aberta |
|
||||
| `LOCALLLM_AUTH_HEADER` | Não | Cabeçalho preferencial da chave; padrão `authorization` (também aceita `x-api-key`) |
|
||||
| `LOCALLLM_AUTH_SCHEME` | Não | Prefixo removido antes da comparação (ex.: `Bearer`) |
|
||||
| `LOCALLLM_CORS_ORIGINS` | Não | Origens permitidas, separadas por vírgula; padrão `*` |
|
||||
| `LOCALLLM_SYSTEM_PROMPT` | Não | Prompt de sistema usado quando a request não traz `system` nem `developer` |
|
||||
| `LOCALLLM_ENABLE_TOOL_CALLS` | Não | Liga ou desliga tool calling; padrão ligado |
|
||||
| `LOCALLLM_LOG_FORMAT` | Não | `json` para logs estruturados; padrão `pretty` |
|
||||
| `LLAMA_DLL_PATH` | Não | Caminho completo da biblioteca do llama.cpp; sobrepõe a resolução automática |
|
||||
| `RUST_LOG` | Não | Filtro de logs do `tracing`; padrão `info` |
|
||||
|
||||
</details>
|
||||
|
||||
## Estado do projeto
|
||||
|
||||
Em desenvolvimento. Pelo [`.planning/ROADMAP.md`](.planning/ROADMAP.md), as fases 1 a 4 estão concluídas, a fase 5 (validação de fontes de modelo) segue aberta com os três planos marcados e a fase 6 (redesign do chat e das Configurações) tem 0 de 3 planos.
|
||||
|
||||
- A compatibilidade com a OpenAI é parcial: `parallel_tool_calls` não é recurso suportado, `content` em partes vira texto e o raciocínio não sai num campo separado (quem separa `<think>…</think>` é o app).
|
||||
- O chat do app usa `sql.js` carregado por CDN e guarda o banco no `localStorage`.
|
||||
- `scripts/build-dev.sh` e `scripts/build-prod.sh` (e as versões `.ps1`) exigem `vendor/llama.cpp`, que não está no repositório.
|
||||
- `inference-server` e `localllm-cli` declaram um binário com o mesmo nome, `localllm`; use `cargo run -p <crate>`.
|
||||
|
||||
Para contribuir, abra uma issue ou um PR e rode os testes da parte que você alterou.
|
||||
|
||||
## Licença
|
||||
|
||||
Sem licença publicada: todos os direitos reservados. O [`Cargo.toml`](Cargo.toml) declara `license = "MIT"`, mas o repositório não inclui o arquivo `LICENSE`. Os binários do llama.cpp e os modelos do Hugging Face seguem a licença de cada autor.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
|
||||
<title id="title">ForgeLM</title>
|
||||
<desc id="desc">ForgeLM: um arquivo .gguf entra no llama.cpp, que divide as camadas entre GPU e CPU, e a resposta sai em streaming pela API local na porta 8080, em /v1/chat/completions.</desc>
|
||||
|
||||
<rect width="1200" height="420" rx="24" fill="#171717"/>
|
||||
|
||||
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||
<text x="64" y="170" font-size="64" font-weight="700" fill="#F3F4F6">ForgeLM</text>
|
||||
<text x="64" y="226" font-size="28" fill="#F3F4F6">Rode modelos .gguf na sua máquina</text>
|
||||
<text x="64" y="264" font-size="28" fill="#F3F4F6">com uma API no estilo OpenAI.</text>
|
||||
<text x="64" y="322" font-size="20" fill="#9CA3AF" font-family="ui-monospace, 'SF Mono', Consolas, monospace">Rust · llama.cpp · Axum · Tauri 2</text>
|
||||
</g>
|
||||
|
||||
<g id="model-file" font-family="ui-monospace, 'SF Mono', Consolas, monospace">
|
||||
<path d="M584 152 h60 l28 28 v88 h-88 z" fill="#262626" stroke="#313944" stroke-width="2"/>
|
||||
<path d="M644 152 v28 h28" fill="none" stroke="#313944" stroke-width="2"/>
|
||||
<text x="628" y="234" font-size="20" fill="#F3F4F6" text-anchor="middle">.gguf</text>
|
||||
<text x="628" y="304" font-size="18" fill="#9CA3AF" text-anchor="middle" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">modelo</text>
|
||||
</g>
|
||||
|
||||
<g id="arrows" fill="none" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M682 210 h26 M700 202 l8 8 l-8 8"/>
|
||||
<path d="M836 210 h20 M848 202 l8 8 l-8 8"/>
|
||||
</g>
|
||||
|
||||
<g id="layers" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||
<text x="772" y="110" font-size="20" font-weight="700" fill="#F3F4F6" text-anchor="middle">llama.cpp</text>
|
||||
<rect x="716" y="126" width="112" height="28" rx="6" fill="#4b86ff"/>
|
||||
<rect x="716" y="162" width="112" height="28" rx="6" fill="#4b86ff"/>
|
||||
<rect x="716" y="198" width="112" height="28" rx="6" fill="#4b86ff"/>
|
||||
<rect x="716" y="234" width="112" height="28" rx="6" fill="#4b86ff"/>
|
||||
<rect x="716" y="270" width="112" height="28" rx="6" fill="#333333"/>
|
||||
<rect x="716" y="306" width="112" height="28" rx="6" fill="#333333"/>
|
||||
<text x="772" y="146" font-size="18" font-weight="700" fill="#0d0d10" text-anchor="middle">GPU</text>
|
||||
<text x="772" y="326" font-size="18" font-weight="700" fill="#F3F4F6" text-anchor="middle">CPU</text>
|
||||
</g>
|
||||
|
||||
<g id="api-stream" font-family="ui-monospace, 'SF Mono', Consolas, monospace">
|
||||
<rect x="864" y="96" width="272" height="244" rx="14" fill="#0d0d10" stroke="#313944" stroke-width="2"/>
|
||||
<circle cx="890" cy="128" r="6" fill="#30d158"/>
|
||||
<text x="906" y="135" font-size="20" fill="#F3F4F6">:8080</text>
|
||||
<text x="1116" y="135" font-size="18" fill="#9CA3AF" text-anchor="end">SSE</text>
|
||||
<path d="M864 154 h272" stroke="#232a33" stroke-width="2"/>
|
||||
<text x="884" y="186" font-size="18" fill="#9CA3AF">/v1/chat/completions</text>
|
||||
<g font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="20" fill="#F3F4F6">
|
||||
<rect x="884" y="204" width="58" height="34" rx="8" fill="#101c2d" stroke="#203451" stroke-width="2"/>
|
||||
<text x="913" y="228" text-anchor="middle">Olá</text>
|
||||
<rect x="950" y="204" width="30" height="34" rx="8" fill="#101c2d" stroke="#203451" stroke-width="2"/>
|
||||
<text x="965" y="228" text-anchor="middle">,</text>
|
||||
<rect x="988" y="204" width="68" height="34" rx="8" fill="#101c2d" stroke="#203451" stroke-width="2"/>
|
||||
<text x="1022" y="228" text-anchor="middle">tudo</text>
|
||||
<rect x="884" y="248" width="62" height="34" rx="8" fill="#101c2d" stroke="#203451" stroke-width="2"/>
|
||||
<text x="915" y="272" text-anchor="middle">bem</text>
|
||||
<rect x="954" y="248" width="30" height="34" rx="8" fill="#101c2d" stroke="#203451" stroke-width="2"/>
|
||||
<text x="969" y="272" text-anchor="middle">?</text>
|
||||
<rect x="994" y="252" width="12" height="26" rx="2" fill="#4b86ff"/>
|
||||
</g>
|
||||
<text x="884" y="318" font-size="18" fill="#9CA3AF">"stream": true</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -11,29 +11,34 @@ pub enum RuntimeProfile {
|
||||
|
||||
impl RuntimeProfile {
|
||||
/// Returns sensible defaults for each profile.
|
||||
///
|
||||
/// `n_ctx` sets the KV cache / context window size. Larger values allow longer
|
||||
/// conversations but consume more VRAM. Override at runtime with `LOCALLLM_CTX`.
|
||||
/// `n_batch` controls the prefill chunk size — kept independent of `n_ctx` to
|
||||
/// avoid pre-allocating the full window as a single batch buffer (OOM risk).
|
||||
pub fn defaults(&self) -> ProfileDefaults {
|
||||
match self {
|
||||
Self::Turbo4B => ProfileDefaults {
|
||||
n_gpu_layers: 999,
|
||||
n_ctx: 4096,
|
||||
n_batch: 512,
|
||||
n_ubatch: 128,
|
||||
n_ctx: 131_072, // 128 K — covers very long conversations; needs ~4 GB KV VRAM at FP16
|
||||
n_batch: 2048, // prefill chunk size; independent of n_ctx
|
||||
n_ubatch: 512,
|
||||
flash_attn: true,
|
||||
n_threads: 8,
|
||||
},
|
||||
Self::Balanced9B => ProfileDefaults {
|
||||
n_gpu_layers: 999,
|
||||
n_ctx: 2048,
|
||||
n_batch: 256,
|
||||
n_ubatch: 64,
|
||||
n_ctx: 65_536, // 64 K
|
||||
n_batch: 2048,
|
||||
n_ubatch: 256,
|
||||
flash_attn: true,
|
||||
n_threads: 8,
|
||||
},
|
||||
Self::CpuFallback => ProfileDefaults {
|
||||
n_gpu_layers: 0,
|
||||
n_ctx: 2048,
|
||||
n_batch: 128,
|
||||
n_ubatch: 32,
|
||||
n_ctx: 16_384, // 16 K — CPU KV cache is RAM-backed; keep conservative
|
||||
n_batch: 512,
|
||||
n_ubatch: 128,
|
||||
flash_attn: false,
|
||||
n_threads: num_cpus(),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ path = "src/main.rs"
|
||||
inference-core = { path = "../inference-core" }
|
||||
api-contracts = { path = "../api-contracts" }
|
||||
metrics = { path = "../metrics" }
|
||||
runtime-deps = { path = "../runtime-deps" }
|
||||
dirs = { workspace = true }
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
hyper.workspace = true
|
||||
|
||||
@@ -1,38 +1,4 @@
|
||||
fn main() {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let target_dir = std::path::Path::new(&out_dir)
|
||||
.ancestors()
|
||||
.nth(3) // Volta 3 níveis de `target/debug/build/inference-server-XYZ/out` para `target/debug/` ou `target/release/`
|
||||
.unwrap();
|
||||
|
||||
let vendor_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("vendor")
|
||||
.join("llama-bin");
|
||||
|
||||
if vendor_dir.exists() {
|
||||
println!(
|
||||
"cargo:warning=Copying DLLs from {:?} to {:?}",
|
||||
vendor_dir, target_dir
|
||||
);
|
||||
|
||||
// Vamos copiar todos os arquivos .dll
|
||||
if let Ok(entries) = std::fs::read_dir(&vendor_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("dll") {
|
||||
let file_name = path.file_name().unwrap();
|
||||
let dest = target_dir.join(file_name);
|
||||
let _ = std::fs::copy(&path, &dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuildar apenas se houver mudanças nessa build file ou nos bins da vendor
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=../../vendor/llama-bin");
|
||||
println!("cargo:rerun-if-changed=../../vendor/llama-runtime/manifest.json");
|
||||
}
|
||||
@@ -3,8 +3,12 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use runtime_deps::{
|
||||
apply_runtime_to_current_process, ensure_llama_runtime, recommended_variant_override_hint,
|
||||
EnsureRuntimeRequest,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use inference_core::{EngineConfig, InferenceEngine, RuntimeProfile};
|
||||
use metrics::counters::PerfCounters;
|
||||
@@ -17,12 +21,25 @@ use routes::{build_router, AppState};
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// ── Tracing ──────────────────────────────────────────────────────────
|
||||
fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
// Set LOCALLLM_LOG_FORMAT=json for machine-readable output (e.g. log aggregators).
|
||||
// Set RUST_LOG=debug for verbose output.
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
match std::env::var("LOCALLLM_LOG_FORMAT").as_deref() {
|
||||
Ok("json") => {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt::layer().json().with_current_span(true).with_span_list(true))
|
||||
.init();
|
||||
}
|
||||
_ => {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt::layer().with_target(true))
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Engine config (reads env vars; sane defaults for dev) ────────────
|
||||
let model_path = std::env::var("LOCALLLM_MODEL")
|
||||
@@ -62,6 +79,45 @@ async fn main() -> Result<()> {
|
||||
let server_config = Arc::new(ServerConfig::from_env(model_name.clone()));
|
||||
|
||||
// ── Load engine ──────────────────────────────────────────────────────
|
||||
tracing::info!(
|
||||
model = %model_path.display(),
|
||||
profile = ?profile,
|
||||
n_gpu_layers = config.n_gpu_layers,
|
||||
n_ctx = config.n_ctx,
|
||||
n_batch = config.n_batch,
|
||||
n_ubatch = config.n_ubatch,
|
||||
n_threads = config.n_threads,
|
||||
n_threads_batch = config.n_threads_batch,
|
||||
flash_attn = config.flash_attn,
|
||||
use_mmap = config.use_mmap,
|
||||
"engine_config"
|
||||
);
|
||||
let binary_path = std::env::current_exe()?;
|
||||
let runtime_cache_root = dirs::data_local_dir()
|
||||
.unwrap_or_else(|| {
|
||||
binary_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.to_path_buf()
|
||||
})
|
||||
.join("ForgeLM");
|
||||
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
|
||||
cache_root: &runtime_cache_root,
|
||||
gpu_layers: config.n_gpu_layers,
|
||||
variant_override: None,
|
||||
binary_path: Some(&binary_path),
|
||||
})
|
||||
.map_err(|error| anyhow::anyhow!(
|
||||
"failed to prepare llama runtime dependencies: {error}. {}",
|
||||
recommended_variant_override_hint()
|
||||
))?;
|
||||
apply_runtime_to_current_process(&runtime);
|
||||
tracing::info!(
|
||||
dll = %runtime.dll_path.display(),
|
||||
variant = runtime.variant_id,
|
||||
source = ?runtime.source,
|
||||
"llama_runtime_ready"
|
||||
);
|
||||
let mut engine = InferenceEngine::load(config)?;
|
||||
engine.warmup()?;
|
||||
|
||||
@@ -81,6 +137,14 @@ async fn main() -> Result<()> {
|
||||
let router = build_router(state);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
tracing::info!(
|
||||
bind_addr = %addr,
|
||||
model = %model_name,
|
||||
api_key_set = std::env::var("LOCALLLM_API_KEY").is_ok(),
|
||||
log_format = std::env::var("LOCALLLM_LOG_FORMAT").as_deref().unwrap_or("pretty"),
|
||||
"server_ready"
|
||||
);
|
||||
|
||||
// ── Startup banner ───────────────────────────────────────────────────
|
||||
let display_addr = if addr.ip().is_unspecified() {
|
||||
format!("http://127.0.0.1:{}", addr.port())
|
||||
@@ -125,8 +189,20 @@ fn env_u32(key: &str, default: u32) -> u32 {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn resolve_batch_size(n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 {
|
||||
explicit_batch.unwrap_or_else(|| n_ctx.max(default_n_batch).max(1))
|
||||
/// Choose the prefill batch size.
|
||||
///
|
||||
/// The batch size controls how many tokens are processed in a single `llama_decode`
|
||||
/// call during prefill. It must not be confused with the context window (`n_ctx`):
|
||||
/// a large `n_ctx` does NOT require an equally large `n_batch` — `decode_prefill`
|
||||
/// already chunks the prompt into `n_batch`-sized slices.
|
||||
///
|
||||
/// Setting `n_batch = n_ctx` for a 128 K context would pre-allocate ~128 K token
|
||||
/// slots in the batch buffer, consuming gigabytes of VRAM at startup before any
|
||||
/// request arrives. Instead we cap auto-selection at `MAX_AUTO_BATCH` and let
|
||||
/// callers pass `LOCALLLM_BATCH` when they need a larger explicit override.
|
||||
fn resolve_batch_size(_n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 {
|
||||
const MAX_AUTO_BATCH: u32 = 4096;
|
||||
explicit_batch.unwrap_or_else(|| default_n_batch.min(MAX_AUTO_BATCH).max(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -134,8 +210,11 @@ mod tests {
|
||||
use super::resolve_batch_size;
|
||||
|
||||
#[test]
|
||||
fn defaults_batch_to_context_size_when_ctx_is_larger() {
|
||||
assert_eq!(resolve_batch_size(16_384, 512, None), 16_384);
|
||||
fn caps_auto_batch_at_max_regardless_of_ctx_size() {
|
||||
// Large n_ctx must NOT bloat n_batch — that would OOM the batch buffer.
|
||||
assert_eq!(resolve_batch_size(131_072, 512, None), 512);
|
||||
assert_eq!(resolve_batch_size(131_072, 4096, None), 4096);
|
||||
assert_eq!(resolve_batch_size(131_072, 8192, None), 4096); // capped at MAX_AUTO_BATCH
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -397,9 +397,17 @@ async fn stream_response(
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream)
|
||||
let response = Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
.into_response();
|
||||
|
||||
axum::response::Response::builder()
|
||||
.status(response.status())
|
||||
.header("content-type", "text/event-stream; charset=utf-8")
|
||||
.header("cache-control", "no-cache")
|
||||
.header("connection", "keep-alive")
|
||||
.body(response.into_body())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn blocking_response(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "runtime-deps"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
@@ -0,0 +1,614 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const MANIFEST_JSON: &str = include_str!("../../../vendor/llama-runtime/manifest.json");
|
||||
const LOCKFILE_NAME: &str = ".install.lock";
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const LOCK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsureRuntimeRequest<'a> {
|
||||
pub cache_root: &'a Path,
|
||||
pub gpu_layers: i32,
|
||||
pub variant_override: Option<&'a str>,
|
||||
pub binary_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedRuntime {
|
||||
pub dll_path: PathBuf,
|
||||
pub library_dir: PathBuf,
|
||||
pub variant_id: String,
|
||||
pub source: RuntimeSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RuntimeSource {
|
||||
EnvOverride,
|
||||
AdjacentBinary,
|
||||
LegacyVendor,
|
||||
ManagedCache,
|
||||
}
|
||||
|
||||
pub fn ensure_llama_runtime(request: &EnsureRuntimeRequest<'_>) -> Result<ResolvedRuntime> {
|
||||
if let Some(runtime) = resolve_from_env_override()? {
|
||||
return Ok(runtime);
|
||||
}
|
||||
|
||||
let manifest = load_manifest()?;
|
||||
let variant = select_variant(&manifest, request.gpu_layers, request.variant_override)?;
|
||||
|
||||
if let Some(runtime) = resolve_from_adjacent_binary(request.binary_path, variant)? {
|
||||
return Ok(runtime);
|
||||
}
|
||||
|
||||
if let Some(runtime) = resolve_from_legacy_vendor(request.binary_path, variant)? {
|
||||
return Ok(runtime);
|
||||
}
|
||||
let install_dir = request
|
||||
.cache_root
|
||||
.join("llama-runtime")
|
||||
.join(&manifest.release)
|
||||
.join(&variant.id);
|
||||
let dll_path = install_dir.join(&variant.dll);
|
||||
|
||||
if installation_is_valid(&install_dir, variant) {
|
||||
return Ok(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: install_dir,
|
||||
variant_id: variant.id.clone(),
|
||||
source: RuntimeSource::ManagedCache,
|
||||
});
|
||||
}
|
||||
|
||||
fs::create_dir_all(install_dir.parent().unwrap_or(request.cache_root)).with_context(|| {
|
||||
format!(
|
||||
"failed to create runtime cache root at {}",
|
||||
request.cache_root.display()
|
||||
)
|
||||
})?;
|
||||
let _lock = InstallationLock::acquire(&install_dir)?;
|
||||
|
||||
if installation_is_valid(&install_dir, variant) {
|
||||
return Ok(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: install_dir,
|
||||
variant_id: variant.id.clone(),
|
||||
source: RuntimeSource::ManagedCache,
|
||||
});
|
||||
}
|
||||
|
||||
install_variant(&install_dir, variant)?;
|
||||
|
||||
Ok(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: install_dir,
|
||||
variant_id: variant.id.clone(),
|
||||
source: RuntimeSource::ManagedCache,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_runtime_to_command(command: &mut Command, runtime: &ResolvedRuntime) {
|
||||
command.env("LLAMA_DLL_PATH", &runtime.dll_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = prepend_path_entry(&runtime.library_dir, std::env::var_os("PATH"));
|
||||
command.env("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_runtime_to_current_process(runtime: &ResolvedRuntime) {
|
||||
std::env::set_var("LLAMA_DLL_PATH", &runtime.dll_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = prepend_path_entry(&runtime.library_dir, std::env::var_os("PATH"));
|
||||
std::env::set_var("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recommended_variant_override_hint() -> &'static str {
|
||||
"Set FORGELM_LLAMA_RUNTIME_VARIANT to a manifest variant id like windows-x64-cpu or windows-x64-cuda-12.4."
|
||||
}
|
||||
|
||||
fn resolve_from_env_override() -> Result<Option<ResolvedRuntime>> {
|
||||
let Ok(raw_path) = std::env::var("LLAMA_DLL_PATH") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dll_path = PathBuf::from(raw_path);
|
||||
if !dll_path.exists() {
|
||||
bail!(
|
||||
"LLAMA_DLL_PATH points to {}, but that file does not exist",
|
||||
dll_path.display()
|
||||
);
|
||||
}
|
||||
let library_dir = dll_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| anyhow!("LLAMA_DLL_PATH must point to a file, not a root path"))?;
|
||||
Ok(Some(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir,
|
||||
variant_id: "env-override".to_string(),
|
||||
source: RuntimeSource::EnvOverride,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_from_adjacent_binary(
|
||||
binary_path: Option<&Path>,
|
||||
variant: &ManifestVariant,
|
||||
) -> Result<Option<ResolvedRuntime>> {
|
||||
let Some(binary_path) = binary_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(binary_dir) = binary_path.parent() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dll_path = binary_dir.join(default_dll_name());
|
||||
if dll_path.exists() && verify_required_files(binary_dir, variant).is_ok() {
|
||||
return Ok(Some(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: binary_dir.to_path_buf(),
|
||||
variant_id: "adjacent-binary".to_string(),
|
||||
source: RuntimeSource::AdjacentBinary,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn resolve_from_legacy_vendor(
|
||||
binary_path: Option<&Path>,
|
||||
variant: &ManifestVariant,
|
||||
) -> Result<Option<ResolvedRuntime>> {
|
||||
let Some(binary_path) = binary_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut dir = binary_path.parent();
|
||||
while let Some(current) = dir {
|
||||
let candidate = current
|
||||
.join("vendor")
|
||||
.join("llama-bin")
|
||||
.join(default_dll_name());
|
||||
let Some(library_dir) = candidate.parent() else {
|
||||
dir = current.parent();
|
||||
continue;
|
||||
};
|
||||
if candidate.exists() && verify_required_files(library_dir, variant).is_ok() {
|
||||
return Ok(Some(ResolvedRuntime {
|
||||
dll_path: candidate.clone(),
|
||||
library_dir: library_dir.to_path_buf(),
|
||||
variant_id: "legacy-vendor".to_string(),
|
||||
source: RuntimeSource::LegacyVendor,
|
||||
}));
|
||||
}
|
||||
dir = current.parent();
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn load_manifest() -> Result<Manifest> {
|
||||
serde_json::from_str(MANIFEST_JSON).context("failed to parse embedded llama runtime manifest")
|
||||
}
|
||||
|
||||
fn select_variant<'a>(
|
||||
manifest: &'a Manifest,
|
||||
gpu_layers: i32,
|
||||
variant_override: Option<&str>,
|
||||
) -> Result<&'a ManifestVariant> {
|
||||
let env_override = std::env::var("FORGELM_LLAMA_RUNTIME_VARIANT").ok();
|
||||
if let Some(override_id) = variant_override.or(env_override.as_deref()) {
|
||||
return manifest
|
||||
.variants
|
||||
.iter()
|
||||
.find(|variant| variant.id == override_id)
|
||||
.ok_or_else(|| anyhow!("unknown runtime variant '{override_id}'"));
|
||||
}
|
||||
|
||||
let target = current_target();
|
||||
let want_gpu = gpu_layers > 0;
|
||||
manifest
|
||||
.variants
|
||||
.iter()
|
||||
.find(|variant| variant.target == target && variant.gpu == want_gpu)
|
||||
.or_else(|| {
|
||||
manifest
|
||||
.variants
|
||||
.iter()
|
||||
.find(|variant| variant.target == target && !variant.gpu)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("no runtime bundle configured for target {target}"))
|
||||
}
|
||||
|
||||
fn install_variant(install_dir: &Path, variant: &ManifestVariant) -> Result<()> {
|
||||
if let Some(parent) = install_dir.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create runtime install parent at {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let staging_dir = install_dir.with_extension("staging");
|
||||
if staging_dir.exists() {
|
||||
fs::remove_dir_all(&staging_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to clean previous staging dir {}",
|
||||
staging_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::create_dir_all(&staging_dir)
|
||||
.with_context(|| format!("failed to create staging dir {}", staging_dir.display()))?;
|
||||
|
||||
for archive in &variant.archives {
|
||||
let archive_path = download_archive_to_file(archive, &staging_dir)?;
|
||||
extract_zip_file(&archive_path, &staging_dir)?;
|
||||
}
|
||||
|
||||
verify_required_files(&staging_dir, variant)?;
|
||||
|
||||
if install_dir.exists() {
|
||||
fs::remove_dir_all(install_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to replace old runtime dir {}",
|
||||
install_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::rename(&staging_dir, install_dir).or_else(|rename_error| {
|
||||
copy_dir_recursive(&staging_dir, install_dir)
|
||||
.and_then(|_| {
|
||||
fs::remove_dir_all(&staging_dir)
|
||||
.with_context(|| format!("failed to remove staging dir {}", staging_dir.display()))
|
||||
})
|
||||
.map_err(|copy_error| {
|
||||
anyhow!(
|
||||
"failed to finalize runtime install via rename ({rename_error}) and copy fallback ({copy_error})"
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_archive_to_file(archive: &ManifestArchive, staging_dir: &Path) -> Result<PathBuf> {
|
||||
let downloads_dir = staging_dir.join(".downloads");
|
||||
fs::create_dir_all(&downloads_dir)
|
||||
.with_context(|| format!("failed to create downloads dir {}", downloads_dir.display()))?;
|
||||
let archive_name = archive
|
||||
.url
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| anyhow!("archive url has no file name: {}", archive.url))?;
|
||||
let archive_path = downloads_dir.join(archive_name);
|
||||
|
||||
let response = ureq::get(&archive.url)
|
||||
.timeout(DOWNLOAD_TIMEOUT)
|
||||
.call()
|
||||
.with_context(|| format!("failed to download {}", archive.url))?;
|
||||
let mut reader = response.into_reader();
|
||||
let mut file = File::create(&archive_path)
|
||||
.with_context(|| format!("failed to create archive file {}", archive_path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut total_bytes = 0u64;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("failed to stream downloaded bytes from {}", archive.url))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
total_bytes += read as u64;
|
||||
if total_bytes > archive.size {
|
||||
bail!(
|
||||
"downloaded archive {} exceeded declared size {} bytes",
|
||||
archive.url,
|
||||
archive.size
|
||||
);
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
file.write_all(&buffer[..read]).with_context(|| {
|
||||
format!(
|
||||
"failed to write downloaded archive to {}",
|
||||
archive_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if total_bytes != archive.size {
|
||||
bail!(
|
||||
"downloaded archive {} size mismatch: expected {} bytes, got {}",
|
||||
archive.url,
|
||||
archive.size,
|
||||
total_bytes
|
||||
);
|
||||
}
|
||||
|
||||
let expected = &archive.sha256;
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != *expected {
|
||||
bail!(
|
||||
"downloaded archive {} failed sha256 verification: expected {}, got {}",
|
||||
archive.url,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
Ok(archive_path)
|
||||
}
|
||||
|
||||
fn extract_zip_file(archive_path: &Path, dest_dir: &Path) -> Result<()> {
|
||||
let file = File::open(archive_path)
|
||||
.with_context(|| format!("failed to open archive {}", archive_path.display()))?;
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(BufReader::new(file)).context("failed to open zip archive")?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.context("failed to read zip entry")?;
|
||||
let Some(relative_path) = entry.enclosed_name().map(|path| path.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output_path = dest_dir.join(relative_path);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output_path).with_context(|| {
|
||||
format!("failed to create extracted dir {}", output_path.display())
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create parent dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut output = File::create(&output_path).with_context(|| {
|
||||
format!("failed to create extracted file {}", output_path.display())
|
||||
})?;
|
||||
io::copy(&mut entry, &mut output)
|
||||
.with_context(|| format!("failed to extract {}", output_path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_required_files(install_dir: &Path, variant: &ManifestVariant) -> Result<()> {
|
||||
for required_file in &variant.required_files {
|
||||
let candidate = install_dir.join(required_file);
|
||||
if !candidate.exists() {
|
||||
bail!(
|
||||
"runtime bundle {} is missing required file {}",
|
||||
variant.id,
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn installation_is_valid(install_dir: &Path, variant: &ManifestVariant) -> bool {
|
||||
install_dir.exists() && verify_required_files(install_dir, variant).is_ok()
|
||||
}
|
||||
|
||||
fn default_dll_name() -> &'static str {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"llama.dll"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"libllama.so"
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"libllama.dylib"
|
||||
}
|
||||
}
|
||||
|
||||
fn current_target() -> &'static str {
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"windows-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
"windows-aarch64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"linux-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"macos-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"macos-aarch64"
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_path_entry(entry: &Path, existing: Option<OsString>) -> OsString {
|
||||
let mut paths = vec![entry.to_path_buf()];
|
||||
if let Some(existing) = existing {
|
||||
paths.extend(std::env::split_paths(&existing));
|
||||
}
|
||||
std::env::join_paths(paths).unwrap_or_else(|_| OsString::from(entry.as_os_str()))
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
||||
fs::create_dir_all(destination)
|
||||
.with_context(|| format!("failed to create {}", destination.display()))?;
|
||||
for entry in
|
||||
fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let source_path = entry.path();
|
||||
let destination_path = destination.join(entry.file_name());
|
||||
if source_path.is_dir() {
|
||||
copy_dir_recursive(&source_path, &destination_path)?;
|
||||
} else {
|
||||
fs::copy(&source_path, &destination_path).with_context(|| {
|
||||
format!(
|
||||
"failed to copy {} to {}",
|
||||
source_path.display(),
|
||||
destination_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct InstallationLock {
|
||||
path: PathBuf,
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl InstallationLock {
|
||||
fn acquire(install_dir: &Path) -> Result<Self> {
|
||||
fs::create_dir_all(install_dir.parent().unwrap_or(install_dir)).with_context(|| {
|
||||
format!("failed to create lock parent for {}", install_dir.display())
|
||||
})?;
|
||||
|
||||
let lock_path = install_dir.join(LOCKFILE_NAME);
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
path: lock_path,
|
||||
_file: file,
|
||||
})
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
if lock_is_stale(&lock_path)? {
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
continue;
|
||||
}
|
||||
if started.elapsed() >= LOCK_TIMEOUT {
|
||||
bail!(
|
||||
"timed out waiting for runtime install lock at {}",
|
||||
lock_path.display()
|
||||
);
|
||||
}
|
||||
thread::sleep(LOCK_POLL_INTERVAL);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| {
|
||||
format!(
|
||||
"failed to acquire runtime install lock at {}",
|
||||
lock_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InstallationLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_is_stale(lock_path: &Path) -> Result<bool> {
|
||||
let metadata = match fs::metadata(lock_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| {
|
||||
format!("failed to read lock metadata for {}", lock_path.display())
|
||||
})
|
||||
}
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.with_context(|| format!("failed to read lock timestamp for {}", lock_path.display()))?;
|
||||
Ok(modified.elapsed().unwrap_or_default() > LOCK_TIMEOUT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Manifest {
|
||||
release: String,
|
||||
variants: Vec<ManifestVariant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManifestVariant {
|
||||
id: String,
|
||||
target: String,
|
||||
gpu: bool,
|
||||
dll: String,
|
||||
archives: Vec<ManifestArchive>,
|
||||
required_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManifestArchive {
|
||||
url: String,
|
||||
sha256: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{current_target, load_manifest, prepend_path_entry, select_variant, Manifest};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
load_manifest().expect("manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_cpu_variant_for_local_cpu_mode() {
|
||||
let manifest = manifest();
|
||||
let variant = select_variant(&manifest, 0, None).expect("variant");
|
||||
assert_eq!(variant.target, current_target());
|
||||
assert!(!variant.gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_gpu_variant_when_gpu_layers_are_requested() {
|
||||
let manifest = manifest();
|
||||
let variant = select_variant(&manifest, 32, None).expect("variant");
|
||||
assert_eq!(variant.target, current_target());
|
||||
assert!(variant.gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_explicit_variant_override() {
|
||||
let manifest = manifest();
|
||||
let variant = select_variant(&manifest, 0, Some("windows-x64-cpu")).expect("variant");
|
||||
assert_eq!(variant.id, "windows-x64-cpu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepends_runtime_dir_to_existing_path() {
|
||||
let joined = prepend_path_entry(
|
||||
Path::new("C:/runtime"),
|
||||
Some(OsString::from("C:/windows;C:/tools")),
|
||||
);
|
||||
let rendered = joined.to_string_lossy();
|
||||
assert!(rendered.starts_with("C:/runtime"));
|
||||
assert!(rendered.contains("C:/windows"));
|
||||
}
|
||||
}
|
||||
Generated
+939
File diff suppressed because it is too large.
Load diff
@@ -11,10 +11,12 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.0.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -25,7 +27,10 @@
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.2"
|
||||
|
||||
Generated
+590
@@ -45,15 +45,32 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.1
|
||||
version: 4.7.0([email protected])
|
||||
jsdom:
|
||||
specifier: ^29.0.1
|
||||
version: 29.0.1
|
||||
typescript:
|
||||
specifier: ^5.5.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.4.8
|
||||
version: 5.4.21
|
||||
vitest:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2([email protected])([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -137,6 +154,46 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bramus/[email protected]':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -275,6 +332,15 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||
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
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -432,6 +498,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
@@ -526,6 +595,12 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
@@ -553,11 +628,47 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@@ -566,12 +677,24 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -581,12 +704,22 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -596,6 +729,22 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -609,9 +758,25 @@ packages:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -626,14 +791,24 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -645,13 +820,30 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
@@ -665,11 +857,19 @@ packages:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -677,15 +877,61 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -723,11 +969,92 @@ packages:
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-color-parser': 4.0.2(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
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
|
||||
|
||||
'@asamuzakjp/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -840,6 +1167,34 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bramus/[email protected]':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.0.2
|
||||
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -909,6 +1264,8 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1005,6 +1362,8 @@ snapshots:
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@tauri-apps/[email protected]': {}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
@@ -1079,6 +1438,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
dompurify: 3.3.3
|
||||
@@ -1111,8 +1477,55 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.2
|
||||
'@vitest/utils': 4.1.2
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.2
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.2
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.2
|
||||
'@vitest/utils': 4.1.2
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.2
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.11
|
||||
@@ -1123,20 +1536,40 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
@@ -1165,6 +1598,16 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -1172,8 +1615,42 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.1.1
|
||||
'@asamuzakjp/dom-selector': 7.0.4
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.2([email protected])
|
||||
'@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.6
|
||||
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
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1182,26 +1659,46 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -1214,6 +1711,8 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -1245,16 +1744,55 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tldts-core: 7.0.27
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tldts: 7.0.27
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
@@ -1269,4 +1807,56 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.2
|
||||
'@vitest/mocker': 4.1.2([email protected])
|
||||
'@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.4
|
||||
std-env: 4.0.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 5.4.21
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
jsdom: 29.0.1
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Generated
+198
-8
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -521,6 +530,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -565,13 +585,34 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -582,7 +623,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -830,12 +871,14 @@ name = "forgelm-chat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"runtime-deps",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1380,7 +1423,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2787,6 +2830,17 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -2882,7 +2936,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2957,6 +3011,19 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runtime-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -2978,6 +3045,7 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3655,7 +3723,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -3705,7 +3773,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -4268,7 +4336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -4366,6 +4434,24 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4678,6 +4764,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
@@ -4917,6 +5012,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -4968,6 +5072,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5025,6 +5144,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5043,6 +5168,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5061,6 +5192,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5091,6 +5228,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5109,6 +5252,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5127,6 +5276,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5145,6 +5300,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5295,7 +5456,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
@@ -5453,8 +5614,37 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
@@ -25,6 +25,8 @@ tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "sync", "time"] }
|
||||
runtime-deps = { path = "../../crates/runtime-deps" }
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
mod gpu;
|
||||
@@ -9,9 +11,13 @@ 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};
|
||||
use gpu::query_gpu;
|
||||
use model_library::huggingface::search_hugging_face_models;
|
||||
use model_library::huggingface::{
|
||||
cancel_hugging_face_download, search_hugging_face_models, start_hugging_face_download,
|
||||
HuggingFaceDownloadState,
|
||||
};
|
||||
use model_library::local_index::list_entries;
|
||||
use model_library::paths::{managed_models_dir, model_library_index_path};
|
||||
use model_library::paths::{managed_models_dir, model_library_index_path, resolve_models_dir};
|
||||
use runtime::config::{load_runtime_config, save_runtime_config};
|
||||
use model_library::types::{ModelLibraryEntry, ModelLibraryState};
|
||||
use model_files::{import_model, pick_model_files, pick_models_dir, scan_models};
|
||||
|
||||
@@ -80,6 +86,105 @@ async fn search_hugging_face_models_command(
|
||||
search_hugging_face_models(&query, token.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_hugging_face_download_command(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<HuggingFaceDownloadState>>,
|
||||
repo: String,
|
||||
file_name: String,
|
||||
token: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
// Load the user-configured models_dir (defaults to ./models if None/empty)
|
||||
let config = load_runtime_config(&app)?;
|
||||
let models_root = resolve_models_dir(config.models_dir.as_deref());
|
||||
|
||||
start_hugging_face_download(
|
||||
app,
|
||||
state.inner().clone(),
|
||||
repo,
|
||||
file_name,
|
||||
token,
|
||||
models_root,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel_hugging_face_download_command(
|
||||
state: State<'_, Arc<HuggingFaceDownloadState>>,
|
||||
download_id: String,
|
||||
) -> Result<(), String> {
|
||||
cancel_hugging_face_download(state.inner(), &download_id)
|
||||
}
|
||||
|
||||
/// Swap the active model: stops the runtime, updates model_path, restarts.
|
||||
/// This is the hot-swap primitive — used when user picks a different local model.
|
||||
#[tauri::command]
|
||||
async fn swap_model_command(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
new_model_path: String,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
// Stop first — this is a no-op if already stopped
|
||||
stop_runtime(state.clone())?;
|
||||
|
||||
// Load, update model_path, save
|
||||
let mut config = load_runtime_config(&app)?;
|
||||
config.model_path = Some(new_model_path);
|
||||
save_runtime_config(&app, &config)
|
||||
.map_err(|e| format!("swap save failed: {e}"))?;
|
||||
|
||||
// Restart with the updated config
|
||||
start_runtime(app, state)
|
||||
}
|
||||
|
||||
/// Delete a local .gguf model file from disk.
|
||||
#[tauri::command]
|
||||
fn delete_model_file(file_path: String) -> Result<(), String> {
|
||||
let path = std::path::Path::new(&file_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("File not found: {file_path}"));
|
||||
}
|
||||
std::fs::remove_file(path).map_err(|e| format!("Delete failed: {e}"))
|
||||
}
|
||||
|
||||
/// Open the folder containing a model file in the native file explorer
|
||||
/// and optionally select/highlight the file.
|
||||
#[tauri::command]
|
||||
fn open_model_folder_command(file_path: String) -> Result<(), String> {
|
||||
let path = std::path::Path::new(&file_path);
|
||||
let _folder = path.parent().unwrap_or(path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.args(["/select,", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open Explorer: {e}"))?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.args(["-R", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open Finder: {e}"))?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(folder)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file manager: {e}"))?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = folder; // folder is only used on some platforms
|
||||
return Err("Unsupported platform".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_model_library_entries_command(app: AppHandle) -> Result<Vec<ModelLibraryEntry>, String> {
|
||||
let app_data_dir = app
|
||||
@@ -112,6 +217,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(RuntimeState::new())
|
||||
.manage(Arc::new(HuggingFaceDownloadState::default()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
query_gpu,
|
||||
scan_models,
|
||||
@@ -119,6 +225,11 @@ pub fn run() {
|
||||
pick_model_files,
|
||||
import_model,
|
||||
search_hugging_face_models_command,
|
||||
start_hugging_face_download_command,
|
||||
cancel_hugging_face_download_command,
|
||||
swap_model_command,
|
||||
delete_model_file,
|
||||
open_model_folder_command,
|
||||
list_model_library_entries_command,
|
||||
get_model_library_state_command,
|
||||
bootstrap_runtime_command,
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::path::Path;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::model_library::paths::resolve_models_dir;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModelFile {
|
||||
pub name: String,
|
||||
@@ -34,23 +36,64 @@ fn scan_model_dir(path: &Path) -> Result<Vec<ModelFile>, String> {
|
||||
return Err(format!("Not a directory: {}", path.display()));
|
||||
}
|
||||
|
||||
// Default scan is top-level only (no recursion into subdirectories)
|
||||
scan_model_dir_top_level(path)
|
||||
}
|
||||
|
||||
/// Scans only the immediate children of `path` (depth=1, no recursion).
|
||||
/// Returns only files with a `.gguf` extension (case-insensitive), sorted by name.
|
||||
fn scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("Directory not found: {}", path.display()));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Not a directory: {}", path.display()));
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?;
|
||||
let mut models = Vec::new();
|
||||
let mut models: Vec<ModelFile> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.is_file()
|
||||
&& p.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|p| model_file_from_path(&p))
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// Recursively scans all subdirectories for .gguf files.
|
||||
/// Kept for future use when deep scanning may be needed.
|
||||
#[allow(dead_code)]
|
||||
fn scan_model_dir_recursive(dir: &Path, models: &mut Vec<ModelFile>) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
if path.is_dir() {
|
||||
// Skip hidden directories (e.g. .git, .cache)
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
scan_model_dir_recursive(&path, models)?;
|
||||
} else if path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
models.push(model_file_from_path(&path));
|
||||
}
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(models)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
|
||||
@@ -86,7 +129,12 @@ fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
|
||||
scan_model_dir(Path::new(&dir))
|
||||
let path = if dir.trim().is_empty() {
|
||||
resolve_models_dir(None)
|
||||
} else {
|
||||
resolve_models_dir(Some(&dir))
|
||||
};
|
||||
scan_model_dir_top_level(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -171,6 +219,63 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_returns_only_top_level_gguf() {
|
||||
// Tests that scan_model_dir (now using top-level scan) does NOT recurse into subdirectories
|
||||
let dir = temp_path("scan-models-top-level");
|
||||
let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF");
|
||||
std::fs::create_dir_all(&sub).expect("create nested dirs");
|
||||
std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model");
|
||||
std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model");
|
||||
std::fs::write(sub.join("notes.txt"), b"ignore").expect("write non-model file");
|
||||
|
||||
let models = scan_model_dir(&dir).expect("scan model dir");
|
||||
let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect();
|
||||
|
||||
// Only top-level model should be returned, NOT nested ones
|
||||
assert_eq!(names, vec!["top-level.gguf"]);
|
||||
assert!(models.iter().all(|m| m.path.ends_with(".gguf")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_recursive_finds_nested_gguf() {
|
||||
// Tests that the recursive scan function still works for future use
|
||||
use super::scan_model_dir_recursive;
|
||||
|
||||
let dir = temp_path("scan-models-recursive-fn");
|
||||
let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF");
|
||||
std::fs::create_dir_all(&sub).expect("create nested dirs");
|
||||
std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model");
|
||||
std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model");
|
||||
|
||||
let mut models = Vec::new();
|
||||
scan_model_dir_recursive(&dir, &mut models).expect("recursive scan");
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["nested-model.gguf", "top-level.gguf"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_skips_hidden_directories() {
|
||||
let dir = temp_path("scan-models-hidden");
|
||||
let hidden = dir.join(".git");
|
||||
std::fs::create_dir_all(&hidden).expect("create hidden dir");
|
||||
std::fs::write(dir.join("visible.gguf"), b"demo").expect("write visible model");
|
||||
std::fs::write(hidden.join("secret.gguf"), b"demo").expect("write hidden model");
|
||||
|
||||
let models = scan_model_dir(&dir).expect("scan model dir");
|
||||
let names: Vec<_> = models.into_iter().map(|m| m.name).collect();
|
||||
|
||||
assert_eq!(names, vec!["visible.gguf"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_model_file_returns_existing_model_when_source_matches_destination() {
|
||||
let dir = temp_path("import-model");
|
||||
|
||||
@@ -1,7 +1,69 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use reqwest::header::{AUTHORIZATION, USER_AGENT};
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::{fs, io::AsyncWriteExt};
|
||||
|
||||
use super::types::{HuggingFaceModelResult, HuggingFaceVerifiedFile};
|
||||
use super::types::{
|
||||
HuggingFaceDownloadPhase, HuggingFaceDownloadProgress, HuggingFaceModelResult,
|
||||
HuggingFaceVerifiedFile,
|
||||
};
|
||||
|
||||
pub const HUGGING_FACE_DOWNLOAD_EVENT: &str = "hugging-face-download-progress";
|
||||
pub const HUGGING_FACE_USER_AGENT: &str = "ForgeLM/0.1";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HuggingFaceDownloadState {
|
||||
next_id: std::sync::atomic::AtomicU64,
|
||||
cancellations: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl HuggingFaceDownloadState {
|
||||
pub fn start_download(&self) -> (String, Arc<AtomicBool>) {
|
||||
let sequence = self
|
||||
.next_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
+ 1;
|
||||
let download_id = format!("hf-download-{sequence}");
|
||||
let cancellation = Arc::new(AtomicBool::new(false));
|
||||
let mut guard = self
|
||||
.cancellations
|
||||
.lock()
|
||||
.expect("hugging face download state poisoned");
|
||||
guard.insert(download_id.clone(), cancellation.clone());
|
||||
(download_id, cancellation)
|
||||
}
|
||||
|
||||
pub fn cancel(&self, download_id: &str) -> bool {
|
||||
let mut guard = self
|
||||
.cancellations
|
||||
.lock()
|
||||
.expect("hugging face download state poisoned");
|
||||
if let Some(flag) = guard.remove(download_id) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&self, download_id: &str) {
|
||||
let mut guard = self
|
||||
.cancellations
|
||||
.lock()
|
||||
.expect("hugging face download state poisoned");
|
||||
guard.remove(download_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct HubSibling {
|
||||
@@ -24,8 +86,359 @@ fn quantization_label(path: &str) -> Option<String> {
|
||||
stem.rsplit_once('-').map(|(_, suffix)| suffix.to_string())
|
||||
}
|
||||
|
||||
fn validate_relative_segments(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{label} cannot be empty"));
|
||||
}
|
||||
if value.contains('\\') {
|
||||
return Err(format!("{label} contains invalid separators"));
|
||||
}
|
||||
|
||||
let segments: Vec<String> = value.split('/').map(str::to_string).collect();
|
||||
if segments
|
||||
.iter()
|
||||
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
|
||||
{
|
||||
return Err(format!("{label} contains invalid path segments"));
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn managed_download_destination(
|
||||
managed_root: &Path,
|
||||
repo: &str,
|
||||
file_name: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let repo_segments = validate_relative_segments(repo, "repo")?;
|
||||
let file_segments = validate_relative_segments(file_name, "file name")?;
|
||||
|
||||
let mut destination = managed_root.to_path_buf();
|
||||
for segment in repo_segments.into_iter().chain(file_segments.into_iter()) {
|
||||
destination.push(segment);
|
||||
}
|
||||
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
pub fn hugging_face_resolve_url(repo: &str, file_name: &str) -> String {
|
||||
format!("https://huggingface.co/{repo}/resolve/main/{file_name}")
|
||||
}
|
||||
|
||||
pub fn download_progress_mib(bytes: u64) -> f64 {
|
||||
bytes as f64 / 1_048_576.0
|
||||
}
|
||||
|
||||
pub fn download_progress_percentage(downloaded_bytes: u64, total_bytes: Option<u64>) -> f64 {
|
||||
match total_bytes {
|
||||
Some(total) if total > 0 => {
|
||||
((downloaded_bytes as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn download_speed_mib_per_second(bytes_delta: u64, elapsed: Duration) -> f64 {
|
||||
let seconds = elapsed.as_secs_f64();
|
||||
if seconds <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
download_progress_mib(bytes_delta) / seconds
|
||||
}
|
||||
|
||||
fn emit_download_progress(app: &AppHandle, payload: &HuggingFaceDownloadProgress) -> Result<(), String> {
|
||||
app.emit(HUGGING_FACE_DOWNLOAD_EVENT, payload)
|
||||
.map_err(|error| format!("failed to emit download progress: {error}"))
|
||||
}
|
||||
|
||||
fn build_progress_payload(
|
||||
download_id: &str,
|
||||
repo: &str,
|
||||
file_name: &str,
|
||||
phase: HuggingFaceDownloadPhase,
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: Option<u64>,
|
||||
download_speed: f64,
|
||||
destination_path: Option<&Path>,
|
||||
message: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> HuggingFaceDownloadProgress {
|
||||
HuggingFaceDownloadProgress {
|
||||
download_id: download_id.to_string(),
|
||||
repo: repo.to_string(),
|
||||
file_name: file_name.to_string(),
|
||||
phase,
|
||||
progress_percentage: download_progress_percentage(downloaded_bytes, total_bytes),
|
||||
downloaded_mb: download_progress_mib(downloaded_bytes),
|
||||
total_mb: total_bytes.map(download_progress_mib),
|
||||
download_speed,
|
||||
destination_path: destination_path.map(|path| path.to_string_lossy().to_string()),
|
||||
message,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_if_exists(path: &Path) {
|
||||
if fs::metadata(path).await.is_ok() {
|
||||
let _ = fs::remove_file(path).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_hugging_face_download(
|
||||
app: AppHandle,
|
||||
state: Arc<HuggingFaceDownloadState>,
|
||||
download_id: String,
|
||||
repo: String,
|
||||
file_name: String,
|
||||
token: Option<String>,
|
||||
cancellation: Arc<AtomicBool>,
|
||||
managed_root: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
let destination = managed_download_destination(&managed_root, &repo, &file_name)?;
|
||||
println!(
|
||||
"[HF Download] download_id={} repo={} file={} destination={}",
|
||||
download_id,
|
||||
repo,
|
||||
file_name,
|
||||
destination.display()
|
||||
);
|
||||
let temp_path = destination.with_extension("part");
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| "failed to resolve download destination".to_string())?;
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|error| format!("failed to prepare download directory: {error}"))?;
|
||||
|
||||
println!(
|
||||
"[HF Download] download_id={} directory ready, initiating HTTP request to {}",
|
||||
download_id,
|
||||
hugging_face_resolve_url(&repo, &file_name)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client
|
||||
.get(hugging_face_resolve_url(&repo, &file_name))
|
||||
.header(USER_AGENT, HUGGING_FACE_USER_AGENT);
|
||||
|
||||
if let Some(value) = token.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {value}"));
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Hugging Face download request failed: {error}"))?
|
||||
.error_for_status()
|
||||
.map_err(|error| format!("Hugging Face download request failed: {error}"))?;
|
||||
|
||||
let total_bytes = response.content_length();
|
||||
|
||||
println!(
|
||||
"[HF Download] download_id={} response received, content_length={:?}",
|
||||
download_id,
|
||||
total_bytes
|
||||
);
|
||||
|
||||
emit_download_progress(
|
||||
&app,
|
||||
&build_progress_payload(
|
||||
&download_id,
|
||||
&repo,
|
||||
&file_name,
|
||||
HuggingFaceDownloadPhase::Started,
|
||||
0,
|
||||
total_bytes,
|
||||
0.0,
|
||||
Some(&destination),
|
||||
Some("Download started".to_string()),
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut stream = response;
|
||||
let mut file = fs::File::create(&temp_path)
|
||||
.await
|
||||
.map_err(|error| format!("failed to create temp download file: {error}"))?;
|
||||
let mut downloaded_bytes = 0_u64;
|
||||
let mut last_emit = Instant::now();
|
||||
let mut last_emit_bytes = 0_u64;
|
||||
|
||||
loop {
|
||||
if cancellation.load(Ordering::Relaxed) {
|
||||
println!(
|
||||
"[HF Download] download_id={} CANCELLED at bytes={}/{:?}",
|
||||
download_id, downloaded_bytes, total_bytes
|
||||
);
|
||||
let _ = file.flush().await;
|
||||
drop(file);
|
||||
remove_if_exists(&temp_path).await;
|
||||
emit_download_progress(
|
||||
&app,
|
||||
&build_progress_payload(
|
||||
&download_id,
|
||||
&repo,
|
||||
&file_name,
|
||||
HuggingFaceDownloadPhase::Cancelled,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
0.0,
|
||||
Some(&destination),
|
||||
Some("Download cancelled".to_string()),
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
state.finish(&download_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let next_chunk = stream
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("failed to read download stream: {error}"))?;
|
||||
|
||||
let Some(chunk) = next_chunk else {
|
||||
break;
|
||||
};
|
||||
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|error| format!("failed to write download chunk: {error}"))?;
|
||||
|
||||
downloaded_bytes += chunk.len() as u64;
|
||||
let elapsed = last_emit.elapsed();
|
||||
if elapsed >= Duration::from_millis(500) {
|
||||
let speed = download_speed_mib_per_second(downloaded_bytes - last_emit_bytes, elapsed);
|
||||
emit_download_progress(
|
||||
&app,
|
||||
&build_progress_payload(
|
||||
&download_id,
|
||||
&repo,
|
||||
&file_name,
|
||||
HuggingFaceDownloadPhase::Progress,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
speed,
|
||||
Some(&destination),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
last_emit = Instant::now();
|
||||
last_emit_bytes = downloaded_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|error| format!("failed to flush download file: {error}"))?;
|
||||
drop(file);
|
||||
|
||||
remove_if_exists(&destination).await;
|
||||
fs::rename(&temp_path, &destination)
|
||||
.await
|
||||
.map_err(|error| format!("failed to finalize download file: {error}"))?;
|
||||
|
||||
let elapsed = last_emit.elapsed();
|
||||
let speed = download_speed_mib_per_second(downloaded_bytes - last_emit_bytes, elapsed);
|
||||
|
||||
println!(
|
||||
"[HF Download] download_id={} COMPLETED downloaded_bytes={} destination={}",
|
||||
download_id,
|
||||
downloaded_bytes,
|
||||
destination.display()
|
||||
);
|
||||
|
||||
emit_download_progress(
|
||||
&app,
|
||||
&build_progress_payload(
|
||||
&download_id,
|
||||
&repo,
|
||||
&file_name,
|
||||
HuggingFaceDownloadPhase::Completed,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
speed,
|
||||
Some(&destination),
|
||||
Some("Download completed".to_string()),
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
|
||||
state.finish(&download_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_hugging_face_download(
|
||||
app: AppHandle,
|
||||
state: Arc<HuggingFaceDownloadState>,
|
||||
repo: String,
|
||||
file_name: String,
|
||||
token: Option<String>,
|
||||
managed_root: PathBuf,
|
||||
) -> Result<String, String> {
|
||||
let (download_id, cancellation) = state.start_download();
|
||||
let task_app = app.clone();
|
||||
let task_state = state.clone();
|
||||
let task_download_id = download_id.clone();
|
||||
let task_repo = repo.clone();
|
||||
let task_file_name = file_name.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = run_hugging_face_download(
|
||||
task_app.clone(),
|
||||
task_state.clone(),
|
||||
task_download_id.clone(),
|
||||
task_repo.clone(),
|
||||
task_file_name.clone(),
|
||||
token,
|
||||
cancellation,
|
||||
managed_root,
|
||||
)
|
||||
.await
|
||||
{
|
||||
println!(
|
||||
"[HF Download] download_id={} ERROR: {}",
|
||||
task_download_id, error
|
||||
);
|
||||
task_state.finish(&task_download_id);
|
||||
let _ = emit_download_progress(
|
||||
&task_app,
|
||||
&build_progress_payload(
|
||||
&task_download_id,
|
||||
&task_repo,
|
||||
&task_file_name,
|
||||
HuggingFaceDownloadPhase::Error,
|
||||
0,
|
||||
None,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
Some(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(download_id)
|
||||
}
|
||||
|
||||
pub fn cancel_hugging_face_download(
|
||||
state: &HuggingFaceDownloadState,
|
||||
download_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if state.cancel(download_id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("download {download_id} was not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn filter_verified_gguf_files(payload: &str) -> Result<Vec<HuggingFaceVerifiedFile>, serde_json::Error> {
|
||||
pub fn filter_verified_gguf_files(
|
||||
payload: &str,
|
||||
) -> Result<Vec<HuggingFaceVerifiedFile>, serde_json::Error> {
|
||||
let model: HubModel = serde_json::from_str(payload)?;
|
||||
Ok(verified_files_from_model(model))
|
||||
}
|
||||
@@ -66,7 +479,7 @@ pub async fn search_hugging_face_models(
|
||||
("limit", "12"),
|
||||
("full", "true"),
|
||||
])
|
||||
.header(USER_AGENT, "ForgeLM/0.1");
|
||||
.header(USER_AGENT, HUGGING_FACE_USER_AGENT);
|
||||
|
||||
if let Some(value) = token.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {value}"));
|
||||
@@ -109,7 +522,12 @@ pub async fn search_hugging_face_models(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::filter_verified_gguf_files;
|
||||
use std::path::Path;
|
||||
|
||||
use super::{
|
||||
download_progress_mib, download_progress_percentage, filter_verified_gguf_files,
|
||||
hugging_face_resolve_url, managed_download_destination,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn search_hf_gguf_filters_verified_files_and_excludes_mmproj() {
|
||||
@@ -132,4 +550,52 @@ mod tests {
|
||||
assert_eq!(files[1].path, "subdir/model-q8_0.gguf");
|
||||
assert_eq!(files[1].quantization.as_deref(), Some("q8_0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_direct_hugging_face_resolve_urls() {
|
||||
assert_eq!(
|
||||
hugging_face_resolve_url("microsoft/phi-4-mini", "phi-4-q4.gguf"),
|
||||
"https://huggingface.co/microsoft/phi-4-mini/resolve/main/phi-4-q4.gguf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_bytes_to_mebibytes_for_progress_reporting() {
|
||||
assert_eq!(download_progress_mib(1_048_576), 1.0);
|
||||
assert_eq!(download_progress_mib(2_621_440), 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculates_progress_percentage_from_content_length() {
|
||||
assert_eq!(download_progress_percentage(524_288, Some(1_048_576)), 50.0);
|
||||
assert_eq!(download_progress_percentage(1_048_576, Some(1_048_576)), 100.0);
|
||||
assert_eq!(download_progress_percentage(1_048_576, None), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_managed_destination_from_repo_and_file_path() {
|
||||
let destination = managed_download_destination(
|
||||
Path::new("/models"),
|
||||
"microsoft/phi-4",
|
||||
"quantized/phi-4-q4.gguf",
|
||||
)
|
||||
.expect("destination path");
|
||||
|
||||
assert_eq!(
|
||||
destination,
|
||||
Path::new("/models")
|
||||
.join("microsoft")
|
||||
.join("phi-4")
|
||||
.join("quantized")
|
||||
.join("phi-4-q4.gguf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_directory_segments_in_download_paths() {
|
||||
let error = managed_download_destination(Path::new("/models"), "microsoft/../phi-4", "model.gguf")
|
||||
.expect_err("invalid path should fail");
|
||||
|
||||
assert!(error.contains("invalid path segments"));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub fn load_index(path: &Path) -> Result<ModelLibraryIndex, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save_index(path: &Path, index: &ModelLibraryIndex) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
|
||||
@@ -3,6 +3,18 @@ use std::path::{Path, PathBuf};
|
||||
const MODEL_LIBRARY_DIR: &str = "model-library";
|
||||
const INDEX_FILE: &str = "index.json";
|
||||
const MANAGED_MODELS_DIR: &str = "managed-models";
|
||||
const DEFAULT_MODELS_DIR: &str = "models";
|
||||
|
||||
/// Resolves the models directory from the user-configured `models_dir`.
|
||||
/// - `None` → `./models` (relative to the app's working directory)
|
||||
/// - `Some("")` → `./models`
|
||||
/// - `Some(path)` → that exact path
|
||||
pub fn resolve_models_dir(models_dir: Option<&str>) -> PathBuf {
|
||||
match models_dir {
|
||||
None | Some("") => PathBuf::from(DEFAULT_MODELS_DIR),
|
||||
Some(path) => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_library_dir(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join(MODEL_LIBRARY_DIR)
|
||||
@@ -12,6 +24,9 @@ pub fn model_library_index_path(app_data_dir: &Path) -> PathBuf {
|
||||
model_library_dir(app_data_dir).join(INDEX_FILE)
|
||||
}
|
||||
|
||||
/// Legacy path for the managed-models location inside AppData.
|
||||
/// Used only for index persistence — downloads now go to the user-configured
|
||||
/// `models_dir` (defaults to `./models` in the project root).
|
||||
pub fn managed_models_dir(app_data_dir: &Path) -> PathBuf {
|
||||
model_library_dir(app_data_dir).join(MANAGED_MODELS_DIR)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,32 @@ pub struct HuggingFaceModelResult {
|
||||
pub verified_files: Vec<HuggingFaceVerifiedFile>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HuggingFaceDownloadPhase {
|
||||
Started,
|
||||
Progress,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HuggingFaceDownloadProgress {
|
||||
pub download_id: String,
|
||||
pub repo: String,
|
||||
pub file_name: String,
|
||||
pub phase: HuggingFaceDownloadPhase,
|
||||
pub progress_percentage: f64,
|
||||
pub downloaded_mb: f64,
|
||||
pub total_mb: Option<f64>,
|
||||
pub download_speed: f64,
|
||||
pub destination_path: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ModelLibraryState {
|
||||
|
||||
@@ -118,7 +118,7 @@ fn start_runtime_inner(
|
||||
inner.config.clone()
|
||||
};
|
||||
|
||||
let child = match spawn_localllm(&config) {
|
||||
let child = match spawn_localllm(app, &config) {
|
||||
Ok(child) => child,
|
||||
Err(message) => {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::types::RuntimeConfig;
|
||||
use runtime_deps::{
|
||||
apply_runtime_to_command, ensure_llama_runtime, recommended_variant_override_hint,
|
||||
EnsureRuntimeRequest,
|
||||
};
|
||||
|
||||
pub fn find_localllm_binary() -> Option<PathBuf> {
|
||||
let exe_name = if cfg!(target_os = "windows") {
|
||||
@@ -52,7 +58,17 @@ pub fn find_localllm_binary() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
|
||||
fn runtime_cache_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve app data dir: {error}"))?;
|
||||
std::fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|error| format!("Cannot create app data dir: {error}"))?;
|
||||
Ok(app_data_dir.join("runtime-cache"))
|
||||
}
|
||||
|
||||
pub fn spawn_localllm(app: &AppHandle, config: &RuntimeConfig) -> Result<Child, String> {
|
||||
let model_path = config
|
||||
.model_path
|
||||
.as_deref()
|
||||
@@ -65,11 +81,28 @@ pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Command::new(&binary_path)
|
||||
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
|
||||
cache_root: &runtime_cache_root(app)?,
|
||||
gpu_layers: config.gpu_layers,
|
||||
variant_override: None,
|
||||
binary_path: Some(&binary_path),
|
||||
})
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Failed to prepare llama runtime dependencies: {error}\n{}",
|
||||
recommended_variant_override_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut command = Command::new(&binary_path);
|
||||
command
|
||||
.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())
|
||||
.env("LOCALLLM_CTX", config.ctx_size.to_string());
|
||||
apply_runtime_to_command(&mut command, &runtime);
|
||||
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model_library::types::RemotePreset;
|
||||
|
||||
pub const DEFAULT_CTX_SIZE: u32 = 4096;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionMode {
|
||||
@@ -102,7 +104,7 @@ impl Default for RuntimeConfig {
|
||||
models_dir: None,
|
||||
addr: Self::default_addr(),
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
ctx_size: DEFAULT_CTX_SIZE,
|
||||
extra_model_paths: Vec::new(),
|
||||
selected_model_id: None,
|
||||
remote_presets: Vec::new(),
|
||||
@@ -113,7 +115,14 @@ impl Default for RuntimeConfig {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConnectionMode, RuntimeConfig};
|
||||
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE};
|
||||
|
||||
#[test]
|
||||
fn runtime_config_defaults_to_safe_context_window() {
|
||||
let config = RuntimeConfig::default();
|
||||
|
||||
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_deserializes_old_shape_with_new_defaults() {
|
||||
|
||||
+67
-31
@@ -1,11 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import ChatView from "./components/ChatView";
|
||||
import SettingsPage from "./features/settings/SettingsPage";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { useDB } from "./hooks/useDB";
|
||||
import { useModels } from "./hooks/useModels";
|
||||
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
|
||||
import { useRuntimeReadyModelRefresh } from "./features/runtime/useRuntimeReadyModelRefresh";
|
||||
import { swapModel } from "./features/runtime/api";
|
||||
import type { Chat } from "./lib/types";
|
||||
import type { RuntimeModelFile } from "./features/runtime/types";
|
||||
import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
@@ -13,6 +17,7 @@ export default function App() {
|
||||
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [view, setView] = useState<"chat" | "settings">("chat");
|
||||
|
||||
const runtime = useRuntimeManager();
|
||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
|
||||
@@ -55,32 +60,51 @@ export default function App() {
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
|
||||
const handleModelSwap = useCallback(async (newModelPath: string) => {
|
||||
setIsSwapping(true);
|
||||
try {
|
||||
await swapModel(newModelPath);
|
||||
setSelectedModel(newModelPath);
|
||||
} catch (err) {
|
||||
console.error("[App] handleModelSwap failed:", err);
|
||||
} finally {
|
||||
setIsSwapping(false);
|
||||
}
|
||||
}, [setSelectedModel]);
|
||||
|
||||
const localModels: RuntimeModelFile[] = useMemo(() => {
|
||||
return runtime.scannedModels ?? [];
|
||||
}, [runtime.scannedModels]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<div className="loading-spinner" />
|
||||
<span>Initializing…</span>
|
||||
</div>
|
||||
<ToastProvider>
|
||||
<div className="app-loading">
|
||||
<div className="loading-spinner" />
|
||||
<span>Initializing…</span>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`app-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<Sidebar
|
||||
chats={chats}
|
||||
currentChatId={currentChatId}
|
||||
onSelectChat={handleSelectChat}
|
||||
onNewChat={newChat}
|
||||
onDeleteChat={deleteChat}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
status={status}
|
||||
onRefreshModels={refreshModels}
|
||||
runtime={runtime}
|
||||
onCloseMobile={() => setSidebarOpen(false)}
|
||||
/>
|
||||
{sidebarOpen && (
|
||||
<ToastProvider>
|
||||
<div className={`app-layout${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
|
||||
{view !== "settings" && (
|
||||
<Sidebar
|
||||
chats={chats}
|
||||
currentChatId={currentChatId}
|
||||
initialScreen={currentChatId === null}
|
||||
onSelectChat={handleSelectChat}
|
||||
onNewChat={newChat}
|
||||
onDeleteChat={deleteChat}
|
||||
onOpenSettings={() => setView("settings")}
|
||||
onCloseMobile={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{view !== "settings" && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-sidebar-backdrop"
|
||||
@@ -89,17 +113,29 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
<div className="app-main">
|
||||
<ChatView
|
||||
db={db!}
|
||||
chatId={currentChatId}
|
||||
server={runtime.connection.serverUrl}
|
||||
apiKey={runtime.connection.apiKey}
|
||||
model={selectedModel}
|
||||
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
|
||||
onChatUpdated={loadChats}
|
||||
onOpenSidebar={() => setSidebarOpen(true)}
|
||||
/>
|
||||
{view === "settings" ? (
|
||||
<SettingsPage runtime={runtime} onClose={() => setView("chat")} onModelSwapped={setSelectedModel} />
|
||||
) : (
|
||||
<ChatView
|
||||
db={db!}
|
||||
chatId={currentChatId}
|
||||
server={runtime.connection.serverUrl}
|
||||
apiKey={runtime.connection.apiKey}
|
||||
model={selectedModel}
|
||||
models={models}
|
||||
localModels={localModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
onModelSwap={handleModelSwap}
|
||||
isSwapping={isSwapping}
|
||||
status={status}
|
||||
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
|
||||
onChatUpdated={loadChats}
|
||||
onOpenSidebar={() => setSidebarOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
+1348
-243
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,15 @@ function createDbStub(): DB {
|
||||
} as unknown as DB;
|
||||
}
|
||||
|
||||
// Default props for ChatView tests
|
||||
const defaultChatViewProps = {
|
||||
models: [] as import("../lib/types").ModelInfo[],
|
||||
localModels: [] as import("../features/runtime/types").RuntimeModelFile[],
|
||||
selectedModel: "",
|
||||
onModelChange: vi.fn(),
|
||||
status: "error" as import("../lib/types").ServerStatus,
|
||||
};
|
||||
|
||||
describe("ChatView", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -51,6 +60,7 @@ describe("ChatView", () => {
|
||||
model=""
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -68,7 +78,7 @@ describe("ChatView", () => {
|
||||
});
|
||||
|
||||
expect(textarea?.value).toBe("Keep this draft");
|
||||
expect(container.textContent).toContain("Connect an API");
|
||||
expect(container.textContent).toContain("Runtime offline");
|
||||
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -103,6 +113,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -119,6 +130,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -192,6 +204,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -221,6 +234,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -310,6 +324,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -382,6 +397,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -420,6 +436,7 @@ describe("ChatView", () => {
|
||||
model="demo"
|
||||
onChatCreated={vi.fn()}
|
||||
onChatUpdated={vi.fn()}
|
||||
{...defaultChatViewProps}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
/* Edit mode */
|
||||
/* ── Edit mode ── */
|
||||
.msg-edit-container {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.msg-edit-textarea {
|
||||
@@ -12,17 +12,19 @@
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
padding: 10px 14px;
|
||||
padding: 11px 14px;
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
box-shadow: 0 0 0 3px rgba(72,166,255,0.1);
|
||||
}
|
||||
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -31,13 +33,18 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
min-height: 44px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent2); }
|
||||
.btn-primary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.22);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg4);
|
||||
@@ -45,108 +52,286 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
min-height: 44px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg5); color: var(--text); }
|
||||
.btn-secondary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
min-height: 44px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text2); }
|
||||
|
||||
/* Message actions */
|
||||
.reasoning-panel {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
.btn-ghost:hover { color: var(--text); background: rgba(237, 245, 255, 0.05); border-color: rgba(93, 114, 136, 0.28); }
|
||||
.btn-ghost:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
|
||||
}
|
||||
|
||||
.reasoning-toggle {
|
||||
display: inline-flex;
|
||||
/* ── Thinking inside bubble ── */
|
||||
.msg-thinking {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.msg-thinking > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.msg-thinking > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(124, 158, 248, 0.08);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.75rem;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
user-select: none;
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
|
||||
.reasoning-toggle:hover {
|
||||
background: rgba(124, 158, 248, 0.12);
|
||||
color: var(--text);
|
||||
.thinking-summary:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.reasoning-content {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(124, 158, 248, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text2);
|
||||
font-size: 0.84rem;
|
||||
.thinking-summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.thinking-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 0.2s ease-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-thinking[open] .thinking-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 4px 16px 16px 16px;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.6;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.thinking-content > :first-child {
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.1);
|
||||
margin-left: 16px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ── Message bubble layout ── */
|
||||
.msg-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
padding: 13px 16px;
|
||||
}
|
||||
|
||||
/* ── Message metrics ── */
|
||||
.message-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--text3);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding-left: 12px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
font-family: monospace;
|
||||
color: #a8b8cc;
|
||||
}
|
||||
|
||||
.message-metrics span {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
/* ── Message footer ── */
|
||||
.msg-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* ── Compact actions for user bubble (floating bar) ── */
|
||||
.msg-wrapper.user {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.msg-wrapper.user:hover .compact-actions,
|
||||
.msg-wrapper.user:focus-within .compact-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.compact-actions .action-btn {
|
||||
min-height: 28px;
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #c8d7ea;
|
||||
}
|
||||
.compact-actions .action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.compact-actions .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
.compact-actions .action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(72, 166, 255, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
.compact-actions .action-btn.copied {
|
||||
color: var(--success);
|
||||
background: rgba(48, 209, 88, 0.12);
|
||||
}
|
||||
.compact-actions .action-btn.danger {
|
||||
color: #f4c2c2;
|
||||
}
|
||||
.compact-actions .action-btn.danger:hover {
|
||||
background: rgba(240, 64, 64, 0.12);
|
||||
color: #ffd7d7;
|
||||
}
|
||||
|
||||
/* ── Message actions (assistant) ── */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.msg-bubble:hover .msg-actions,
|
||||
.msg-bubble:focus-within .msg-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.msg-wrapper:hover .msg-actions { opacity: 1; }
|
||||
.msg-wrapper:focus-within .msg-actions { opacity: 1; }
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
min-width: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #c8d7ea;
|
||||
border-radius: 0;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: background 0.2s ease-out, color 0.2s ease-out;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--border2); }
|
||||
.action-btn.copied { color: var(--success); border-color: var(--success); }
|
||||
.action-btn.danger:hover { background: rgba(240,64,64,0.1); color: var(--danger); border-color: var(--danger); }
|
||||
.action-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
.action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
|
||||
opacity: 1;
|
||||
}
|
||||
.action-btn.copied {
|
||||
color: var(--success);
|
||||
background: rgba(48, 209, 88, 0.08);
|
||||
}
|
||||
.action-btn.danger {
|
||||
color: #f4c2c2;
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
background: rgba(240, 64, 64, 0.1);
|
||||
color: #ffd7d7;
|
||||
}
|
||||
|
||||
@media (hover: none), (max-width: 960px) {
|
||||
.msg-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.msg-actions {
|
||||
opacity: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1 1 132px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action-btn,
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
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 MessageBubble from "./MessageBubble";
|
||||
import type { Message } from "../lib/types";
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
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);
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("renders accessible thinking and action controls for assistant messages", async () => {
|
||||
const message: Message = {
|
||||
id: 12,
|
||||
chat_id: 4,
|
||||
role: "assistant",
|
||||
content: "Final answer",
|
||||
reasoning_content: "Reasoning details",
|
||||
prompt_tokens: 18,
|
||||
completion_tokens: 24,
|
||||
total_tokens: 42,
|
||||
tokens_per_second: 6.4,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MessageBubble
|
||||
message={message}
|
||||
onDelete={vi.fn()}
|
||||
onEditAssistant={vi.fn()}
|
||||
renderMarkdown={(text) => `<p>${text}</p>`}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const summary = container.querySelector("summary.reasoning-toggle");
|
||||
expect(summary?.getAttribute("aria-label")).toBe("Toggle thinking details");
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
expect(buttons.some((node) => node.getAttribute("aria-label") === "Copy message")).toBe(true);
|
||||
expect(buttons.some((node) => node.getAttribute("aria-label") === "Edit message")).toBe(true);
|
||||
expect(buttons.some((node) => node.getAttribute("aria-label") === "Delete message")).toBe(true);
|
||||
|
||||
expect(container.textContent).toContain("Sent 18");
|
||||
expect(container.textContent).toContain("Generated 24");
|
||||
expect(container.textContent).toContain("Total 42");
|
||||
expect(container.textContent).toContain("6.4 tok/s");
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const reasoningRef = useRef<HTMLDivElement>(null);
|
||||
const thinkingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Wire up copy-code buttons inside rendered markdown
|
||||
useEffect(() => {
|
||||
@@ -36,8 +35,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!reasoningRef.current) return;
|
||||
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
|
||||
if (!thinkingRef.current) return;
|
||||
const buttons = thinkingRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
|
||||
buttons.forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const code = btn.getAttribute("data-code") || "";
|
||||
@@ -90,6 +89,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
(message.total_tokens ?? 0) > 0
|
||||
);
|
||||
|
||||
const showFooter = message.role === "assistant";
|
||||
|
||||
return (
|
||||
<div className={`msg-wrapper ${message.role}`}>
|
||||
{editing ? (
|
||||
@@ -118,56 +119,101 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasReasoning && (
|
||||
<div className="reasoning-panel">
|
||||
<button
|
||||
className="reasoning-toggle"
|
||||
onClick={() => setShowReasoning((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{showReasoning ? "▼" : "▶"}</span>
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
{showReasoning && (
|
||||
<div
|
||||
ref={reasoningRef}
|
||||
className="reasoning-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className="msg-bubble"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="msg-bubble">{message.content}</div>
|
||||
)}
|
||||
>
|
||||
{hasReasoning && (
|
||||
<details className="msg-thinking">
|
||||
<summary className="reasoning-toggle" aria-label="Toggle thinking details">
|
||||
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
Thinking
|
||||
</summary>
|
||||
<div
|
||||
className="thinking-content"
|
||||
ref={thinkingRef}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{hasUsage && (
|
||||
<div className="message-metrics">
|
||||
<span>Sent {message.prompt_tokens ?? 0}</span>
|
||||
<span>Generated {message.completion_tokens ?? 0}</span>
|
||||
<span>Total {message.total_tokens ?? 0}</span>
|
||||
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
|
||||
<div
|
||||
className="msg-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
|
||||
{showFooter && (
|
||||
<div className="msg-footer">
|
||||
{hasUsage && (
|
||||
<div className="message-metrics">
|
||||
<span>Sent {message.prompt_tokens ?? 0}</span>
|
||||
<span>Generated {message.completion_tokens ?? 0}</span>
|
||||
<span>Total {message.total_tokens ?? 0}</span>
|
||||
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{!hasUsage && <div />}
|
||||
<div className="msg-actions" aria-label="Message actions">
|
||||
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
|
||||
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
|
||||
<rect x="7" y="7" width="10" height="10" rx="2" />
|
||||
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn"
|
||||
onClick={startEdit}
|
||||
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
|
||||
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
|
||||
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
|
||||
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
|
||||
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="msg-bubble user-bubble">
|
||||
<div className="msg-content">{message.content}</div>
|
||||
</div>
|
||||
<div className="msg-actions compact-actions" aria-label="Message actions">
|
||||
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
|
||||
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
|
||||
<rect x="7" y="7" width="10" height="10" rx="2" />
|
||||
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn"
|
||||
onClick={startEdit}
|
||||
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
|
||||
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
|
||||
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
|
||||
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
|
||||
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="msg-actions">
|
||||
<button className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage}>
|
||||
{copied ? "✓ Copied" : "⎘ Copy"}
|
||||
</button>
|
||||
<button className="action-btn" onClick={startEdit}>
|
||||
✎ Edit{onEditAndResend ? " & Resend" : ""}
|
||||
</button>
|
||||
<button className="action-btn danger" onClick={() => onDelete(message.id)}>
|
||||
✕ Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import type { ModelInfo, ServerStatus } from "../lib/types";
|
||||
import type { RuntimeModelFile } from "../features/runtime/types";
|
||||
import { getDisplayName, isInChatSelector } from "../lib/modelAlias";
|
||||
|
||||
interface ModelSelectorPillProps {
|
||||
models: ModelInfo[];
|
||||
localModels: RuntimeModelFile[];
|
||||
selectedModel: string;
|
||||
onModelChange: (id: string) => void;
|
||||
onModelSwap?: (path: string) => void;
|
||||
isSwapping?: boolean;
|
||||
status: ServerStatus;
|
||||
}
|
||||
|
||||
export default function ModelSelectorPill({
|
||||
models,
|
||||
localModels,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
onModelSwap,
|
||||
isSwapping,
|
||||
status,
|
||||
}: ModelSelectorPillProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
setDropdownOpen(false);
|
||||
onModelChange(modelId);
|
||||
if (onModelSwap) {
|
||||
onModelSwap(modelId);
|
||||
}
|
||||
};
|
||||
|
||||
// Unified model list: merge API models + local scanned files
|
||||
const allAvailableModels = [
|
||||
...models.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
|
||||
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
|
||||
];
|
||||
// Deduplicate by id
|
||||
const seen = new Set<string>();
|
||||
const dedupedModels = allAvailableModels.filter((m) => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Chat selector: only Cooper + Iron (Steel is settings-only)
|
||||
const chatSelectorModels = dedupedModels.filter((m) => isInChatSelector(m.id));
|
||||
|
||||
const selectedLabel = getDisplayName(selectedModel) || "No model";
|
||||
|
||||
return (
|
||||
<div className="model-selector-pill-wrapper" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-selector-pill"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
disabled={chatSelectorModels.length === 0 || isSwapping}
|
||||
title={chatSelectorModels.length === 0 ? "Start server to load models" : ""}
|
||||
aria-label="Choose active model"
|
||||
aria-expanded={dropdownOpen}
|
||||
>
|
||||
<span className={`model-status-dot status-${status}`} aria-hidden="true" />
|
||||
{isSwapping ? (
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
) : (
|
||||
<span className="model-selector-label">{selectedLabel}</span>
|
||||
)}
|
||||
<ChevronDown size={12} strokeWidth={2} className="model-selector-chevron" />
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<ul className="model-selector-menu" role="listbox">
|
||||
{chatSelectorModels.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
role="option"
|
||||
aria-selected={m.id === selectedModel}
|
||||
className={`model-selector-item${m.id === selectedModel ? " model-selector-item--selected" : ""}`}
|
||||
onClick={() => handleSelectModel(m.id)}
|
||||
>
|
||||
{m.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
|
||||
var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
width: 276px;
|
||||
min-width: 276px;
|
||||
background: #1f1f1f;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar--initial .model-section-label,
|
||||
.sidebar--initial .footer-meta,
|
||||
.sidebar--initial .footer-version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar--initial .model-section {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.sidebar--initial .model-row .icon-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar--initial .model-dropdown-trigger {
|
||||
background: transparent;
|
||||
border-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.sidebar-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-close-btn {
|
||||
@@ -23,9 +40,9 @@
|
||||
/* ── Header ── */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 14px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
align-items: flex-start;
|
||||
padding: 14px 12px 10px;
|
||||
border-bottom: 0;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -33,32 +50,83 @@
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #2b88e6, #49c0ff);
|
||||
.brand-mark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(91,106,240,0.35);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, #f5fbff, #89b5d9);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav-section {
|
||||
padding: 2px 8px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-nav-item svg {
|
||||
color: var(--text3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -66,78 +134,34 @@
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg4);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Settings panel ── */
|
||||
.settings-panel {
|
||||
padding: 10px 12px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: var(--bg3);
|
||||
animation: slide-down 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
background: var(--bg4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
padding: 7px 10px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg5);
|
||||
}
|
||||
|
||||
/* ── Model section ── */
|
||||
.model-section {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-top: auto;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.model-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -147,14 +171,7 @@
|
||||
.model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.model-help {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -186,56 +203,160 @@
|
||||
|
||||
.model-select {
|
||||
flex: 1;
|
||||
background: var(--bg4);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
padding: 6px 28px 6px 9px;
|
||||
font-size: 13px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.model-select:hover {
|
||||
background-color: var(--bg3);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.model-select:focus {
|
||||
border-color: var(--accent);
|
||||
background-color: var(--bg3);
|
||||
}
|
||||
|
||||
.model-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Custom dropdown (replaces native select) ── */
|
||||
.model-dropdown-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.model-dropdown-trigger {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.model-dropdown-trigger:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.model-dropdown-trigger:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.model-dropdown-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.model-dropdown-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text3);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.model-dropdown-trigger[aria-expanded="true"] .model-dropdown-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.model-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.model-dropdown-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.model-dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.model-dropdown-item--selected {
|
||||
background: rgba(72,166,255,0.07);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Sidebar actions ── */
|
||||
.sidebar-actions {
|
||||
padding: 10px 12px 6px;
|
||||
padding: 6px 8px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-actions--top {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #2b88e6, #49c0ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
padding: 10px 13px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(72,166,255,0.4);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.new-chat-btn:active {
|
||||
@@ -245,18 +366,18 @@
|
||||
/* ── Chat list ── */
|
||||
.chat-list-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 8px 12px 4px;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 12px 12px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 8px;
|
||||
padding: 4px 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
@@ -266,7 +387,7 @@
|
||||
text-align: center;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
padding: 24px 0;
|
||||
padding: 20px 8px 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -279,7 +400,7 @@
|
||||
color: var(--text2);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
position: relative;
|
||||
border-left: 2px solid transparent;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.chat-item-button {
|
||||
@@ -287,40 +408,29 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
padding: 10px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chat-item:hover {
|
||||
background: var(--bg3);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-item:focus-within {
|
||||
background: var(--bg3);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-item.active {
|
||||
background: rgba(91,106,240,0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-item-icon {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chat-item.active .chat-item-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-item-title {
|
||||
@@ -329,6 +439,7 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-item-del {
|
||||
@@ -337,7 +448,6 @@
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
@@ -346,8 +456,8 @@
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-item:hover .chat-item-del {
|
||||
@@ -366,20 +476,35 @@
|
||||
|
||||
/* ── Footer ── */
|
||||
.sidebar-footer {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 12px 14px;
|
||||
border-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar--initial .sidebar-footer {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.footer-version {
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,73 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
|
||||
import RuntimeWorkspace from "../features/runtime/components/RuntimeWorkspace";
|
||||
import type { RuntimeConfig, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot } from "../features/runtime/types";
|
||||
import type { Chat } from "../lib/types";
|
||||
import { Plus, Settings, X, Trash2, Bot, Search, Library, PanelLeft } from "lucide-react";
|
||||
import "./Sidebar.css";
|
||||
|
||||
interface Props {
|
||||
chats: Chat[];
|
||||
currentChatId: number | null;
|
||||
initialScreen?: boolean;
|
||||
onSelectChat: (id: number) => void;
|
||||
onNewChat: () => void;
|
||||
onDeleteChat: (id: number) => void;
|
||||
models: ModelInfo[];
|
||||
selectedModel: string;
|
||||
onModelChange: (v: string) => void;
|
||||
status: ServerStatus;
|
||||
onRefreshModels: () => void;
|
||||
runtime: {
|
||||
snapshot: RuntimeSnapshot;
|
||||
savedBaseline: RuntimeConfig;
|
||||
draftConfig: RuntimeConfig;
|
||||
draftDirty: boolean;
|
||||
draftOpen: boolean;
|
||||
draftFields: RuntimeDraftFieldState;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
activeLifecycleCommand: RuntimeSnapshot["active_command"];
|
||||
lastLifecycleResult: RuntimeSnapshot["last_command_result"];
|
||||
lifecycleBusy: boolean;
|
||||
applyImpact: "immediate" | "restart_required" | "save_before_apply";
|
||||
openDraft: () => void;
|
||||
closeDraft: () => void;
|
||||
resetDraft: () => void;
|
||||
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
||||
updateDraftFieldMeta: (field: keyof RuntimeDraftFieldState & string, patch: Partial<RuntimeDraftFieldMeta>) => void;
|
||||
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
startLocal: () => Promise<unknown>;
|
||||
stopLocal: () => Promise<unknown>;
|
||||
};
|
||||
onOpenSettings: () => void;
|
||||
onCloseMobile: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
|
||||
models, selectedModel, onModelChange, status, onRefreshModels,
|
||||
runtime,
|
||||
chats, currentChatId, initialScreen = false, onSelectChat, onNewChat, onDeleteChat,
|
||||
onOpenSettings,
|
||||
onCloseMobile,
|
||||
}: Props) {
|
||||
const modelCountLabel = models.length === 1 ? "1 model available" : `${models.length} models available`;
|
||||
const workspaceNavItems = [
|
||||
{ label: "Agents", icon: Bot },
|
||||
{ label: "Search", icon: Search },
|
||||
{ label: "Library", icon: Library },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="sidebar">
|
||||
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
|
||||
{/* ── Header ── */}
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-brand">
|
||||
<div className="brand-icon">⚡</div>
|
||||
<span className="brand-name">ForgeLM</span>
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<PanelLeft size={15} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="brand-copy">
|
||||
<span className="brand-name">ForgeLM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-header-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => {
|
||||
runtime.openDraft();
|
||||
}}
|
||||
title="Runtime settings"
|
||||
aria-label="Open runtime settings"
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
aria-label="Open settings"
|
||||
type="button"
|
||||
>
|
||||
⚙
|
||||
<Settings size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn sidebar-close-btn"
|
||||
@@ -76,65 +54,35 @@ export default function Sidebar({
|
||||
aria-label="Close navigation"
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
<X size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Unified Runtime Workspace ── */}
|
||||
<RuntimeWorkspace runtime={runtime} />
|
||||
|
||||
{/* ── Active model selector ── */}
|
||||
<div className="model-section">
|
||||
<div className="model-section-label">Active model</div>
|
||||
<div className="model-row">
|
||||
<div className={`status-dot status-${status}`} title={status} aria-hidden="true" />
|
||||
<select
|
||||
className="model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
disabled={models.length === 0}
|
||||
title={models.length === 0 ? "Start the server to load models" : ""}
|
||||
aria-label="Choose active model"
|
||||
>
|
||||
{models.length === 0 ? (
|
||||
<option value="">Start server to load models</option>
|
||||
) : (
|
||||
models.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.id}</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={onRefreshModels}
|
||||
title="Refresh models"
|
||||
aria-label="Refresh models"
|
||||
type="button"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
<div className="model-help">
|
||||
{models.length === 0 ? "Start the runtime to load available models." : modelCountLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── New chat ── */}
|
||||
<div className="sidebar-actions">
|
||||
<div className="sidebar-actions sidebar-actions--top">
|
||||
<button className="new-chat-btn" onClick={onNewChat} type="button">
|
||||
<span>+</span> New Chat
|
||||
<Plus size={16} strokeWidth={1.5} />
|
||||
<span>New task</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-nav-section">
|
||||
<div className="sidebar-nav-list" aria-label="Workspace navigation">
|
||||
{workspaceNavItems.map(({ label, icon: Icon }) => (
|
||||
<button key={label} type="button" className="sidebar-nav-item">
|
||||
<Icon size={15} strokeWidth={1.8} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Chat list ── */}
|
||||
{chats.length > 0 && (
|
||||
<div className="chat-list-section-label">Conversations</div>
|
||||
)}
|
||||
{chats.length > 0 && <div className="chat-list-section-label">Recent work</div>}
|
||||
<nav className="chat-list" aria-label="Conversation history">
|
||||
{chats.length === 0 && (
|
||||
<div className="chat-list-empty">
|
||||
No conversations yet.<br />Start a new chat above.
|
||||
No work yet.<br />Start a new task above.
|
||||
</div>
|
||||
)}
|
||||
{chats.map((chat) => (
|
||||
@@ -148,7 +96,6 @@ export default function Sidebar({
|
||||
onClick={() => onSelectChat(chat.id)}
|
||||
aria-current={chat.id === currentChatId ? "page" : undefined}
|
||||
>
|
||||
<span className="chat-item-icon">💬</span>
|
||||
<span className="chat-item-title">{chat.title}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -158,7 +105,7 @@ export default function Sidebar({
|
||||
title="Delete conversation"
|
||||
aria-label={`Delete conversation ${chat.title}`}
|
||||
>
|
||||
✕
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -166,7 +113,10 @@ export default function Sidebar({
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="sidebar-footer">
|
||||
<span className="footer-version">ForgeLM v0.1.0</span>
|
||||
<div className="sidebar-footer-copy">
|
||||
<span className="footer-version">ForgeLM v0.1.0</span>
|
||||
<span className="footer-meta">Local-first workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { createDefaultRuntimeSnapshot, createRuntimeLifecycleResult } from "./runtime-utils";
|
||||
import type {
|
||||
HuggingFaceDownloadProgress,
|
||||
ModelLibraryState,
|
||||
HuggingFaceModelResult,
|
||||
RuntimeConfig,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
|
||||
export const HUGGING_FACE_DOWNLOAD_EVENT = "hugging-face-download-progress";
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
@@ -146,6 +148,62 @@ export async function searchHuggingFaceModels(query: string, token = ""): Promis
|
||||
});
|
||||
}
|
||||
|
||||
export async function startHuggingFaceDownload(repo: string, fileName: string, token = ""): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>("start_hugging_face_download_command", {
|
||||
repo,
|
||||
fileName,
|
||||
token: token.trim() || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[api] startHuggingFaceDownload failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelHuggingFaceDownload(downloadId: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>("cancel_hugging_face_download_command", {
|
||||
downloadId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[api] cancelHuggingFaceDownload failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelLibraryState(): Promise<ModelLibraryState> {
|
||||
return invoke<ModelLibraryState>("get_model_library_state_command");
|
||||
}
|
||||
|
||||
/// Hot-swap: stop runtime → update model_path → restart.
|
||||
export async function swapModel(newModelPath: string): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("swap_model_command", { newModelPath });
|
||||
} catch (err) {
|
||||
console.error("[api] swapModel failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a local .gguf model file from disk.
|
||||
export async function deleteModelFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>("delete_model_file", { filePath });
|
||||
} catch (err) {
|
||||
console.error("[api] deleteModelFile failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the folder containing a model in the native file explorer.
|
||||
export async function openModelFolder(filePath: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>("open_model_folder_command", { filePath });
|
||||
} catch (err) {
|
||||
console.error("[api] openModelFolder failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type { HuggingFaceDownloadProgress };
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveDraftModelSelection,
|
||||
toModelFile,
|
||||
} from "../runtime-settings-form";
|
||||
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime-defaults";
|
||||
import {
|
||||
createRuntimeDraftFieldState,
|
||||
type ModelLibraryState,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
} from "../types";
|
||||
import "./RuntimeSettingsDrawer.css";
|
||||
|
||||
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
|
||||
const REMOTE_PRESET_TEMPLATES = [
|
||||
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
|
||||
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
@@ -799,10 +799,10 @@ export default function RuntimeSettingsDrawer({
|
||||
<input
|
||||
id="runtime-context"
|
||||
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
|
||||
min={512}
|
||||
min={MIN_CTX_SIZE}
|
||||
step={512}
|
||||
value={draftConfig.ctx_size}
|
||||
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
|
||||
onChange={(event) => updateDraft({ ctx_size: Math.max(MIN_CTX_SIZE, parseInt(event.target.value, 10) || DEFAULT_CTX_SIZE) })}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-card-header-left {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.runtime-card-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@@ -42,6 +47,25 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
/* Pulsing green dot when runtime is running */
|
||||
.runtime-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #30d158;
|
||||
flex-shrink: 0;
|
||||
animation: runtime-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes runtime-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(48, 209, 88, 0.5); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(48, 209, 88, 0); }
|
||||
}
|
||||
|
||||
.runtime-card-mode {
|
||||
@@ -92,6 +116,13 @@
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.runtime-card-meta-model {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runtime-card-note {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
@@ -177,12 +208,63 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary {
|
||||
/* Start — green */
|
||||
.runtime-card-btn-start {
|
||||
color: #fff;
|
||||
border-color: rgba(48, 209, 88, 0.5);
|
||||
background: linear-gradient(135deg, #1ea84d, #30d158);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-card-btn-start:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #25c05a, #3fe068);
|
||||
}
|
||||
|
||||
/* Stop — red */
|
||||
.runtime-card-btn-stop {
|
||||
color: #fff;
|
||||
border-color: rgba(240, 64, 64, 0.5);
|
||||
background: linear-gradient(135deg, #c0302e, #f04040);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-card-btn-stop:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #d03838, #f55555);
|
||||
}
|
||||
|
||||
/* Setup — blue (same as primary) */
|
||||
.runtime-card-btn-setup {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.6);
|
||||
background: linear-gradient(135deg, #2b88e6, #46c2ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary:hover:not(:disabled) {
|
||||
.runtime-card-btn-setup:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3193f5, #55cbff);
|
||||
}
|
||||
|
||||
/* Spinner for in-progress lifecycle */
|
||||
.runtime-btn-spinner {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: runtime-spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes runtime-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -62,26 +62,35 @@ export default function RuntimeStatusCard({
|
||||
? "runtime-card-result-warning"
|
||||
: "runtime-card-result-success";
|
||||
|
||||
const isRunning = isLocal && snapshot.process_running;
|
||||
const isStopping = isLocal && snapshot.status === "stopping";
|
||||
const primaryVariant = needsSetup ? "setup" : isRunning ? "stop" : "start";
|
||||
|
||||
return (
|
||||
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
|
||||
<div className="runtime-card-header">
|
||||
<div>
|
||||
<div className="runtime-card-header-left">
|
||||
<div className="runtime-card-kicker">Runtime Manager</div>
|
||||
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
|
||||
<div className="runtime-card-title">
|
||||
{isRunning && !isStopping && <span className="runtime-status-dot" aria-hidden="true" />}
|
||||
{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
|
||||
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="runtime-card-copy">
|
||||
<p className="runtime-card-copy" title={snapshot.config.connection_mode === "local" ? snapshot.resolved_url ?? "" : snapshot.config.server_url ?? ""}>
|
||||
{snapshot.config.connection_mode === "local"
|
||||
? `Managed endpoint ${snapshot.resolved_url}`
|
||||
: snapshot.config.server_url || "Configure a remote API endpoint"}
|
||||
</p>
|
||||
|
||||
<div className="runtime-card-meta" aria-label="Runtime details">
|
||||
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
|
||||
<span className="runtime-card-meta-model" title={snapshot.config.model_path ?? ""}>
|
||||
{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}
|
||||
</span>
|
||||
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +113,13 @@ export default function RuntimeStatusCard({
|
||||
Manage
|
||||
</button>
|
||||
{snapshot.config.connection_mode === "local" && (
|
||||
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
|
||||
<button
|
||||
className={`runtime-card-btn runtime-card-btn-${primaryVariant}`}
|
||||
onClick={primaryAction}
|
||||
type="button"
|
||||
disabled={primaryDisabled}
|
||||
>
|
||||
{lifecycleBusy ? <span className="runtime-btn-spinner" aria-hidden="true" /> : null}
|
||||
{primaryLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MIN_CTX_SIZE = 512;
|
||||
export const DEFAULT_CTX_SIZE = 4096;
|
||||
export const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
toModelFile,
|
||||
type RuntimeSettingsFormState,
|
||||
} from "./runtime-settings-form";
|
||||
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
|
||||
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
|
||||
import {
|
||||
createRuntimeDraftState,
|
||||
@@ -185,6 +186,17 @@ describe("runtime settings form helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the shared desktop default context size when the draft value is empty", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const patch = createRuntimeConfigPatch(
|
||||
snapshot.config,
|
||||
createFormState({ ctxSize: 0 }),
|
||||
32,
|
||||
);
|
||||
|
||||
expect(patch.ctx_size).toBe(DEFAULT_CTX_SIZE);
|
||||
});
|
||||
|
||||
it("keeps saved local values untouched when editing remote mode", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const patch = createRuntimeConfigPatch(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RuntimeModelFile,
|
||||
StartPolicy,
|
||||
} from "./types";
|
||||
import { DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "./runtime-defaults";
|
||||
|
||||
function normalizeUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
@@ -126,7 +127,7 @@ export function createRuntimeConfigPatch(
|
||||
form.mode === "local"
|
||||
? Math.max(0, Math.min(form.gpuLayers, maxLayers))
|
||||
: snapshotConfig.gpu_layers,
|
||||
ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size,
|
||||
ctx_size: form.mode === "local" ? Math.max(MIN_CTX_SIZE, form.ctxSize || DEFAULT_CTX_SIZE) : snapshotConfig.ctx_size,
|
||||
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
|
||||
extra_model_paths:
|
||||
form.mode === "local"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
|
||||
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
|
||||
import {
|
||||
createDefaultRuntimeSnapshot,
|
||||
createRuntimeLifecycleResult,
|
||||
getRuntimeConnection,
|
||||
getRuntimeStartGuard,
|
||||
getRuntimeStateLabel,
|
||||
getRuntimeStatusTone,
|
||||
} from "./runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
|
||||
|
||||
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
|
||||
@@ -42,6 +50,10 @@ function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
|
||||
}
|
||||
|
||||
describe("getRuntimeConnection", () => {
|
||||
it("creates local snapshots with the shared desktop default context window", () => {
|
||||
expect(createDefaultRuntimeSnapshot().config.ctx_size).toBe(DEFAULT_CTX_SIZE);
|
||||
});
|
||||
|
||||
it("prefers the resolved local runtime url in local mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
resolved_url: "http://127.0.0.1:9000",
|
||||
@@ -90,6 +102,10 @@ describe("getRuntimeStateLabel", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
|
||||
});
|
||||
|
||||
it("reports local processes that lost readiness as not ready", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("Not ready");
|
||||
});
|
||||
|
||||
it("reports local background start progress", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
|
||||
});
|
||||
@@ -113,6 +129,12 @@ describe("getRuntimeStateLabel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStatusTone", () => {
|
||||
it("marks local not-ready snapshots as danger", () => {
|
||||
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStartGuard", () => {
|
||||
it("requires a saved model before starting the local runtime", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
RuntimeWorkspaceSummary,
|
||||
} from "./types";
|
||||
import { scanRuntimeModels } from "./api";
|
||||
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
|
||||
|
||||
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
|
||||
|
||||
@@ -179,6 +180,10 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
|
||||
return "Remote API";
|
||||
}
|
||||
|
||||
if (snapshot.status === "ready" && !snapshot.ready) {
|
||||
return snapshot.process_running ? "Not ready" : "Local API offline";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Ready";
|
||||
@@ -200,6 +205,10 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
if (snapshot.status === "ready" && !snapshot.ready) {
|
||||
return snapshot.process_running ? "danger" : "neutral";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
@@ -299,7 +308,7 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
ctx_size: DEFAULT_CTX_SIZE,
|
||||
extra_model_paths: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface RuntimeConnection {
|
||||
export interface RuntimeModelFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
export interface RuntimePickDirResult {
|
||||
@@ -142,6 +143,22 @@ export interface HuggingFaceModelResult {
|
||||
verified_files: HuggingFaceVerifiedFile[];
|
||||
}
|
||||
|
||||
export type HuggingFaceDownloadPhase = "started" | "progress" | "completed" | "cancelled" | "error";
|
||||
|
||||
export interface HuggingFaceDownloadProgress {
|
||||
downloadId: string;
|
||||
repo: string;
|
||||
fileName: string;
|
||||
phase: HuggingFaceDownloadPhase;
|
||||
progressPercentage: number;
|
||||
downloadedMB: number;
|
||||
totalMB: number | null;
|
||||
downloadSpeed: number;
|
||||
destinationPath: string | null;
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ModelLibraryEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
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 { HUGGING_FACE_DOWNLOAD_EVENT, type HuggingFaceDownloadProgress } from "./api";
|
||||
import { useModelDownload } from "./useModelDownload";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
const listenMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: (...args: unknown[]) => listenMock(...args),
|
||||
}));
|
||||
|
||||
function Harness({ token = "hf_token" }: { token?: string }) {
|
||||
const state = useModelDownload(token);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<output id="is-downloading">{String(state.isDownloading)}</output>
|
||||
<output id="progress">{state.progress.toFixed(1)}</output>
|
||||
<output id="speed">{state.speed.toFixed(1)}</output>
|
||||
<output id="downloaded">{state.downloaded.toFixed(1)}</output>
|
||||
<output id="total">{state.total.toFixed(1)}</output>
|
||||
<output id="active-file">{state.activeFile ?? ""}</output>
|
||||
<output id="error">{state.error ?? ""}</output>
|
||||
<button id="start" onClick={() => void state.startDownload("microsoft/phi-4", "phi-4-q4.gguf")}>
|
||||
Start
|
||||
</button>
|
||||
<button id="cancel" onClick={() => void state.cancelDownload()}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useModelDownload", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let progressListener: ((event: { payload: HuggingFaceDownloadProgress }) => void) | null = null;
|
||||
|
||||
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);
|
||||
invokeMock.mockReset();
|
||||
listenMock.mockReset();
|
||||
progressListener = null;
|
||||
listenMock.mockImplementation(async (eventName, callback) => {
|
||||
if (eventName === HUGGING_FACE_DOWNLOAD_EVENT) {
|
||||
progressListener = callback as (event: { payload: HuggingFaceDownloadProgress }) => void;
|
||||
}
|
||||
return () => undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("starts a Hugging Face download with the active token and tracks progress events", async () => {
|
||||
invokeMock.mockResolvedValue("download-1");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness token="hf_secret" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector("#start") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("start_hugging_face_download_command", {
|
||||
repo: "microsoft/phi-4",
|
||||
fileName: "phi-4-q4.gguf",
|
||||
token: "hf_secret",
|
||||
});
|
||||
expect(container.querySelector("#is-downloading")?.textContent).toBe("true");
|
||||
expect(container.querySelector("#active-file")?.textContent).toBe("microsoft/phi-4:phi-4-q4.gguf");
|
||||
|
||||
await act(async () => {
|
||||
progressListener?.({
|
||||
payload: {
|
||||
downloadId: "download-1",
|
||||
repo: "microsoft/phi-4",
|
||||
fileName: "phi-4-q4.gguf",
|
||||
phase: "progress",
|
||||
progressPercentage: 37.5,
|
||||
downloadedMB: 768,
|
||||
totalMB: 2048,
|
||||
downloadSpeed: 12.4,
|
||||
destinationPath: null,
|
||||
message: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector("#progress")?.textContent).toBe("37.5");
|
||||
expect(container.querySelector("#speed")?.textContent).toBe("12.4");
|
||||
expect(container.querySelector("#downloaded")?.textContent).toBe("768.0");
|
||||
expect(container.querySelector("#total")?.textContent).toBe("2048.0");
|
||||
});
|
||||
|
||||
it("ignores progress for stale downloads and cancels the active one", async () => {
|
||||
invokeMock.mockResolvedValueOnce("download-1").mockResolvedValueOnce(undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector("#start") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
progressListener?.({
|
||||
payload: {
|
||||
downloadId: "download-stale",
|
||||
repo: "other/repo",
|
||||
fileName: "other.gguf",
|
||||
phase: "progress",
|
||||
progressPercentage: 90,
|
||||
downloadedMB: 90,
|
||||
totalMB: 100,
|
||||
downloadSpeed: 4,
|
||||
destinationPath: null,
|
||||
message: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector("#progress")?.textContent).toBe("0.0");
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector("#cancel") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenLastCalledWith("cancel_hugging_face_download_command", {
|
||||
downloadId: "download-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
HUGGING_FACE_DOWNLOAD_EVENT,
|
||||
cancelHuggingFaceDownload,
|
||||
startHuggingFaceDownload,
|
||||
type HuggingFaceDownloadProgress,
|
||||
} from "./api";
|
||||
|
||||
interface UseModelDownloadState {
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
speed: number;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
activeFile: string | null;
|
||||
error: string | null;
|
||||
didComplete: boolean;
|
||||
downloadedFilePath: string | null;
|
||||
resetDidComplete: () => void;
|
||||
startDownload: (repo: string, fileName: string) => Promise<void>;
|
||||
cancelDownload: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ModelDownloadSnapshot {
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
speed: number;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
activeFile: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: ModelDownloadSnapshot = {
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function toActiveFile(repo: string, fileName: string): string {
|
||||
return `${repo}:${fileName}`;
|
||||
}
|
||||
|
||||
export function useModelDownload(token: string): UseModelDownloadState {
|
||||
const [state, setState] = useState<ModelDownloadSnapshot>(INITIAL_STATE);
|
||||
const [didComplete, setDidComplete] = useState(false);
|
||||
const [downloadedFilePath, setDownloadedFilePath] = useState<string | null>(null);
|
||||
const activeDownloadIdRef = useRef<string | null>(null);
|
||||
const tokenRef = useRef(token);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = token;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const unsubscribePromise = listen<HuggingFaceDownloadProgress>(HUGGING_FACE_DOWNLOAD_EVENT, (event) => {
|
||||
const payload = event.payload;
|
||||
if (payload.downloadId !== activeDownloadIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeProgress = Number.isFinite(payload.progressPercentage) ? payload.progressPercentage : 0;
|
||||
const safeSpeed = Number.isFinite(payload.downloadSpeed) ? payload.downloadSpeed : 0;
|
||||
const safeDownloaded = Number.isFinite(payload.downloadedMB) ? payload.downloadedMB : 0;
|
||||
const safeTotal = payload.totalMB != null && Number.isFinite(payload.totalMB) ? payload.totalMB : 0;
|
||||
|
||||
const isActive = payload.phase === "started" || payload.phase === "progress";
|
||||
const isComplete = payload.phase === "completed";
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isDownloading: isActive,
|
||||
progress: safeProgress,
|
||||
speed: safeSpeed,
|
||||
downloaded: safeDownloaded,
|
||||
total: safeTotal,
|
||||
activeFile: toActiveFile(payload.repo, payload.fileName),
|
||||
error: payload.error ?? null,
|
||||
}));
|
||||
|
||||
if (isComplete) {
|
||||
const finalPath = payload.destinationPath ?? null;
|
||||
console.log(
|
||||
"[HuggingFace] Download concluído! Arquivo em:",
|
||||
finalPath
|
||||
);
|
||||
setDownloadedFilePath(finalPath);
|
||||
setDidComplete(true);
|
||||
activeDownloadIdRef.current = null;
|
||||
} else if (payload.phase === "cancelled" || payload.phase === "error") {
|
||||
activeDownloadIdRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
void unsubscribePromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(async (repo: string, fileName: string) => {
|
||||
try {
|
||||
const downloadId = await startHuggingFaceDownload(repo, fileName, tokenRef.current);
|
||||
activeDownloadIdRef.current = downloadId;
|
||||
setState({
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: toActiveFile(repo, fileName),
|
||||
error: null,
|
||||
});
|
||||
console.log("[HuggingFace] Download iniciado:", repo, fileName);
|
||||
} catch (err) {
|
||||
console.error("[useModelDownload] startDownload failed:", err);
|
||||
setState({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
activeDownloadIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelDownload = useCallback(async () => {
|
||||
if (!activeDownloadIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelHuggingFaceDownload(activeDownloadIdRef.current);
|
||||
} catch (err) {
|
||||
console.error("[useModelDownload] cancelDownload failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
didComplete,
|
||||
downloadedFilePath,
|
||||
resetDidComplete: () => {
|
||||
setDidComplete(false);
|
||||
setDownloadedFilePath(null);
|
||||
},
|
||||
startDownload,
|
||||
cancelDownload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
/* ── Shell ── */
|
||||
.sp-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left Navigation ── */
|
||||
.sp-nav {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: var(--manus-surface);
|
||||
border-right: 1px solid var(--manus-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-nav-header {
|
||||
padding: 24px 16px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-nav-brand {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-nav-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sp-nav-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sp-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.sp-nav-item:hover {
|
||||
background: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
}
|
||||
|
||||
.sp-nav-item--active {
|
||||
background: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Runtime dot in nav */
|
||||
.sp-nav-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-nav-status-label {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.sp-nav-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.sp-back-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Status dot ── */
|
||||
.sp-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-dot--inline {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: currentColor;
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
}
|
||||
|
||||
.sp-dot--success { background: var(--success); box-shadow: 0 0 6px rgba(48,209,88,0.55); }
|
||||
.sp-dot--warn { background: var(--warning); animation: sp-pulse 1.2s ease-in-out infinite; }
|
||||
.sp-dot--danger { background: var(--danger); }
|
||||
.sp-dot--idle { background: var(--text3); }
|
||||
|
||||
@keyframes sp-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Main area (right side of sidebar) ── */
|
||||
.settings-main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
background: var(--bg-main, #0B0E14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-scroll-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 120px 32px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.settings-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sp-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sp-section-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--manus-tx);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sp-section-desc {
|
||||
font-size: 14px;
|
||||
color: var(--manus-tx2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Status pill ── */
|
||||
.sp-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-status-pill--success {
|
||||
background: rgba(48,209,88,0.08);
|
||||
color: var(--success);
|
||||
border-color: rgba(48,209,88,0.2);
|
||||
}
|
||||
|
||||
.sp-status-pill--warn {
|
||||
background: rgba(255,214,10,0.08);
|
||||
color: var(--warning);
|
||||
border-color: rgba(255,214,10,0.2);
|
||||
}
|
||||
|
||||
.sp-status-pill--danger {
|
||||
background: rgba(255,69,58,0.08);
|
||||
color: var(--danger);
|
||||
border-color: rgba(255,69,58,0.2);
|
||||
}
|
||||
|
||||
/* ── Card (continuous, macOS/Linear style) ── */
|
||||
.sp-card {
|
||||
background: var(--manus-elevated);
|
||||
border: 1px solid var(--manus-hover);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.sp-card-row--full {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sp-card-row-label {
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
width: 180px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.sp-card-row-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-card-row-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sp-card-row-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.sp-card-row-control--wide {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sp-card-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
/* ── Active model ── */
|
||||
.sp-model-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-model-path {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sp-pending {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: rgba(72,166,255,0.07);
|
||||
border: 1px solid rgba(72,166,255,0.18);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.sp-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Folder path ── */
|
||||
.sp-folder-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sp-folder-path {
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
background: var(--bg3);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
word-break: break-all;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Model list ── */
|
||||
.sp-model-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sp-model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sp-model-row:last-child { border-bottom: none; }
|
||||
|
||||
.sp-model-row:hover:not(:disabled) { background: var(--bg4); }
|
||||
|
||||
.sp-model-row--selected {
|
||||
background: rgba(72,166,255,0.07);
|
||||
}
|
||||
|
||||
.sp-model-row--disabled { opacity: 0.45; pointer-events: none; }
|
||||
|
||||
.sp-model-row-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.sp-model-row-select:disabled { cursor: not-allowed; }
|
||||
|
||||
.sp-model-row-name { font-size: 13px; color: var(--text); }
|
||||
|
||||
.sp-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sp-remove-btn:hover {
|
||||
background: rgba(240,64,64,0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sp-scanning-row {
|
||||
padding: 12px 14px;
|
||||
color: var(--text3);
|
||||
font-size: 13px;
|
||||
background: var(--bg3);
|
||||
animation: sp-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Radio controls ── */
|
||||
.sp-radio {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border2);
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.12s, background 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.sp-radio--on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(72,166,255,0.18);
|
||||
}
|
||||
|
||||
/* ── Segmented control (macOS style) ── */
|
||||
.sp-segmented {
|
||||
display: inline-flex;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sp-segmented-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
font-family: inherit;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sp-segmented-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: var(--bg4);
|
||||
}
|
||||
|
||||
.sp-segmented-btn--active {
|
||||
background: var(--bg4);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.sp-segmented-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* ── Context custom input ── */
|
||||
.sp-ctx-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Toggle switches (iOS style) ── */
|
||||
.sp-toggles {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sp-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sp-toggle input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sp-toggle-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--bg4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--text3);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.sp-toggle--active .sp-toggle-track {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.sp-toggle--active .sp-toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sp-toggle--active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sp-toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sp-toggle-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── GPU ── */
|
||||
.sp-gpu-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sp-gpu-name { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.sp-gpu-vram { font-size: 12px; color: var(--text3); font-family: monospace; }
|
||||
|
||||
/* ── Slider ── */
|
||||
.sp-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: var(--bg5);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sp-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 5px rgba(72,166,255,0.4);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.sp-slider::-webkit-slider-thumb:hover {
|
||||
box-shadow: 0 0 0 4px rgba(72,166,255,0.2);
|
||||
}
|
||||
|
||||
.sp-slider-presets {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Field label ── */
|
||||
.sp-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sp-field-label {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sp-field-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
/* ── Inputs ── */
|
||||
.sp-input {
|
||||
background: var(--manus-surface);
|
||||
border: 1px solid var(--manus-elevated);
|
||||
border-radius: 8px;
|
||||
color: var(--manus-tx);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sp-input:hover {
|
||||
border-color: var(--manus-hover);
|
||||
}
|
||||
|
||||
.sp-input:focus {
|
||||
border-color: var(--manus-blue);
|
||||
background: var(--manus-elevated);
|
||||
box-shadow: 0 0 0 1px var(--manus-blue);
|
||||
}
|
||||
|
||||
.sp-input:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sp-input--sm {
|
||||
width: 120px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Hint text ── */
|
||||
.sp-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sp-hint code {
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text2);
|
||||
background: var(--bg4);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.sp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sp-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.sp-btn--primary {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
.sp-btn--primary:hover:not(:disabled) { opacity: 0.85; }
|
||||
|
||||
.sp-btn--secondary {
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.sp-btn--secondary:hover:not(:disabled) { background: var(--bg4); color: var(--text); }
|
||||
|
||||
.sp-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
border-color: transparent;
|
||||
}
|
||||
.sp-btn--ghost:hover:not(:disabled) { background: var(--bg3); color: var(--text); }
|
||||
|
||||
.sp-btn--danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: rgba(240,64,64,0.3);
|
||||
}
|
||||
.sp-btn--danger:hover:not(:disabled) { background: rgba(240,64,64,0.1); }
|
||||
|
||||
.sp-btn-xs {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
transition: color 0.12s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sp-btn-xs:hover { color: var(--accent); }
|
||||
|
||||
/* ── Floating action bar ── */
|
||||
.settings-action-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(11, 14, 20, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.action-bar-inner {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sp-error-inline {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
background: rgba(240,64,64,0.08);
|
||||
border: 1px solid rgba(240,64,64,0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Diagnostics ── */
|
||||
.sp-diag-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sp-diag-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sp-diag-key { color: var(--text3); flex-shrink: 0; }
|
||||
.sp-diag-val { color: var(--text2); text-align: right; word-break: break-all; }
|
||||
|
||||
.sp-mono { font-family: "Cascadia Code", "JetBrains Mono", monospace; font-size: 11px; }
|
||||
.sp-accent { color: var(--accent); }
|
||||
.sp-danger { color: var(--danger); }
|
||||
|
||||
/* ── Utilities ── */
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.w-full { width: 100%; }
|
||||
.w-32 { width: 8rem; }
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -0,0 +1,161 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SettingsPage from "./SettingsPage";
|
||||
import type { RuntimeSnapshot } from "../runtime/types";
|
||||
|
||||
const startDownloadMock = vi.fn();
|
||||
const cancelDownloadMock = vi.fn();
|
||||
|
||||
const modelDownloadState = {
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null as string | null,
|
||||
error: null as string | null,
|
||||
startDownload: startDownloadMock,
|
||||
cancelDownload: cancelDownloadMock,
|
||||
};
|
||||
|
||||
vi.mock("../runtime/api", () => ({
|
||||
pickRuntimeModelFiles: vi.fn(async () => []),
|
||||
pickRuntimeModelsDir: vi.fn(async () => null),
|
||||
queryRuntimeGpu: vi.fn(async () => null),
|
||||
searchHuggingFaceModels: vi.fn(async () => [
|
||||
{
|
||||
repo_id: "microsoft/phi-4",
|
||||
author: "microsoft",
|
||||
verified_files: [
|
||||
{
|
||||
path: "quantized/phi-4-q4.gguf",
|
||||
size: 4_294_967_296,
|
||||
quantization: "q4",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime/useModelDownload", () => ({
|
||||
useModelDownload: () => modelDownloadState,
|
||||
}));
|
||||
|
||||
function createSnapshot(): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: "",
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
active_command: "idle",
|
||||
last_command_result: {
|
||||
command: "save",
|
||||
outcome: "succeeded",
|
||||
message: "",
|
||||
},
|
||||
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: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
hugging_face_token: "hf_token",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("SettingsPage Hugging Face downloads", () => {
|
||||
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);
|
||||
startDownloadMock.mockReset();
|
||||
cancelDownloadMock.mockReset();
|
||||
Object.assign(modelDownloadState, {
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("starts a real download from the Hugging Face row", async () => {
|
||||
const runtime = {
|
||||
snapshot: createSnapshot(),
|
||||
savedBaseline: createSnapshot().config,
|
||||
draftConfig: createSnapshot().config,
|
||||
draftDirty: false,
|
||||
loading: false,
|
||||
busy: false,
|
||||
lifecycleBusy: false,
|
||||
scannedModels: [],
|
||||
isScanningModels: false,
|
||||
updateDraft: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
startLocal: vi.fn(),
|
||||
stopLocal: vi.fn(),
|
||||
scanModels: vi.fn(async () => undefined),
|
||||
resetDraft: vi.fn(),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(<SettingsPage runtime={runtime} onClose={() => undefined} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const input = container.querySelector('input[placeholder="Search for GGUF models…"]') as HTMLInputElement;
|
||||
setInputValue(input, "phi-4");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Search")) as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const fileRow = container.querySelector(".sp-hf-file");
|
||||
expect(fileRow).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
(fileRow?.querySelector('button[aria-label^="Download "]') as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(startDownloadMock).toHaveBeenCalledWith("microsoft/phi-4", "quantized/phi-4-q4.gguf");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+37
-18
@@ -2,41 +2,57 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--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;
|
||||
/* Updated to Manus design tokens */
|
||||
--bg: #171717; /* Manus bg-base */
|
||||
--bg2: #1E1E1E; /* Manus bg-surface */
|
||||
--bg3: #262626; /* Manus bg-elevated */
|
||||
--bg4: #333333; /* Manus bg-hover */
|
||||
--bg5: #3D3D3D; /* slightly lighter */
|
||||
--border: #232a33;
|
||||
--border2: #313944;
|
||||
--text: #F3F4F6; /* Manus text-primary */
|
||||
--text2: #9CA3AF; /* Manus text-secondary */
|
||||
--text3: #52525B; /* Manus text-muted */
|
||||
--accent: #4b86ff;
|
||||
--accent2: #3674ef;
|
||||
--accent-glow: rgba(75,134,255,0.14);
|
||||
--user-bg: #101c2d;
|
||||
--user-border: #203451;
|
||||
--asst-bg: #10161d;
|
||||
--asst-border: #26303b;
|
||||
--danger: #f04040;
|
||||
--success: #30d158;
|
||||
--warning: #ffd60a;
|
||||
--code-bg: #0d0d10;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--font-sans: "Inter", "Segoe UI Variable", "SF Pro Display", system-ui, sans-serif;
|
||||
--font-serif: "Merriweather", Georgia, serif;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Manus design tokens as named vars (for new components) */
|
||||
:root {
|
||||
--manus-base: #171717;
|
||||
--manus-surface: #1E1E1E;
|
||||
--manus-elevated: #262626;
|
||||
--manus-hover: #333333;
|
||||
--manus-tx: #F3F4F6;
|
||||
--manus-tx2: #9CA3AF;
|
||||
--manus-tx3: #52525B;
|
||||
--manus-blue: #3B82F6;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI Variable", "SF Pro Display", "IBM Plex Sans", "Inter", system-ui, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@@ -54,3 +70,6 @@ html, body, #root {
|
||||
|
||||
/* ── Highlight.js theme override ── */
|
||||
.hljs { background: var(--code-bg) !important; }
|
||||
|
||||
/* ── Tailwind utilities (preflight disabled — existing styles preserved) ── */
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
corePlugins: { preflight: false }, // preserve existing hand-crafted CSS
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"c-bg": "var(--bg)",
|
||||
"c-bg2": "var(--bg2)",
|
||||
"c-bg3": "var(--bg3)",
|
||||
"c-bg4": "var(--bg4)",
|
||||
"c-bg5": "var(--bg5)",
|
||||
"c-bdr": "var(--border)",
|
||||
"c-bdr2": "var(--border2)",
|
||||
"c-tx": "var(--text)",
|
||||
"c-tx2": "var(--text2)",
|
||||
"c-tx3": "var(--text3)",
|
||||
"c-ac": "var(--accent)",
|
||||
"c-ac2": "var(--accent2)",
|
||||
"c-danger": "var(--danger)",
|
||||
"c-success": "var(--success)",
|
||||
"c-warn": "var(--warning)",
|
||||
// Manus design tokens
|
||||
"manus-base": "var(--manus-base)",
|
||||
"manus-surface": "var(--manus-surface)",
|
||||
"manus-elevated": "var(--manus-elevated)",
|
||||
"manus-hover": "var(--manus-hover)",
|
||||
"manus-tx": "var(--manus-tx)",
|
||||
"manus-tx2": "var(--manus-tx2)",
|
||||
"manus-tx3": "var(--manus-tx3)",
|
||||
"manus-blue": "var(--manus-blue)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"release": "b8358",
|
||||
"variants": [
|
||||
{
|
||||
"id": "windows-x64-cpu",
|
||||
"target": "windows-x86_64",
|
||||
"gpu": false,
|
||||
"dll": "llama.dll",
|
||||
"archives": [
|
||||
{
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b8358/llama-b8358-bin-win-cpu-x64.zip",
|
||||
"sha256": "bb78ff8d7e9b53c36fe4cc845f8a479017f97bf5fd99a9a047b45c9c36b93810",
|
||||
"size": 37300696
|
||||
}
|
||||
],
|
||||
"required_files": [
|
||||
"llama.dll",
|
||||
"ggml.dll",
|
||||
"ggml-base.dll"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "windows-x64-cuda-12.4",
|
||||
"target": "windows-x86_64",
|
||||
"gpu": true,
|
||||
"dll": "llama.dll",
|
||||
"archives": [
|
||||
{
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b8358/llama-b8358-bin-win-cuda-12.4-x64.zip",
|
||||
"sha256": "92c0c30401e7284fd28836b112ffa752b3beb7d89786e0c87e378f478b973e48",
|
||||
"size": 226836797
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b8358/cudart-llama-bin-win-cuda-12.4-x64.zip",
|
||||
"sha256": "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6",
|
||||
"size": 391443627
|
||||
}
|
||||
],
|
||||
"required_files": [
|
||||
"llama.dll",
|
||||
"ggml.dll",
|
||||
"ggml-base.dll",
|
||||
"ggml-cuda.dll",
|
||||
"cudart64_12.dll"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user