feat: add SettingsPage with HuggingFace model search and fix flexbox layout

- Add SettingsPage component with macOS/Linear-style settings UI (sp-* classes)
- Fix HuggingFace results list squeeze: sp-card-row--full now has align-items: stretch
  overriding the inherited flex-start, ensuring children fill 100% horizontal width
- Fix sp-hf-file row layout: width 100%, grid columns for name/quant/size/download
- Fix sp-hf-name truncation: explicit overflow/ellipsis/white-space in CSS
- Add white-space: nowrap to sp-hf-size and sp-hf-quant-badge to protect secondary columns
- Add runtime-deps crate, Tailwind/PostCSS config, runtime-defaults, MessageBubble test
- Update Cargo workspace, inference-server, tauri runtime types and process handling
This commit is contained in:
Evandro Fonseca Junior committed 2026-03-30 23:35:08 -03:00
1 parent 9da25c3688
commit 01b629cb4a
41 files changed
+6738 -696

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
---
status: investigating
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,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>
+58
View File
@@ -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
View File
@@ -92,6 +92,15 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "async-stream" name = "async-stream"
version = "0.3.6" version = "0.3.6"
@@ -202,6 +211,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -296,6 +314,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -305,6 +332,64 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -457,6 +542,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -758,7 +853,7 @@ dependencies = [
"llama-sys", "llama-sys",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@@ -774,13 +869,15 @@ dependencies = [
"async-stream", "async-stream",
"axum", "axum",
"bytes", "bytes",
"dirs",
"futures", "futures",
"hyper", "hyper",
"inference-core", "inference-core",
"metrics", "metrics",
"runtime-deps",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower", "tower",
@@ -840,6 +937,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "libredox"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -971,6 +1077,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1064,6 +1176,17 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -1095,6 +1218,19 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "runtime-deps"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs",
"serde",
"serde_json",
"sha2",
"ureq",
"zip",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.37" version = "0.23.37"
@@ -1220,6 +1356,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1319,13 +1466,33 @@ dependencies = [
"syn", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ 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]] [[package]]
@@ -1535,6 +1702,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -1612,6 +1785,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -1801,13 +1980,22 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -1819,34 +2007,67 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1859,24 +2080,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -2060,8 +2305,37 @@ dependencies = [
"syn", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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",
]
+4
View File
@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"crates/llama-sys", "crates/llama-sys",
"crates/runtime-deps",
"crates/inference-core", "crates/inference-core",
"crates/inference-server", "crates/inference-server",
"crates/api-contracts", "crates/api-contracts",
@@ -47,6 +48,9 @@ tokio-stream = "0.1"
bytes = "1" bytes = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
sha2 = "0.10"
zip = { version = "2", default-features = false, features = ["deflate"] }
dirs = "5"
[profile.dev] [profile.dev]
opt-level = 0 opt-level = 0
+14 -9
View File
@@ -11,29 +11,34 @@ pub enum RuntimeProfile {
impl RuntimeProfile { impl RuntimeProfile {
/// Returns sensible defaults for each profile. /// 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 { pub fn defaults(&self) -> ProfileDefaults {
match self { match self {
Self::Turbo4B => ProfileDefaults { Self::Turbo4B => ProfileDefaults {
n_gpu_layers: 999, n_gpu_layers: 999,
n_ctx: 4096, n_ctx: 131_072, // 128 K — covers very long conversations; needs ~4 GB KV VRAM at FP16
n_batch: 512, n_batch: 2048, // prefill chunk size; independent of n_ctx
n_ubatch: 128, n_ubatch: 512,
flash_attn: true, flash_attn: true,
n_threads: 8, n_threads: 8,
}, },
Self::Balanced9B => ProfileDefaults { Self::Balanced9B => ProfileDefaults {
n_gpu_layers: 999, n_gpu_layers: 999,
n_ctx: 2048, n_ctx: 65_536, // 64 K
n_batch: 256, n_batch: 2048,
n_ubatch: 64, n_ubatch: 256,
flash_attn: true, flash_attn: true,
n_threads: 8, n_threads: 8,
}, },
Self::CpuFallback => ProfileDefaults { Self::CpuFallback => ProfileDefaults {
n_gpu_layers: 0, n_gpu_layers: 0,
n_ctx: 2048, n_ctx: 16_384, // 16 K — CPU KV cache is RAM-backed; keep conservative
n_batch: 128, n_batch: 512,
n_ubatch: 32, n_ubatch: 128,
flash_attn: false, flash_attn: false,
n_threads: num_cpus(), n_threads: num_cpus(),
}, },
+2
View File
@@ -12,6 +12,8 @@ path = "src/main.rs"
inference-core = { path = "../inference-core" } inference-core = { path = "../inference-core" }
api-contracts = { path = "../api-contracts" } api-contracts = { path = "../api-contracts" }
metrics = { path = "../metrics" } metrics = { path = "../metrics" }
runtime-deps = { path = "../runtime-deps" }
dirs = { workspace = true }
tokio.workspace = true tokio.workspace = true
axum.workspace = true axum.workspace = true
hyper.workspace = true hyper.workspace = true
+1 -35
View File
@@ -1,38 +1,4 @@
fn main() { 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=build.rs");
println!("cargo:rerun-if-changed=../../vendor/llama-bin"); println!("cargo:rerun-if-changed=../../vendor/llama-runtime/manifest.json");
} }
+89 -10
View File
@@ -3,8 +3,12 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use runtime_deps::{
apply_runtime_to_current_process, ensure_llama_runtime, recommended_variant_override_hint,
EnsureRuntimeRequest,
};
use tokio::sync::Mutex; 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 inference_core::{EngineConfig, InferenceEngine, RuntimeProfile};
use metrics::counters::PerfCounters; use metrics::counters::PerfCounters;
@@ -17,12 +21,25 @@ use routes::{build_router, AppState};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// ── Tracing ────────────────────────────────────────────────────────── // ── Tracing ──────────────────────────────────────────────────────────
fmt() // Set LOCALLLM_LOG_FORMAT=json for machine-readable output (e.g. log aggregators).
.with_env_filter( // Set RUST_LOG=debug for verbose output.
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), let env_filter = EnvFilter::try_from_default_env()
) .unwrap_or_else(|_| EnvFilter::new("info"));
.with_target(false)
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(); .init();
}
_ => {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().with_target(true))
.init();
}
}
// ── Engine config (reads env vars; sane defaults for dev) ──────────── // ── Engine config (reads env vars; sane defaults for dev) ────────────
let model_path = std::env::var("LOCALLLM_MODEL") 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())); let server_config = Arc::new(ServerConfig::from_env(model_name.clone()));
// ── Load engine ────────────────────────────────────────────────────── // ── 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)?; let mut engine = InferenceEngine::load(config)?;
engine.warmup()?; engine.warmup()?;
@@ -81,6 +137,14 @@ async fn main() -> Result<()> {
let router = build_router(state); let router = build_router(state);
let listener = tokio::net::TcpListener::bind(addr).await?; 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 ─────────────────────────────────────────────────── // ── Startup banner ───────────────────────────────────────────────────
let display_addr = if addr.ip().is_unspecified() { let display_addr = if addr.ip().is_unspecified() {
format!("http://127.0.0.1:{}", addr.port()) format!("http://127.0.0.1:{}", addr.port())
@@ -125,8 +189,20 @@ fn env_u32(key: &str, default: u32) -> u32 {
.unwrap_or(default) .unwrap_or(default)
} }
fn resolve_batch_size(n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 { /// Choose the prefill batch size.
explicit_batch.unwrap_or_else(|| n_ctx.max(default_n_batch).max(1)) ///
/// 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)] #[cfg(test)]
@@ -134,8 +210,11 @@ mod tests {
use super::resolve_batch_size; use super::resolve_batch_size;
#[test] #[test]
fn defaults_batch_to_context_size_when_ctx_is_larger() { fn caps_auto_batch_at_max_regardless_of_ctx_size() {
assert_eq!(resolve_batch_size(16_384, 512, None), 16_384); // 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] #[test]
+10 -2
View File
@@ -397,9 +397,17 @@ async fn stream_response(
} }
}; };
Sse::new(stream) let response = Sse::new(stream)
.keep_alive(KeepAlive::default()) .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( async fn blocking_response(
+13
View File
@@ -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 }
+614
View File
@@ -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"));
}
}
+939
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -11,10 +11,12 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"highlight.js": "^11.0.0", "highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0", "marked": "^12.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -25,7 +27,10 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.27",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.4.8", "vite": "^5.4.8",
"vitest": "^4.1.2" "vitest": "^4.1.2"
+590
View File
@@ -45,15 +45,32 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.1 specifier: ^4.3.1
version: 4.7.0([email protected]) version: 4.7.0([email protected])
jsdom:
specifier: ^29.0.1
version: 29.0.1
typescript: typescript:
specifier: ^5.5.3 specifier: ^5.5.3
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: ^5.4.8 specifier: ^5.4.8
version: 5.4.21 version: 5.4.21
vitest:
specifier: ^4.1.2
version: 4.1.2([email protected])([email protected])
packages: 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]': '@babel/[email protected]':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -137,6 +154,46 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} 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]': '@esbuild/[email protected]':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -275,6 +332,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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]': '@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -432,6 +498,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tauri-apps/[email protected]': '@tauri-apps/[email protected]':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -526,6 +595,12 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} 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]': '@types/[email protected]':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} 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. 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: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 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]: [email protected]:
resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -566,12 +677,24 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
[email protected]:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
[email protected]: [email protected]:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 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]: [email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 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]: [email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -581,12 +704,22 @@ packages:
supports-color: supports-color:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==} 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]: [email protected]:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -596,6 +729,22 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} 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]: [email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 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==} resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'} 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]: [email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} 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]: [email protected]:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -626,14 +791,24 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
[email protected]: [email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -645,13 +820,30 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} 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]: [email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
[email protected]: [email protected]:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies: peerDependencies:
@@ -665,11 +857,19 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} 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]: [email protected]:
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
[email protected]: [email protected]:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -677,15 +877,61 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]: [email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} 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]: [email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
engines: {node: '>=20.18.1'}
[email protected]: [email protected]:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true hasBin: true
@@ -723,11 +969,92 @@ packages:
terser: terser:
optional: true 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]: [email protected]:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
snapshots: 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]': '@babel/[email protected]':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@@ -840,6 +1167,34 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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]': '@esbuild/[email protected]':
optional: true optional: true
@@ -909,6 +1264,8 @@ snapshots:
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@exodus/[email protected]': {}
'@jridgewell/[email protected]': '@jridgewell/[email protected]':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1005,6 +1362,8 @@ snapshots:
'@rollup/[email protected]': '@rollup/[email protected]':
optional: true optional: true
'@standard-schema/[email protected]': {}
'@tauri-apps/[email protected]': {} '@tauri-apps/[email protected]': {}
'@tauri-apps/[email protected]': '@tauri-apps/[email protected]':
@@ -1079,6 +1438,13 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@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]': '@types/[email protected]':
dependencies: dependencies:
dompurify: 3.3.3 dompurify: 3.3.3
@@ -1111,8 +1477,55 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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]: {}
[email protected]:
dependencies:
require-from-string: 2.0.2
[email protected]: [email protected]:
dependencies: dependencies:
baseline-browser-mapping: 2.10.11 baseline-browser-mapping: 2.10.11
@@ -1123,20 +1536,40 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
[email protected]: [email protected]:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
[email protected]: {}
[email protected]: [email protected]:
optionalDependencies: optionalDependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5 '@esbuild/aix-ppc64': 0.21.5
@@ -1165,6 +1598,16 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.8
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.4
[email protected]: [email protected]:
optional: true optional: true
@@ -1172,8 +1615,42 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
'@exodus/bytes': 1.15.0
transitivePeerDependencies:
- '@noble/hashes'
[email protected]: {}
[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]: {}
[email protected]: {} [email protected]: {}
@@ -1182,26 +1659,46 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
yallist: 3.1.1 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]: {}
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
entities: 6.0.1
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
[email protected]: {}
[email protected]([email protected]): [email protected]([email protected]):
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -1214,6 +1711,8 @@ snapshots:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -1245,16 +1744,55 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.0 '@rollup/rollup-win32-x64-msvc': 4.60.0
fsevents: 2.3.3 fsevents: 2.3.3
[email protected]:
dependencies:
xmlchars: 2.2.0
[email protected]: [email protected]:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
[email protected]: {} [email protected]: {}
[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]([email protected]): [email protected]([email protected]):
dependencies: dependencies:
browserslist: 4.28.1 browserslist: 4.28.1
@@ -1269,4 +1807,56 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 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]: {} [email protected]: {}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+197 -8
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -521,6 +530,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -565,13 +585,34 @@ dependencies = [
"crypto-common", "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]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [ 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]] [[package]]
@@ -582,7 +623,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -830,6 +871,7 @@ name = "forgelm-chat"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"runtime-deps",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -1380,7 +1422,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -2787,6 +2829,17 @@ dependencies = [
"bitflags 2.11.0", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -2882,7 +2935,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -2957,6 +3010,19 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -2978,6 +3044,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3655,7 +3722,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs", "dirs 6.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -3705,7 +3772,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs", "dirs 6.0.0",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@@ -4268,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2", "objc2",
@@ -4366,6 +4433,24 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4678,6 +4763,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.6"
@@ -4917,6 +5011,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -4968,6 +5071,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -5025,6 +5143,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -5043,6 +5167,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -5061,6 +5191,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -5091,6 +5227,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -5109,6 +5251,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -5127,6 +5275,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -5145,6 +5299,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -5295,7 +5455,7 @@ dependencies = [
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"dom_query", "dom_query",
"dpi", "dpi",
"dunce", "dunce",
@@ -5453,8 +5613,37 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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",
]
+1
View File
@@ -25,6 +25,7 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime-deps = { path = "../../crates/runtime-deps" }
[profile.release] [profile.release]
panic = "abort" panic = "abort"
+1 -1
View File
@@ -118,7 +118,7 @@ fn start_runtime_inner(
inner.config.clone() inner.config.clone()
}; };
let child = match spawn_localllm(&config) { let child = match spawn_localllm(app, &config) {
Ok(child) => child, Ok(child) => child,
Err(message) => { Err(message) => {
let mut inner = state.0.lock().map_err(|e| e.to_string())?; let mut inner = state.0.lock().map_err(|e| e.to_string())?;
+36 -3
View File
@@ -1,7 +1,13 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command}; use std::process::{Child, Command};
use tauri::{AppHandle, Manager};
use super::types::RuntimeConfig; 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> { pub fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") { let exe_name = if cfg!(target_os = "windows") {
@@ -52,7 +58,17 @@ pub fn find_localllm_binary() -> Option<PathBuf> {
None 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 let model_path = config
.model_path .model_path
.as_deref() .as_deref()
@@ -65,11 +81,28 @@ pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
.to_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_MODEL", model_path)
.env("LOCALLLM_ADDR", &config.addr) .env("LOCALLLM_ADDR", &config.addr)
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string()) .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() .spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display())) .map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
} }
+11 -2
View File
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use crate::model_library::types::RemotePreset; use crate::model_library::types::RemotePreset;
pub const DEFAULT_CTX_SIZE: u32 = 4096;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)] #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ConnectionMode { pub enum ConnectionMode {
@@ -102,7 +104,7 @@ impl Default for RuntimeConfig {
models_dir: None, models_dir: None,
addr: Self::default_addr(), addr: Self::default_addr(),
gpu_layers: 0, gpu_layers: 0,
ctx_size: 4096, ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: Vec::new(), extra_model_paths: Vec::new(),
selected_model_id: None, selected_model_id: None,
remote_presets: Vec::new(), remote_presets: Vec::new(),
@@ -113,7 +115,14 @@ impl Default for RuntimeConfig {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn runtime_config_deserializes_old_shape_with_new_defaults() { fn runtime_config_deserializes_old_shape_with_new_defaults() {
+11 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import ChatView from "./components/ChatView"; import ChatView from "./components/ChatView";
import SettingsPage from "./features/settings/SettingsPage";
import { useDB } from "./hooks/useDB"; import { useDB } from "./hooks/useDB";
import { useModels } from "./hooks/useModels"; import { useModels } from "./hooks/useModels";
import { useRuntimeManager } from "./features/runtime/useRuntimeManager"; import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
@@ -13,6 +14,7 @@ export default function App() {
const [currentChatId, setCurrentChatId] = useState<number | null>(null); const [currentChatId, setCurrentChatId] = useState<number | null>(null);
const [chats, setChats] = useState<Chat[]>([]); const [chats, setChats] = useState<Chat[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [view, setView] = useState<"chat" | "settings">("chat");
const runtime = useRuntimeManager(); const runtime = useRuntimeManager();
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels( const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
@@ -65,7 +67,8 @@ export default function App() {
} }
return ( return (
<div className={`app-layout${sidebarOpen ? " sidebar-open" : ""}`}> <div className={`app-layout${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
{view !== "settings" && (
<Sidebar <Sidebar
chats={chats} chats={chats}
currentChatId={currentChatId} currentChatId={currentChatId}
@@ -77,10 +80,11 @@ export default function App() {
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
status={status} status={status}
onRefreshModels={refreshModels} onRefreshModels={refreshModels}
runtime={runtime} onOpenSettings={() => setView("settings")}
onCloseMobile={() => setSidebarOpen(false)} onCloseMobile={() => setSidebarOpen(false)}
/> />
{sidebarOpen && ( )}
{view !== "settings" && sidebarOpen && (
<button <button
type="button" type="button"
className="app-sidebar-backdrop" className="app-sidebar-backdrop"
@@ -89,6 +93,9 @@ export default function App() {
/> />
)} )}
<div className="app-main"> <div className="app-main">
{view === "settings" ? (
<SettingsPage runtime={runtime} onClose={() => setView("chat")} />
) : (
<ChatView <ChatView
db={db!} db={db!}
chatId={currentChatId} chatId={currentChatId}
@@ -99,6 +106,7 @@ export default function App() {
onChatUpdated={loadChats} onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)} onOpenSidebar={() => setSidebarOpen(true)}
/> />
)}
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large. Load diff
+307 -106
View File
@@ -5,6 +5,7 @@ import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import { renderMarkdown, ensureHljs } from "../lib/markdown"; import { renderMarkdown, ensureHljs } from "../lib/markdown";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state"; import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react";
import "./ChatView.css"; import "./ChatView.css";
const MAX_MEMORY = 20; const MAX_MEMORY = 20;
@@ -31,37 +32,55 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const [tps, setTps] = useState<number | null>(null); const [tps, setTps] = useState<number | null>(null);
const [useStream, setUseStream] = useState(true); const [useStream, setUseStream] = useState(true);
const [useMemory, setUseMemory] = useState(true); const [useMemory, setUseMemory] = useState(true);
const [streamTokenCount, setStreamTokenCount] = useState(0);
const [messageQueue, setMessageQueue] = useState<string[]>([]);
const [showMemoryModal, setShowMemoryModal] = useState(false);
const [isQueueOpen, setIsQueueOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const activeChatIdRef = useRef<number | null>(chatId); const activeChatIdRef = useRef<number | null>(chatId);
const pendingRequestChatIdRef = useRef<number | null>(null); const pendingRequestChatIdRef = useRef<number | null>(null);
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {}); const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
const messageQueueRef = useRef<string[]>([]);
const sendRef = useRef<(text: string, fromChatId?: number) => Promise<void>>(async () => {});
// Single helper to keep ref and state in sync — use at every queue mutation site
const setQueue = (next: string[]) => {
messageQueueRef.current = next;
setMessageQueue(next);
};
const composerNotice = getComposerNotice(model, server); const composerNotice = getComposerNotice(model, server);
const starterPrompts = getSuggestedPrompts(); const starterPrompts = getSuggestedPrompts();
const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0;
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => { const chatTotals = messages.reduce<ChatTokenTotals>((acc, msg) => {
totals.prompt_tokens += message.prompt_tokens ?? 0; acc.prompt_tokens += msg.prompt_tokens ?? 0;
totals.completion_tokens += message.completion_tokens ?? 0; acc.completion_tokens += msg.completion_tokens ?? 0;
totals.total_tokens += message.total_tokens ?? 0; acc.total_tokens += msg.total_tokens ?? 0;
return totals; return acc;
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); }, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
const memoryMessageCount = chatId != null
? Math.min(
messages.filter(m => m.content && !m.content.startsWith("⚠️") && m.content !== "internal error").length,
useMemory ? MAX_MEMORY : 1,
)
: 0;
useEffect(() => { ensureHljs(); }, []); useEffect(() => { ensureHljs(); }, []);
const loadMessages = useCallback((targetChatId: number | null = chatId) => { const loadMessages = useCallback((targetChatId: number | null = chatId) => {
console.log(`[ChatView] loadMessages: chatId=${targetChatId}`); if (targetChatId == null) { setMessages([]); return; }
if (targetChatId == null) { console.trace("[ChatView] loadMessages: no chatId — clearing messages, called from"); setMessages([]); return; }
const rows = db.query<Message>( const rows = db.query<Message>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC", "SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[targetChatId] [targetChatId],
); );
console.log(`[ChatView] loadMessages: loaded ${rows.length} messages for chat ${targetChatId}`, rows.map(r => ({ id: r.id, role: r.role, contentLen: r.content?.length ?? 0 })));
console.trace(`[ChatView] loadMessages: setMessages(${rows.length}) called from`);
setMessages(rows); setMessages(rows);
}, [db, chatId]); }, [db, chatId]);
// Keep ref always pointing to latest loadMessages so async closures don't go stale
loadMessagesRef.current = loadMessages; loadMessagesRef.current = loadMessages;
useEffect(() => { loadMessages(); }, [loadMessages]); useEffect(() => { loadMessages(); }, [loadMessages]);
@@ -69,18 +88,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
useEffect(() => { useEffect(() => {
activeChatIdRef.current = chatId; activeChatIdRef.current = chatId;
const switchedAwayFromPendingChat = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current; const switchedAway = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
if (abortRef.current && switchedAwayFromPendingChat) { if (abortRef.current && switchedAway) {
abortRef.current.abort(); abortRef.current.abort();
abortRef.current = null; abortRef.current = null;
messageQueueRef.current = [];
setMessageQueue([]);
setGenerating(false); setGenerating(false);
setStreamContent(""); setStreamContent("");
setStreamReasoning(""); setStreamReasoning("");
setStreamThinkingOpen(false); setStreamThinkingOpen(false);
setShowLiveReasoning(false); setShowLiveReasoning(false);
setTps(null); setTps(null);
setStreamTokenCount(0);
pendingRequestChatIdRef.current = null; pendingRequestChatIdRef.current = null;
return; return;
} }
@@ -92,6 +113,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(false); setStreamThinkingOpen(false);
setShowLiveReasoning(false); setShowLiveReasoning(false);
setTps(null); setTps(null);
setStreamTokenCount(0);
} }
}, [chatId]); }, [chatId]);
@@ -109,7 +131,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const getOrCreateChat = useCallback((firstMessage?: string): number => { const getOrCreateChat = useCallback((firstMessage?: string): number => {
if (chatId != null) return chatId; if (chatId != null) return chatId;
const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]); const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
console.log(`[ChatView] getOrCreateChat: created chat id=${newId}`);
onChatCreated(newId); onChatCreated(newId);
return newId; return newId;
}, [chatId, db, onChatCreated]); }, [chatId, db, onChatCreated]);
@@ -127,30 +148,27 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const limit = useMemory ? MAX_MEMORY : 1; const limit = useMemory ? MAX_MEMORY : 1;
return db.query<{ role: string; content: string }>( return db.query<{ role: string; content: string }>(
"SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?", "SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?",
[cid, limit] [cid, limit],
).reverse(); ).reverse();
}, [db, useMemory]); }, [db, useMemory]);
const send = useCallback(async (text: string, fromChatId?: number) => { const send = useCallback(async (text: string, fromChatId?: number) => {
console.log(`[ChatView] send: text="${text.slice(0, 60)}…" fromChatId=${fromChatId} generating=${generating} model=${model} server=${server}`); if (!text.trim() || generating) return;
if (!text.trim() || generating) { console.warn("[ChatView] send: blocked — empty text or already generating"); return; } if (getComposerNotice(model, server)) return;
if (getComposerNotice(model, server)) { console.warn("[ChatView] send: blocked — composerNotice active"); return; }
setGenerating(true); setGenerating(true);
setStreamContent(""); setStreamContent("");
setStreamReasoning(""); setStreamReasoning("");
setStreamThinkingOpen(false); setStreamThinkingOpen(false);
setShowLiveReasoning(false); setShowLiveReasoning(false);
setStreamTokenCount(0);
const cid = fromChatId ?? getOrCreateChat(text); const cid = fromChatId ?? getOrCreateChat(text);
console.log(`[ChatView] send: using chatId=${cid}`);
activeChatIdRef.current = cid; activeChatIdRef.current = cid;
pendingRequestChatIdRef.current = cid; pendingRequestChatIdRef.current = cid;
// Save user message
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]); db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]); const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
console.log(`[ChatView] send: inserted user message, chat now has ${count?.c} messages`);
if (count?.c === 1) ensureChatTitle(cid, text); if (count?.c === 1) ensureChatTitle(cid, text);
loadMessagesRef.current(cid); loadMessagesRef.current(cid);
@@ -162,7 +180,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
abortRef.current = new AbortController(); abortRef.current = new AbortController();
console.log(`[ChatView] send: fetching ${server}/v1/chat/completions — stream=${useStream}, messages=${apiMessages.length}`);
try { try {
const res = await fetch(`${server}/v1/chat/completions`, { const res = await fetch(`${server}/v1/chat/completions`, {
method: "POST", method: "POST",
@@ -177,7 +194,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
}), }),
}); });
console.log(`[ChatView] send: response status=${res.status} ok=${res.ok}`);
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`); throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`);
@@ -188,14 +204,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
let tokenCount = 0; let tokenCount = 0;
if (useStream) { if (useStream) {
console.log("[ChatView] send: starting stream read");
const reader = res.body!.getReader(); const reader = res.body!.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder('utf-8');
let buf = ""; let buf = "";
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { console.log(`[ChatView] send: stream done, total tokens received=${tokenCount}`); break; } if (done) break;
buf += decoder.decode(value, { stream: true }); buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n"); const lines = buf.split("\n");
buf = lines.pop()!; buf = lines.pop()!;
@@ -203,12 +218,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
for (const line of lines) { for (const line of lines) {
if (!line.startsWith("data: ")) continue; if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim(); const data = line.slice(6).trim();
if (data === "[DONE]") { console.log("[ChatView] send: received [DONE]"); continue; } if (data === "[DONE]") continue;
try { try {
const chunk = JSON.parse(data); const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content; const delta = chunk.choices?.[0]?.delta?.content;
if (delta) { if (delta) {
tokenCount++; tokenCount++;
setStreamTokenCount(tokenCount);
fullContent += delta; fullContent += delta;
const parsed = parseAssistantResponse(fullContent); const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content); setStreamContent(parsed.content);
@@ -216,17 +232,14 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(parsed.thinkingOpen); setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true); if (parsed.reasoning) setShowLiveReasoning(true);
} }
if (chunk.usage) { usage = chunk.usage; console.log("[ChatView] send: received usage", usage); } if (chunk.usage) usage = chunk.usage;
} catch (parseErr) { console.warn("[ChatView] send: failed to parse chunk", parseErr, data); } } catch { /* ignore malformed chunks */ }
} }
} }
console.log(`[ChatView] send: stream complete — fullContent.length=${fullContent.length}`);
} else { } else {
console.log("[ChatView] send: reading blocking response");
const data = await res.json(); const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || ""; fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage; usage = data.usage;
console.log(`[ChatView] send: blocking response — fullContent.length=${fullContent.length}, usage=`, usage);
const parsed = parseAssistantResponse(fullContent); const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content); setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning); setStreamReasoning(parsed.reasoning);
@@ -234,13 +247,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
if (parsed.reasoning) setShowLiveReasoning(true); if (parsed.reasoning) setShowLiveReasoning(true);
} }
// Save assistant message — only if there is real content to persist
const parsed = parseAssistantResponse(fullContent); const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim(); const finalContent = parsed.content.trim() || fullContent.trim();
if (!finalContent && !parsed.reasoning) { if (finalContent || parsed.reasoning) {
console.warn("[ChatView] send: assistant returned empty content — not saving to DB");
} else {
console.log(`[ChatView] send: saving assistant message — finalContent.length=${finalContent.length}, reasoning.length=${parsed.reasoning?.length ?? 0}, usage=`, usage);
db.run( db.run(
`INSERT INTO messages ( `INSERT INTO messages (
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
@@ -259,52 +268,58 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]); db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
if (usage?.tokens_per_second) setTps(usage.tokens_per_second); if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
onChatUpdated(); onChatUpdated();
console.log(`[ChatView] send: assistant message saved to DB for chat ${cid}`);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") { if (err instanceof Error && err.name !== "AbortError") {
console.error(`[ChatView] send: error — ${err.message}`, err);
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [ db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
cid, cid,
`⚠️ Error: ${err.message}`, `⚠️ Error: ${err.message}`,
]); ]);
onChatUpdated(); onChatUpdated();
} else {
console.log("[ChatView] send: aborted");
} }
} finally { } finally {
console.log(`[ChatView] send: finally — activeChatIdRef=${activeChatIdRef.current} cid=${cid}`);
// Direct DB check before reload
const dbCheck = db.query<{ id: number; role: string; content: string }>(
"SELECT id, role, substr(content,1,40) as content FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[cid]
);
console.log(`[ChatView] send: DB has ${dbCheck.length} messages for chat ${cid}:`, dbCheck);
setGenerating(false); setGenerating(false);
setStreamContent(""); setStreamContent("");
setStreamReasoning(""); setStreamReasoning("");
setStreamThinkingOpen(false); setStreamThinkingOpen(false);
setShowLiveReasoning(false); setShowLiveReasoning(false);
setStreamTokenCount(0);
if (activeChatIdRef.current === cid) { if (activeChatIdRef.current === cid) {
console.log(`[ChatView] send: reloading messages for chat ${cid}`);
loadMessagesRef.current(cid); loadMessagesRef.current(cid);
} else {
console.warn(`[ChatView] send: skipping reload — active chat changed to ${activeChatIdRef.current}`);
} }
pendingRequestChatIdRef.current = null; pendingRequestChatIdRef.current = null;
abortRef.current = null; abortRef.current = null;
// Process message queue
const nextInQueue = messageQueueRef.current[0];
if (nextInQueue && activeChatIdRef.current === cid) {
setQueue(messageQueueRef.current.slice(1));
setTimeout(() => sendRef.current(nextInQueue), 100);
}
} }
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]); }, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
const handleSend = () => { const handleSend = () => {
const text = input.trim(); const text = input.trim();
if (!text || composerNotice) return; if (!text || composerNotice) return;
setInput(""); setInput("");
if (textareaRef.current) { textareaRef.current.style.height = "auto"; } if (textareaRef.current) textareaRef.current.style.height = "auto";
if (generating) {
setQueue([...messageQueueRef.current, text]);
return;
}
send(text); send(text);
}; };
const handleStop = () => { abortRef.current?.abort(); }; const handleStop = () => {
abortRef.current?.abort();
setQueue([]);
};
const handleDeleteMessage = (id: number) => { const handleDeleteMessage = (id: number) => {
if (!window.confirm("Delete this message?")) return; if (!window.confirm("Delete this message?")) return;
@@ -324,7 +339,11 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]); db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]); db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
loadMessages(); loadMessages();
if (generating) {
setQueue([...messageQueueRef.current, newContent]);
} else {
send(newContent, msg.chat_id); send(newContent, msg.chat_id);
}
}; };
const handleEditAssistant = (id: number, newContent: string) => { const handleEditAssistant = (id: number, newContent: string) => {
@@ -332,51 +351,133 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
loadMessages(); loadMessages();
}; };
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model;
return ( return (
<div className="chat-view"> <div className="chat-view">
{/* Top bar */} {/* ── Top bar ── */}
<div className="chat-topbar"> <div className="chat-topbar">
<div className="chat-topbar-left"> <div className="chat-topbar-left">
{onOpenSidebar && ( {onOpenSidebar && (
<button <button type="button" className="chat-menu-btn" onClick={onOpenSidebar} aria-label="Open navigation">
type="button"
className="chat-menu-btn"
onClick={onOpenSidebar}
aria-label="Open navigation"
>
☰ ☰
</button> </button>
)} )}
{model && (
<div className="topbar-model-badge">
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
</div>
<div className="chat-topbar-center">
{chatId !== null && chatTotals.total_tokens > 0 && ( {chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary"> <div className="chat-token-summary">
<span>Sent {chatTotals.prompt_tokens}</span> <span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span>Generated {chatTotals.completion_tokens}</span> <span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
<span>Total {chatTotals.total_tokens}</span> <span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
</div> </div>
)} )}
{tps !== null && ( </div>
<div className="chat-topbar-right">
{generating && streamTokenCount > 0 && (
<div className="live-token-counter">
<span className="live-token-pulse" />
<span>{streamTokenCount.toLocaleString()} tokens</span>
</div>
)}
{tps !== null && !generating && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span> <span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)} )}
</div>
<div className="chat-topbar-right"> <button
<label className="toggle-label"> type="button"
<input type="checkbox" checked={useStream} onChange={(e) => setUseStream(e.target.checked)} /> className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
Streaming onClick={() => setShowMemoryModal(!showMemoryModal)}
</label> title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
<label className="toggle-label"> aria-label="View memory context"
<input type="checkbox" checked={useMemory} onChange={(e) => setUseMemory(e.target.checked)} /> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
{memoryMessageCount > 0 && (
<span className="memory-count-badge">{memoryMessageCount}</span>
)}
</button>
<div className="topbar-toggles">
<button
type="button"
className={`toggle-pill${useStream ? " on" : " off"}`}
onClick={() => setUseStream(!useStream)}
title={useStream ? "Streaming enabled" : "Streaming disabled"}
>
Stream
</button>
<button
type="button"
className={`toggle-pill${useMemory ? " on" : " off"}`}
onClick={() => setUseMemory(!useMemory)}
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
>
Memory Memory
</label> </button>
</div>
</div> </div>
</div> </div>
{/* Messages */} {/* ── Memory Modal ── */}
<div className="messages-area"> {showMemoryModal && (
<div className="memory-modal-overlay" onClick={() => setShowMemoryModal(false)}>
<div className="memory-modal" onClick={(e) => e.stopPropagation()}>
<div className="memory-modal-header">
<div className="memory-modal-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
<span>Memory Context</span>
</div>
<span className="memory-modal-subtitle">
{memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode
</span>
<button type="button" className="memory-modal-close" onClick={() => setShowMemoryModal(false)}>✕</button>
</div>
<div className="memory-modal-body">
{memoryMessages.length === 0 ? (
<div className="memory-empty">No messages in context yet. Start a conversation to populate memory.</div>
) : (
memoryMessages.map((msg, i) => (
<div key={i} className={`memory-item ${msg.role}`}>
<span className="memory-item-role">{msg.role}</span>
<span className="memory-item-content">
{msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content}
</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* ── Chat Layout ── */}
<div className="chat-layout-root">
{/* Scrollable message area */}
<div className="chat-scroll-area">
<div className="messages-container">
{chatId === null && messages.length === 0 && ( {chatId === null && messages.length === 0 && (
<div className="empty-state"> <div className="empty-state">
<div className="empty-state-panel"> <div className="empty-state-panel">
<div className="empty-icon">⚡</div> <div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first coding workspace</span> <span className="empty-kicker">Local-first AI workspace</span>
<h2>Start with a prompt that moves the project forward.</h2> <h2>Start with a prompt that moves the project forward.</h2>
<p> <p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect, ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
@@ -384,12 +485,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</p> </p>
<div className="empty-state-actions"> <div className="empty-state-actions">
{starterPrompts.map((prompt) => ( {starterPrompts.map((prompt) => (
<button <button key={prompt} type="button" className="starter-chip" onClick={() => handleStarterPrompt(prompt)}>
key={prompt}
type="button"
className="starter-chip"
onClick={() => handleStarterPrompt(prompt)}
>
{prompt} {prompt}
</button> </button>
))} ))}
@@ -425,28 +521,32 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{/* Live streaming bubble */} {/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && ( {generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant"> <div className="msg-wrapper assistant">
<div className="msg-bubble">
{(streamReasoning || streamThinkingOpen) && ( {(streamReasoning || streamThinkingOpen) && (
<div className="reasoning-panel live-reasoning-panel"> <details
<button className="msg-thinking"
className="reasoning-toggle" open={showLiveReasoning}
onClick={() => setShowLiveReasoning((value) => !value)} onToggle={(event) => setShowLiveReasoning(event.currentTarget.open)}
type="button"
> >
<span>{showLiveReasoning ? "▼" : "▶"}</span> <summary className="thinking-summary" aria-label="Toggle live thinking details">
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span> <svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
</button> <path d="M6 4l4 4-4 4" />
{showLiveReasoning && streamReasoning && ( </svg>
<span className="reasoning-live-dot" aria-hidden="true" />
Thinking{streamThinkingOpen ? "..." : ""}
</summary>
{streamReasoning && (
<div <div
className="reasoning-content" className="thinking-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }} dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/> />
)} )}
</div> </details>
)} )}
{streamContent && ( {streamContent && (
<div <div
className="msg-bubble" className="msg-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }} dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/> />
)} )}
@@ -457,6 +557,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</div> </div>
)} )}
</div> </div>
</div>
)} )}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && ( {generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
@@ -468,10 +569,32 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
<div className="scroll-spacer" />
</div>
</div> </div>
{/* Input */} {/* ── Message Queue indicator ── */}
<div className="input-area"> {messageQueue.length > 0 && (
<div className="queue-indicator">
<span className="queue-indicator-pulse" />
<span className="queue-indicator-text">
{messageQueue.length} message{messageQueue.length > 1 ? "s" : ""} queued
</span>
<button
type="button"
className="queue-clear-btn"
onClick={() => { messageQueueRef.current = []; setMessageQueue([]); }}
>
✕ Clear
</button>
</div>
)}
{/* ── Fixed Input ── */}
<div className="input-sticky-wrapper">
<div className="input-gradient-mask" />
<div className="input-content-constrained">
<div className="input-wrapper">
{composerNotice && ( {composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}> <div className={`composer-notice ${composerNotice.tone}`}>
<div> <div>
@@ -485,29 +608,107 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)} )}
</div> </div>
)} )}
<div className="input-row">
{/* Model capabilities — floating above the pill */}
<div className="model-capabilities" aria-label="Model capabilities">
<span className="capability-group">
<span className="label">In</span>
<ChatCircleText size={14} weight="duotone" />
<Image size={14} weight="duotone" />
</span>
<span className="separator">·</span>
<span className="capability-group">
<span className="label">Out</span>
<ChatCircleText size={14} weight="duotone" />
</span>
</div>
{/* The typing pill */}
<div className="input-box">
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
<Microphone size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
<Paperclip size={20} weight="duotone" />
</button>
</div>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="input-textarea"
value={input} value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }} onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}} }}
placeholder={composerNotice ? `${composerNotice.title}. ${composerNotice.description}` : "Message… (Enter to send, Shift+Enter for new line)"} placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Message..."
}
rows={1} rows={1}
disabled={generating} disabled={Boolean(composerNotice)}
/> />
{generating ? ( {generating ? (
<button className="stop-btn" onClick={handleStop}>■ Stop</button> <button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : ( ) : (
<button className="send-btn" onClick={handleSend} disabled={!input.trim() || Boolean(composerNotice)}> <button
Send ↑ className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<ArrowUp size={18} weight="duotone" />
</button> </button>
)} )}
</div> </div>
<div className="input-meta">
<span className="char-count">{input.length} chars</span> {/* Queue system */}
{messageQueue.length > 0 && (
<div className="queue-container">
{isQueueOpen && (
<div className="queue-popover">
<div className="queue-header">
<span>Mensagens em espera</span>
</div>
<ul className="queue-list">
{messageQueue.map((msg, index) => (
<li key={index} className="queue-item">
<span className="queue-text">{msg}</span>
<button
className="queue-remove"
aria-label="Remover da fila"
onClick={() => {
const next = messageQueue.filter((_, i) => i !== index);
setQueue(next);
}}
>
✕
</button>
</li>
))}
</ul>
</div>
)}
<button
className="queue-toggle"
onClick={() => setIsQueueOpen(!isQueueOpen)}
>
{messageQueue.length} queued
</button>
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+252 -67
View File
@@ -1,7 +1,7 @@
/* Edit mode */ /* ── Edit mode ── */
.msg-edit-container { .msg-edit-container {
width: 100%; width: 100%;
max-width: 780px; max-width: 800px;
} }
.msg-edit-textarea { .msg-edit-textarea {
@@ -12,17 +12,19 @@
color: var(--text); color: var(--text);
font-size: 0.9rem; font-size: 0.9rem;
font-family: inherit; font-family: inherit;
padding: 10px 14px; padding: 11px 14px;
resize: none; resize: none;
min-height: 80px; min-height: 80px;
outline: none; outline: none;
line-height: 1.6; line-height: 1.6;
box-shadow: 0 0 0 3px rgba(72,166,255,0.1);
} }
.msg-edit-actions { .msg-edit-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
margin-top: 8px; margin-top: 8px;
flex-wrap: wrap;
} }
.btn-primary { .btn-primary {
@@ -31,13 +33,18 @@
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 6px 14px; padding: 6px 14px;
min-height: 44px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
font-family: inherit; 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:hover { background: var(--accent2); }
.btn-primary:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.22);
}
.btn-secondary { .btn-secondary {
background: var(--bg4); background: var(--bg4);
@@ -45,108 +52,286 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 6px 14px; padding: 6px 14px;
min-height: 44px;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
font-family: inherit; 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: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 { .btn-ghost {
background: none; background: transparent;
color: var(--text3); color: var(--text2);
border: none; border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 6px 10px; padding: 6px 10px;
min-height: 44px;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
transition: color 0.15s; transition: color 0.15s, background 0.15s, border-color 0.15s;
} }
.btn-ghost:hover { color: var(--text2); } .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 {
/* Message actions */ outline: none;
.reasoning-panel { box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
width: 100%;
margin-bottom: 8px;
} }
.reasoning-toggle { /* ── Thinking inside bubble ── */
display: inline-flex; .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; align-items: center;
gap: 8px; gap: 8px;
background: rgba(124, 158, 248, 0.08); padding: 10px 16px;
color: var(--text2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.75rem;
cursor: pointer; 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 { .thinking-summary:hover {
background: rgba(124, 158, 248, 0.12); color: rgba(255, 255, 255, 0.8);
color: var(--text);
} }
.reasoning-content { .thinking-summary:focus-visible {
margin-top: 8px; outline: none;
padding: 12px 14px; box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
background: rgba(124, 158, 248, 0.06); }
border: 1px solid var(--border);
border-radius: var(--radius); .thinking-chevron {
color: var(--text2); width: 14px;
font-size: 0.84rem; 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; 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 { .message-metrics {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
gap: 8px; gap: 8px;
margin-top: 8px; padding-left: 12px;
color: var(--text3); font-size: 0.75rem;
font-size: 0.72rem; opacity: 0.7;
font-variant-numeric: tabular-nums; font-family: monospace;
color: #a8b8cc;
} }
.message-metrics span { /* ── Message footer ── */
background: var(--bg3); .msg-footer {
border: 1px solid var(--border); display: flex;
border-radius: 999px; justify-content: space-between;
padding: 3px 8px; align-items: stretch;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.12);
min-height: 32px;
} }
.msg-actions { /* ── 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; display: flex;
flex-wrap: wrap;
gap: 4px; gap: 4px;
margin-top: 5px; 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; opacity: 0;
transition: opacity 0.15s; pointer-events: none;
transform: translateY(-4px);
transition: all 0.2s ease-out;
z-index: 10;
} }
.msg-wrapper:hover .msg-actions { opacity: 1; }
.msg-wrapper:focus-within .msg-actions { opacity: 1; }
.action-btn { .msg-wrapper.user:hover .compact-actions,
background: none; .msg-wrapper.user:focus-within .compact-actions {
border: 1px solid var(--border); opacity: 1;
color: var(--text2); pointer-events: auto;
border-radius: var(--radius-sm); transform: translateY(0);
padding: 3px 9px;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s;
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); }
@media (hover: none), (max-width: 960px) { .compact-actions .action-btn {
.msg-actions { 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; 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;
opacity: 0;
transition: opacity 0.2s ease-out;
}
.msg-bubble:hover .msg-actions,
.msg-bubble:focus-within .msg-actions {
opacity: 1;
}
.action-btn {
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: background 0.2s ease-out, color 0.2s ease-out;
font-family: inherit;
}
.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 {
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");
});
});
+81 -35
View File
@@ -14,10 +14,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content); const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null); const bubbleRef = useRef<HTMLDivElement>(null);
const reasoningRef = useRef<HTMLDivElement>(null); const thinkingRef = useRef<HTMLDivElement>(null);
// Wire up copy-code buttons inside rendered markdown // Wire up copy-code buttons inside rendered markdown
useEffect(() => { useEffect(() => {
@@ -36,8 +35,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
}); });
useEffect(() => { useEffect(() => {
if (!reasoningRef.current) return; if (!thinkingRef.current) return;
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn"); const buttons = thinkingRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => { buttons.forEach((btn) => {
btn.onclick = () => { btn.onclick = () => {
const code = btn.getAttribute("data-code") || ""; const code = btn.getAttribute("data-code") || "";
@@ -90,6 +89,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
(message.total_tokens ?? 0) > 0 (message.total_tokens ?? 0) > 0
); );
const showFooter = message.role === "assistant";
return ( return (
<div className={`msg-wrapper ${message.role}`}> <div className={`msg-wrapper ${message.role}`}>
{editing ? ( {editing ? (
@@ -118,36 +119,34 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div> </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" ? ( {message.role === "assistant" ? (
<div <div
ref={bubbleRef} ref={bubbleRef}
className="msg-bubble" className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} >
{hasReasoning && (
<details className="msg-thinking">
<summary className="thinking-summary">
<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>
<div className="msg-bubble">{message.content}</div>
)} )}
<div
className="msg-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
{showFooter && (
<div className="msg-footer">
{hasUsage && ( {hasUsage && (
<div className="message-metrics"> <div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span> <span>Sent {message.prompt_tokens ?? 0}</span>
@@ -156,18 +155,65 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null} {message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</div> </div>
)} )}
{!hasUsage && <div />}
<div className="msg-actions"> <div className="msg-actions" aria-label="Message actions">
<button className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage}> <button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
{copied ? "✓ Copied" : "⎘ Copy"} <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>
<button className="action-btn" onClick={startEdit}> <button
✎ Edit{onEditAndResend ? " & Resend" : ""} 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>
<button className="action-btn danger" onClick={() => onDelete(message.id)}> <button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
✕ Delete <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> </button>
</div> </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> </div>
+52 -125
View File
@@ -1,9 +1,7 @@
.sidebar { .sidebar {
width: 280px; width: 280px;
min-width: 280px; min-width: 280px;
background: background: var(--bg2);
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
var(--bg2);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -13,7 +11,7 @@
.sidebar-header-actions { .sidebar-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
} }
.sidebar-close-btn { .sidebar-close-btn {
@@ -24,7 +22,7 @@
.sidebar-header { .sidebar-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 14px 14px 12px; padding: 16px 16px 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
@@ -33,32 +31,15 @@
.sidebar-brand { .sidebar-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.brand-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #2b88e6, #49c0ff);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(91,106,240,0.35);
}
.brand-name { .brand-name {
font-size: 15px; font-size: 14px;
font-weight: 800; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.01em;
background: linear-gradient(135deg, #f5fbff, #89b5d9); color: var(--text);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.icon-btn { .icon-btn {
@@ -66,16 +47,15 @@
border: none; border: none;
color: var(--text3); color: var(--text3);
cursor: pointer; cursor: pointer;
font-size: 15px; width: 32px;
width: 36px; height: 32px;
height: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
flex-shrink: 0; flex-shrink: 0;
line-height: 1; padding: 0;
} }
.icon-btn:hover { .icon-btn:hover {
@@ -83,61 +63,16 @@
color: var(--text); 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 ── */
.model-section { .model-section {
padding: 8px 12px; padding: 10px 16px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
.model-section-label { .model-section-label {
font-size: 10px; font-size: 11px;
font-weight: 700; font-weight: 600;
color: var(--text3); color: var(--text3);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -147,14 +82,7 @@
.model-row { .model-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 8px;
}
.model-help {
margin-top: 8px;
font-size: 11px;
color: var(--text3);
line-height: 1.5;
} }
.status-dot { .status-dot {
@@ -186,56 +114,67 @@
.model-select { .model-select {
flex: 1; flex: 1;
background: var(--bg4); background: transparent;
border: 1px solid var(--border); border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text); color: var(--text);
font-size: 12px; font-size: 13px;
padding: 6px 28px 6px 9px; padding: 6px 28px 6px 8px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
min-width: 0; min-width: 0;
font-family: inherit; font-family: inherit;
transition: border-color 0.15s; transition: border-color 0.15s, background 0.15s;
appearance: none; 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-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-repeat: no-repeat;
background-position: right 8px center; background-position: right 8px center;
} }
.model-select:hover {
background-color: var(--bg3);
border-color: var(--border);
}
.model-select:focus { .model-select:focus {
border-color: var(--accent); border-color: var(--accent);
background-color: var(--bg3);
}
.model-select:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
/* ── Sidebar actions ── */ /* ── Sidebar actions ── */
.sidebar-actions { .sidebar-actions {
padding: 10px 12px 6px; padding: 10px 16px 6px;
flex-shrink: 0; flex-shrink: 0;
} }
.new-chat-btn { .new-chat-btn {
width: 100%; width: 100%;
background: linear-gradient(135deg, #2b88e6, #49c0ff); background: var(--bg3);
color: #fff; color: var(--text2);
border: none; border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 9px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 500;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 7px; gap: 8px;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; transition: background 0.15s, color 0.15s, border-color 0.15s;
font-family: inherit; font-family: inherit;
letter-spacing: 0.01em; height: 36px;
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
} }
.new-chat-btn:hover { .new-chat-btn:hover {
opacity: 0.9; background: var(--bg4);
box-shadow: 0 4px 16px rgba(72,166,255,0.4); color: var(--text);
border-color: var(--border);
} }
.new-chat-btn:active { .new-chat-btn:active {
@@ -244,12 +183,12 @@
/* ── Chat list ── */ /* ── Chat list ── */
.chat-list-section-label { .chat-list-section-label {
font-size: 10px; font-size: 11px;
font-weight: 700; font-weight: 600;
color: var(--text3); color: var(--text3);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
padding: 8px 12px 4px; padding: 12px 16px 4px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -279,7 +218,6 @@
color: var(--text2); color: var(--text2);
transition: background 0.12s, color 0.12s; transition: background 0.12s, color 0.12s;
position: relative; position: relative;
border-left: 2px solid transparent;
} }
.chat-item-button { .chat-item-button {
@@ -287,7 +225,6 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
padding: 8px 10px; padding: 8px 10px;
background: transparent; background: transparent;
border: 0; border: 0;
@@ -295,6 +232,7 @@
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
text-align: left; text-align: left;
border-radius: var(--radius-sm);
} }
.chat-item:hover { .chat-item:hover {
@@ -308,19 +246,8 @@
} }
.chat-item.active { .chat-item.active {
background: rgba(91,106,240,0.1); background: var(--bg4);
color: var(--text); 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 { .chat-item-title {
@@ -329,6 +256,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13px; font-size: 13px;
line-height: 1.4;
} }
.chat-item-del { .chat-item-del {
@@ -337,7 +265,6 @@
border: none; border: none;
color: var(--text3); color: var(--text3);
cursor: pointer; cursor: pointer;
font-size: 12px;
width: 28px; width: 28px;
height: 28px; height: 28px;
display: flex; display: flex;
@@ -346,8 +273,8 @@
border-radius: 4px; border-radius: 4px;
transition: opacity 0.15s, background 0.15s, color 0.15s; transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0; flex-shrink: 0;
line-height: 1; padding: 0;
margin-right: 6px; margin-right: 4px;
} }
.chat-item:hover .chat-item-del { .chat-item:hover .chat-item-del {
@@ -366,7 +293,7 @@
/* ── Footer ── */ /* ── Footer ── */
.sidebar-footer { .sidebar-footer {
padding: 8px 14px; padding: 8px 16px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
display: flex; display: flex;
align-items: center; align-items: center;
+14 -48
View File
@@ -1,7 +1,5 @@
import { useState } from "react";
import type { Chat, ModelInfo, ServerStatus } from "../lib/types"; import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import RuntimeWorkspace from "../features/runtime/components/RuntimeWorkspace"; import { Plus, Settings, X, Trash2, RefreshCw } from "lucide-react";
import type { RuntimeConfig, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot } from "../features/runtime/types";
import "./Sidebar.css"; import "./Sidebar.css";
interface Props { interface Props {
@@ -15,59 +13,33 @@ interface Props {
onModelChange: (v: string) => void; onModelChange: (v: string) => void;
status: ServerStatus; status: ServerStatus;
onRefreshModels: () => void; onRefreshModels: () => void;
runtime: { onOpenSettings: () => void;
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>;
};
onCloseMobile: () => void; onCloseMobile: () => void;
} }
export default function Sidebar({ export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat, chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
models, selectedModel, onModelChange, status, onRefreshModels, models, selectedModel, onModelChange, status, onRefreshModels,
runtime, onOpenSettings,
onCloseMobile, onCloseMobile,
}: Props) { }: Props) {
const modelCountLabel = models.length === 1 ? "1 model available" : `${models.length} models available`;
return ( return (
<> <>
<aside className="sidebar"> <aside className="sidebar">
{/* ── Header ── */} {/* ── Header ── */}
<div className="sidebar-header"> <div className="sidebar-header">
<div className="sidebar-brand"> <div className="sidebar-brand">
<div className="brand-icon">⚡</div>
<span className="brand-name">ForgeLM</span> <span className="brand-name">ForgeLM</span>
</div> </div>
<div className="sidebar-header-actions"> <div className="sidebar-header-actions">
<button <button
className="icon-btn" className="icon-btn"
onClick={() => { onClick={onOpenSettings}
runtime.openDraft(); title="Settings"
}} aria-label="Open settings"
title="Runtime settings"
aria-label="Open runtime settings"
type="button" type="button"
> >
⚙ <Settings size={16} strokeWidth={1.5} />
</button> </button>
<button <button
className="icon-btn sidebar-close-btn" className="icon-btn sidebar-close-btn"
@@ -76,17 +48,14 @@ export default function Sidebar({
aria-label="Close navigation" aria-label="Close navigation"
type="button" type="button"
> >
✕ <X size={16} strokeWidth={1.5} />
</button> </button>
</div> </div>
</div> </div>
{/* ── Unified Runtime Workspace ── */}
<RuntimeWorkspace runtime={runtime} />
{/* ── Active model selector ── */} {/* ── Active model selector ── */}
<div className="model-section"> <div className="model-section">
<div className="model-section-label">Active model</div> <div className="model-section-label">Model</div>
<div className="model-row"> <div className="model-row">
<div className={`status-dot status-${status}`} title={status} aria-hidden="true" /> <div className={`status-dot status-${status}`} title={status} aria-hidden="true" />
<select <select
@@ -94,7 +63,7 @@ export default function Sidebar({
value={selectedModel} value={selectedModel}
onChange={(e) => onModelChange(e.target.value)} onChange={(e) => onModelChange(e.target.value)}
disabled={models.length === 0} disabled={models.length === 0}
title={models.length === 0 ? "Start the server to load models" : ""} title={models.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model" aria-label="Choose active model"
> >
{models.length === 0 ? ( {models.length === 0 ? (
@@ -112,18 +81,16 @@ export default function Sidebar({
aria-label="Refresh models" aria-label="Refresh models"
type="button" type="button"
> >
↻ <RefreshCw size={14} strokeWidth={1.5} />
</button> </button>
</div> </div>
<div className="model-help">
{models.length === 0 ? "Start the runtime to load available models." : modelCountLabel}
</div>
</div> </div>
{/* ── New chat ── */} {/* ── New chat ── */}
<div className="sidebar-actions"> <div className="sidebar-actions">
<button className="new-chat-btn" onClick={onNewChat} type="button"> <button className="new-chat-btn" onClick={onNewChat} type="button">
<span>+</span> New Chat <Plus size={16} strokeWidth={1.5} />
<span>New Chat</span>
</button> </button>
</div> </div>
@@ -148,7 +115,6 @@ export default function Sidebar({
onClick={() => onSelectChat(chat.id)} onClick={() => onSelectChat(chat.id)}
aria-current={chat.id === currentChatId ? "page" : undefined} aria-current={chat.id === currentChatId ? "page" : undefined}
> >
<span className="chat-item-icon">💬</span>
<span className="chat-item-title">{chat.title}</span> <span className="chat-item-title">{chat.title}</span>
</button> </button>
<button <button
@@ -158,7 +124,7 @@ export default function Sidebar({
title="Delete conversation" title="Delete conversation"
aria-label={`Delete conversation ${chat.title}`} aria-label={`Delete conversation ${chat.title}`}
> >
✕ <Trash2 size={14} strokeWidth={1.5} />
</button> </button>
</div> </div>
))} ))}
@@ -16,6 +16,7 @@ import {
resolveDraftModelSelection, resolveDraftModelSelection,
toModelFile, toModelFile,
} from "../runtime-settings-form"; } from "../runtime-settings-form";
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime-defaults";
import { import {
createRuntimeDraftFieldState, createRuntimeDraftFieldState,
type ModelLibraryState, type ModelLibraryState,
@@ -31,7 +32,6 @@ import {
} from "../types"; } from "../types";
import "./RuntimeSettingsDrawer.css"; import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
const REMOTE_PRESET_TEMPLATES = [ const REMOTE_PRESET_TEMPLATES = [
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" }, { 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" }, { id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
@@ -799,10 +799,10 @@ export default function RuntimeSettingsDrawer({
<input <input
id="runtime-context" id="runtime-context"
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`} className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
min={512} min={MIN_CTX_SIZE}
step={512} step={512}
value={draftConfig.ctx_size} 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" type="number"
/> />
</div> </div>
@@ -28,6 +28,11 @@
gap: 10px; gap: 10px;
} }
.runtime-card-header-left {
min-width: 0;
flex: 1;
}
.runtime-card-kicker { .runtime-card-kicker {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -42,6 +47,25 @@
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--text); 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 { .runtime-card-mode {
@@ -92,6 +116,13 @@
padding: 7px 10px; padding: 7px 10px;
} }
.runtime-card-meta-model {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-card-note { .runtime-card-note {
display: block; display: block;
margin-top: 10px; margin-top: 10px;
@@ -177,12 +208,63 @@
border-color: var(--accent); 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; color: #fff;
border-color: rgba(72, 166, 255, 0.6); border-color: rgba(72, 166, 255, 0.6);
background: linear-gradient(135deg, #2b88e6, #46c2ff); 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); 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-warning"
: "runtime-card-result-success"; : "runtime-card-result-success";
const isRunning = isLocal && snapshot.process_running;
const isStopping = isLocal && snapshot.status === "stopping";
const primaryVariant = needsSetup ? "setup" : isRunning ? "stop" : "start";
return ( return (
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary"> <section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
<div className="runtime-card-header"> <div className="runtime-card-header">
<div> <div className="runtime-card-header-left">
<div className="runtime-card-kicker">Runtime Manager</div> <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> </div>
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}> <span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"} {snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
</span> </span>
</div> </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" {snapshot.config.connection_mode === "local"
? `Managed endpoint ${snapshot.resolved_url}` ? `Managed endpoint ${snapshot.resolved_url}`
: snapshot.config.server_url || "Configure a remote API endpoint"} : snapshot.config.server_url || "Configure a remote API endpoint"}
</p> </p>
<div className="runtime-card-meta" aria-label="Runtime details"> <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> <span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
</div> </div>
@@ -104,7 +113,13 @@ export default function RuntimeStatusCard({
Manage Manage
</button> </button>
{snapshot.config.connection_mode === "local" && ( {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} {primaryLabel}
</button> </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, toModelFile,
type RuntimeSettingsFormState, type RuntimeSettingsFormState,
} from "./runtime-settings-form"; } from "./runtime-settings-form";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
import { createDefaultRuntimeSnapshot } from "./runtime-utils"; import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import { import {
createRuntimeDraftState, 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", () => { it("keeps saved local values untouched when editing remote mode", () => {
const snapshot = createDefaultRuntimeSnapshot(); const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch( const patch = createRuntimeConfigPatch(
@@ -4,6 +4,7 @@ import type {
RuntimeModelFile, RuntimeModelFile,
StartPolicy, StartPolicy,
} from "./types"; } from "./types";
import { DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "./runtime-defaults";
function normalizeUrl(value: string): string { function normalizeUrl(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -126,7 +127,7 @@ export function createRuntimeConfigPatch(
form.mode === "local" form.mode === "local"
? Math.max(0, Math.min(form.gpuLayers, maxLayers)) ? Math.max(0, Math.min(form.gpuLayers, maxLayers))
: snapshotConfig.gpu_layers, : 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, selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
extra_model_paths: extra_model_paths:
form.mode === "local" form.mode === "local"
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest"; 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"; import type { RuntimeConfig, RuntimeSnapshot } from "./types";
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & { type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
@@ -42,6 +50,10 @@ function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
} }
describe("getRuntimeConnection", () => { 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", () => { it("prefers the resolved local runtime url in local mode", () => {
const connection = getRuntimeConnection(createSnapshot({ const connection = getRuntimeConnection(createSnapshot({
resolved_url: "http://127.0.0.1:9000", 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"); 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", () => { it("reports local background start progress", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API"); 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", () => { describe("getRuntimeStartGuard", () => {
it("requires a saved model before starting the local runtime", () => { it("requires a saved model before starting the local runtime", () => {
const guard = getRuntimeStartGuard(createSnapshot({ const guard = getRuntimeStartGuard(createSnapshot({
@@ -13,6 +13,7 @@ import type {
RuntimeWorkspaceSummary, RuntimeWorkspaceSummary,
} from "./types"; } from "./types";
import { scanRuntimeModels } from "./api"; import { scanRuntimeModels } from "./api";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080"; const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
@@ -179,6 +180,10 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
return "Remote API"; return "Remote API";
} }
if (snapshot.status === "ready" && !snapshot.ready) {
return snapshot.process_running ? "Not ready" : "Local API offline";
}
switch (snapshot.status) { switch (snapshot.status) {
case "ready": case "ready":
return "Ready"; return "Ready";
@@ -200,6 +205,10 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
return "neutral"; return "neutral";
} }
if (snapshot.status === "ready" && !snapshot.ready) {
return snapshot.process_running ? "danger" : "neutral";
}
switch (snapshot.status) { switch (snapshot.status) {
case "ready": case "ready":
return "success"; return "success";
@@ -299,7 +308,7 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
models_dir: null, models_dir: null,
addr: "127.0.0.1:8080", addr: "127.0.0.1:8080",
gpu_layers: 0, gpu_layers: 0,
ctx_size: 4096, ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: [], extra_model_paths: [],
selected_model_id: null, selected_model_id: null,
remote_presets: [], remote_presets: [],
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,922 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
pickRuntimeModelFiles,
pickRuntimeModelsDir,
queryRuntimeGpu,
searchHuggingFaceModels,
} from "../runtime/api";
import {
dedupeModels,
pathName,
toModelFile,
} from "../runtime/runtime-settings-form";
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime/runtime-defaults";
import { getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime/runtime-utils";
import type {
HuggingFaceModelResult,
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimeSnapshot,
} from "../runtime/types";
import { Box, Zap, ArrowLeftRight, Settings2, X, Trash2, Search, Info, Download } from "lucide-react";
import "./SettingsPage.css";
// ─── Types ───────────────────────────────────────────────────────────────────
type Section = "model" | "runtime" | "connection" | "advanced";
interface SettingsRuntime {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
loading: boolean;
busy: boolean;
lifecycleBusy: boolean;
scannedModels: RuntimeModelFile[];
isScanningModels: boolean;
updateDraft: (patch: Partial<RuntimeConfig>) => void;
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal: () => Promise<unknown>;
stopLocal: () => Promise<unknown>;
scanModels: (dir: string | null) => Promise<void>;
resetDraft: () => void;
}
interface Props {
runtime: SettingsRuntime;
onClose: () => void;
}
// ─── Nav definition ──────────────────────────────────────────────────────────
const NAV: { id: Section; label: string; icon: typeof Box }[] = [
{ id: "model", label: "Model", icon: Box },
{ id: "runtime", label: "Runtime", icon: Zap },
{ id: "connection", label: "Connection", icon: ArrowLeftRight },
{ id: "advanced", label: "Advanced", icon: Settings2 },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatBytes(bytes: number | null | undefined): string {
if (!bytes) return "";
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GiB`;
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MiB`;
return `${(bytes / 1024).toFixed(0)} KiB`;
}
function statusColor(snapshot: RuntimeSnapshot): string {
const tone = getRuntimeStatusTone(snapshot);
if (tone === "success") return "sp-dot--success";
if (tone === "warning") return "sp-dot--warn";
if (tone === "danger") return "sp-dot--danger";
return "sp-dot--idle";
}
function statusPillClass(snapshot: RuntimeSnapshot): string {
const tone = getRuntimeStatusTone(snapshot);
if (tone === "success") return "sp-status-pill--success";
if (tone === "warning") return "sp-status-pill--warn";
if (tone === "danger") return "sp-status-pill--danger";
return "";
}
// ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage({ runtime, onClose }: Props) {
const {
snapshot, savedBaseline, draftConfig, draftDirty,
loading, busy, lifecycleBusy,
scannedModels, isScanningModels,
updateDraft, saveConfig, startLocal, stopLocal, scanModels, resetDraft,
} = runtime;
const [section, setSection] = useState<Section>("model");
const [hfQuery, setHfQuery] = useState("");
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
const [hfLoading, setHfLoading] = useState(false);
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
const [localError, setLocalError] = useState("");
const [saving, setSaving] = useState(false);
const didAutoScan = useRef(false);
const busyAll = busy || lifecycleBusy || saving;
// ── Side-effects ──────────────────────────────────────────────────────────
useEffect(() => {
queryRuntimeGpu().then(setGpuInfo).catch(() => undefined);
}, []);
useEffect(() => {
if (section !== "model" || didAutoScan.current) return;
const dir = draftConfig.models_dir?.trim();
if (dir && scannedModels.length === 0 && !isScanningModels) {
didAutoScan.current = true;
void scanModels(dir);
}
}, [section, draftConfig.models_dir, scannedModels.length, isScanningModels, scanModels]);
// ── Derived data ──────────────────────────────────────────────────────────
const allModels = dedupeModels([
...scannedModels,
...draftConfig.extra_model_paths.map(toModelFile),
]);
const isLocal = draftConfig.connection_mode === "local";
const isRunning = snapshot.process_running;
const isStopping = snapshot.status === "stopping";
const runtimeStatusLabel = loading ? "Loading..." : getRuntimeStateLabel(snapshot);
// ── Handlers ─────────────────────────────────────────────────────────────
const handlePickDir = useCallback(async () => {
setLocalError("");
try {
const result = await pickRuntimeModelsDir();
if (!result) return;
updateDraft({ models_dir: result.dir });
void scanModels(result.dir);
} catch (e) {
setLocalError(String(e));
}
}, [updateDraft, scanModels]);
const handlePickFiles = useCallback(async () => {
setLocalError("");
try {
const picked = await pickRuntimeModelFiles();
if (picked.length === 0) return;
const next = dedupeModels([
...draftConfig.extra_model_paths.map(toModelFile),
...picked,
]);
updateDraft({
extra_model_paths: next.map((m) => m.path),
model_path: draftConfig.model_path || next[0]?.path || null,
});
} catch (e) {
setLocalError(String(e));
}
}, [draftConfig, updateDraft]);
const handleSelectModel = useCallback((path: string) => {
updateDraft({ model_path: path, selected_model_id: null });
}, [updateDraft]);
const handleRemoveExtra = useCallback((path: string) => {
const next = draftConfig.extra_model_paths.filter((p) => p !== path);
updateDraft({
extra_model_paths: next,
model_path: draftConfig.model_path === path
? (next[0] ?? scannedModels[0]?.path ?? null)
: draftConfig.model_path,
});
}, [draftConfig, scannedModels, updateDraft]);
const handleHfSearch = useCallback(async () => {
if (!hfQuery.trim()) return;
setHfLoading(true);
setLocalError("");
try {
const results = await searchHuggingFaceModels(hfQuery, draftConfig.hugging_face_token || "");
setHfResults(results);
} catch (e) {
setLocalError(String(e));
} finally {
setHfLoading(false);
}
}, [hfQuery, draftConfig.hugging_face_token]);
const handleSave = useCallback(async () => {
setSaving(true);
setLocalError("");
try {
await saveConfig(draftConfig);
} catch (e) {
setLocalError(String(e));
} finally {
setSaving(false);
}
}, [saveConfig, draftConfig]);
const handleSaveAndStart = useCallback(async () => {
setSaving(true);
setLocalError("");
try {
await saveConfig(draftConfig);
await startLocal();
} catch (e) {
setLocalError(String(e));
} finally {
setSaving(false);
}
}, [saveConfig, draftConfig, startLocal]);
const handleStop = useCallback(async () => {
setLocalError("");
try {
await stopLocal();
} catch (e) {
setLocalError(String(e));
}
}, [stopLocal]);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="sp-shell flex h-full overflow-hidden">
{/* ── Left navigation ──────────────────────────────────────────── */}
<nav className="sp-nav">
<div className="sp-nav-header">
<div className="sp-nav-brand">ForgeLM</div>
<h2 className="sp-nav-title">Settings</h2>
</div>
<div className="sp-nav-items">
{NAV.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => setSection(item.id)}
className={`sp-nav-item${section === item.id ? " sp-nav-item--active" : ""}`}
>
<Icon size={16} strokeWidth={1.5} />
<span className="flex-1">{item.label}</span>
</button>
);
})}
</div>
{/* Runtime status pill */}
<div className="sp-nav-status">
<span className={`sp-dot ${statusColor(snapshot)}`} />
<span className="text-xs sp-nav-status-label">
{runtimeStatusLabel}
</span>
</div>
<div className="sp-nav-footer">
<button type="button" onClick={onClose} className="sp-back-btn">
← Back to Chat
</button>
</div>
</nav>
{/* ── Main area (right side) ─────────────────────────────────── */}
<div className="settings-main-area">
<div className="settings-scroll-container">
<div className="settings-content">
{/* ═══════════════════════════════════ MODEL SECTION ═══════════════════════════ */}
{section === "model" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Model</h2>
<p className="sp-section-desc">
Select a local GGUF model file for inference.
</p>
</div>
{isRunning && (
<span className={`sp-status-pill ${statusPillClass(snapshot)}`}>
<span className="sp-dot sp-dot--inline" />
{isStopping ? "Stopping local API" : getRuntimeStateLabel(snapshot)}
</span>
)}
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
{/* Active model */}
<div className="sp-card">
{snapshot.config.model_path ? (
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Active model</div>
<div className="sp-card-row-desc">Currently loaded GGUF file</div>
</div>
<div className="sp-card-row-control">
<div className="sp-model-name">{pathName(snapshot.config.model_path)}</div>
<div className="sp-model-path">{snapshot.config.model_path}</div>
{draftDirty && draftConfig.model_path !== snapshot.config.model_path && (
<div className="sp-pending mt-2">
Draft → {pathName(draftConfig.model_path) || "None selected"}
</div>
)}
</div>
</div>
) : (
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Active model</div>
<div className="sp-card-row-desc">No model selected yet</div>
</div>
<div className="sp-card-row-control">
<p className="sp-empty">Use the sections below to load one.</p>
</div>
</div>
)}
{/* Scan folder */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Scan folder</div>
<div className="sp-card-row-desc">Browse and scan a directory for .gguf files</div>
</div>
<div className="sp-card-row-control">
<div className="sp-folder-actions">
<button
type="button"
className="sp-btn sp-btn--secondary"
onClick={() => void handlePickDir()}
disabled={busyAll}
>
Browse…
</button>
{draftConfig.models_dir && (
<button
type="button"
className="sp-btn sp-btn--ghost"
onClick={() => { void scanModels(draftConfig.models_dir!); }}
disabled={busyAll || isScanningModels}
>
{isScanningModels ? "Scanning…" : "Rescan"}
</button>
)}
</div>
{draftConfig.models_dir && (
<div className="sp-folder-path">{draftConfig.models_dir}</div>
)}
</div>
</div>
{/* Model list from scan */}
{(isScanningModels || scannedModels.length > 0) && (
<>
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
{isScanningModels && scannedModels.length === 0 && (
<div className="sp-scanning-row">Scanning for models…</div>
)}
<div className="sp-model-list">
{scannedModels.map((model) => {
const selected = draftConfig.model_path === model.path;
return (
<button
key={model.path}
type="button"
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}`}
onClick={() => handleSelectModel(model.path)}
disabled={busyAll}
>
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
<span className="flex-1 truncate sp-model-row-name">{model.name}</span>
</button>
);
})}
</div>
</div>
</>
)}
{/* Browse individual files */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Individual files</div>
<div className="sp-card-row-desc">Add specific .gguf files without scanning a folder</div>
</div>
<div className="sp-card-row-control">
<button
type="button"
className="sp-btn sp-btn--secondary"
onClick={() => void handlePickFiles()}
disabled={busyAll}
>
Pick files…
</button>
{draftConfig.extra_model_paths.length > 0 && (
<div className="sp-model-list mt-3">
{draftConfig.extra_model_paths.map((p) => {
const selected = draftConfig.model_path === p;
return (
<div
key={p}
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}${busyAll ? " sp-model-row--disabled" : ""}`}
role="row"
>
<button
type="button"
className="sp-model-row-select"
onClick={() => handleSelectModel(p)}
disabled={busyAll}
aria-pressed={selected}
>
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
<span className="flex-1 truncate sp-model-row-name">{pathName(p)}</span>
</button>
<button
type="button"
className="sp-remove-btn"
onClick={() => handleRemoveExtra(p)}
disabled={busyAll}
aria-label="Remove file"
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
{/* HuggingFace search */}
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
<div className="sp-hf-search-header">
<div>
<div className="sp-card-row-title">Search HuggingFace</div>
<div className="sp-card-row-desc">
{!draftConfig.hugging_face_token && "Add HF token in Advanced for private models"}
</div>
</div>
<span className="tooltip-container">
<Info size={14} className="sp-hf-info-icon" />
<div className="sp-tooltip-content">
<div className="sp-hf-tooltip-title">O que é Quantização?</div>
<p className="sp-hf-tooltip-body">
É a compressão do modelo para que ele rode mais rápido e caiba na memória (RAM/VRAM) do seu PC.
</p>
<div className="sp-hf-tooltip-levels">
<span><span className="sp-hf-dot sp-hf-dot--green" /> <strong>Máxima Qualidade (Pesado):</strong> Q8_0, Q6_K — Quase sem perda de inteligência.</span>
<span><span className="sp-hf-dot sp-hf-dot--blue" /> <strong>Equilíbrio Ideal (Recomendado):</strong> Q4_K_M, Q5_K_M — Melhor custo-benefício entre velocidade e resposta.</span>
<span><span className="sp-hf-dot sp-hf-dot--yellow" /> <strong>Alta Compressão (Leve):</strong> Q2_K, Q3_K_M — Roda em PCs fracos, mas o modelo fica mais "esquecido" ou impreciso.</span>
</div>
</div>
</span>
</div>
<div className="sp-hf-search-bar">
<input
className="sp-input sp-hf-input"
placeholder="Search for GGUF models…"
value={hfQuery}
onChange={(e) => setHfQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleHfSearch(); }}
/>
<button
type="button"
className="sp-btn sp-btn--primary"
onClick={() => void handleHfSearch()}
disabled={hfLoading || !hfQuery.trim()}
>
<Search size={14} strokeWidth={2} />
{hfLoading ? "…" : "Search"}
</button>
</div>
{hfResults.length > 0 && (
<div className="sp-hf-results">
{hfResults.map((result) => (
<div key={result.repo_id} className="sp-hf-card">
<div className="sp-hf-card-header">
<div className="sp-hf-repo">{result.repo_id}</div>
<div className="sp-hf-author">{result.author || "Unknown author"}</div>
</div>
{result.verified_files.length > 0 && (
<div className="sp-hf-files">
{result.verified_files.map((file) => {
const fileName = file.path.replace(/\.gguf$/i, "");
const fileSize = file.size ? `${(file.size / 1_073_741_824).toFixed(1)} GB` : "—";
return (
<div key={`${result.repo_id}:${file.path}`} className="sp-hf-file">
<span className="sp-hf-name truncate" title={file.path}>{fileName}</span>
<span className="sp-hf-size">{fileSize}</span>
<span className="sp-hf-quant-badge">{file.quantization || "gguf"}</span>
<button
type="button"
className="sp-hf-download-btn"
title={`Download ${file.path}`}
onClick={() => {
// TODO: wire up actual download
console.log("Download:", file.path);
}}
>
<Download size={16} strokeWidth={2} />
</button>
</div>
);
})}
</div>
)}
</div>
))}
</div>
)}
{!hfLoading && hfQuery && hfResults.length === 0 && (
<p className="sp-hint mt-3">No results. Try a different query.</p>
)}
</div>
</div>
</div>
</>
)}
{/* ═══════════════════════════════════ RUNTIME SECTION ════════════════════════ */}
{section === "runtime" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Runtime</h2>
<p className="sp-section-desc">GPU acceleration, context window, and startup policy.</p>
</div>
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
<div className="sp-card">
{/* GPU acceleration */}
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">GPU acceleration</div>
<div className="sp-card-row-desc">
{gpuInfo ? `${gpuInfo.name} · ${gpuInfo.vram_mib} MiB VRAM` : "No GPU detected — CPU only"}
</div>
</div>
<div className="sp-card-row-control">
{gpuInfo && (
<span className="sp-status-pill sp-status-pill--success">GPU detected</span>
)}
</div>
</div>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">GPU layers</div>
<div className="sp-card-row-desc">Layers to offload to the GPU</div>
</div>
<div className="sp-card-row-control sp-card-row-control--wide">
<div className="sp-field-value">{draftConfig.gpu_layers}</div>
<input
type="range"
min={0}
max={gpuInfo ? Math.max(gpuInfo.recommended_layers, draftConfig.gpu_layers, 64) : 64}
value={draftConfig.gpu_layers}
onChange={(e) => updateDraft({ gpu_layers: Number(e.target.value) })}
className="sp-slider w-full mt-2"
disabled={busyAll}
/>
<div className="sp-slider-presets">
<button
type="button"
className="sp-btn-xs"
onClick={() => updateDraft({ gpu_layers: 0 })}
>
0 — CPU only
</button>
{gpuInfo && (
<button
type="button"
className="sp-btn-xs"
onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })}
>
{gpuInfo.recommended_layers} — Recommended
</button>
)}
</div>
</div>
</div>
{/* Context window */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Context window</div>
<div className="sp-card-row-desc">Maximum tokens the model can process</div>
</div>
<div className="sp-card-row-control sp-card-row-control--wide">
<div className="sp-field-value">{draftConfig.ctx_size.toLocaleString()} tokens</div>
<div className="sp-segmented">
{CTX_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => updateDraft({ ctx_size: preset })}
className={`sp-segmented-btn${draftConfig.ctx_size === preset ? " sp-segmented-btn--active" : ""}`}
disabled={busyAll}
>
{preset >= 1024 ? `${preset / 1024}K` : preset}
</button>
))}
</div>
<div className="sp-ctx-custom">
<input
type="number"
className="sp-input sp-input--sm"
value={draftConfig.ctx_size}
min={MIN_CTX_SIZE}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= MIN_CTX_SIZE) updateDraft({ ctx_size: v });
}}
disabled={busyAll}
/>
<span className="sp-hint">Min {MIN_CTX_SIZE.toLocaleString()}</span>
</div>
</div>
</div>
{/* Startup policy */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Startup policy</div>
<div className="sp-card-row-desc">When to start the local API</div>
</div>
<div className="sp-card-row-control">
<div className="sp-toggles">
{(["manual", "on_app_launch"] as const).map((policy) => {
const active = draftConfig.start_policy === policy;
return (
<label key={policy} className={`sp-toggle${active ? " sp-toggle--active" : ""}`}>
<input
type="checkbox"
checked={active}
onChange={() => updateDraft({ start_policy: policy })}
disabled={busyAll}
/>
<span className="sp-toggle-track" />
<span className="sp-toggle-label">
{policy === "manual" ? "Manual" : "Auto-start"}
</span>
</label>
);
})}
</div>
<div className="sp-toggle-desc">
{draftConfig.start_policy === "manual"
? "Start the local API from the runtime panel when needed."
: "Automatically start the local API when ForgeLM opens."}
</div>
</div>
</div>
</div>
</div>
</>
)}
{/* ═══════════════════════════════════ CONNECTION SECTION ═════════════════════ */}
{section === "connection" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Connection</h2>
<p className="sp-section-desc">
Use the built-in local inference engine or connect to a remote OpenAI-compatible server.
</p>
</div>
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
<div className="sp-card">
{/* Mode */}
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Mode</div>
<div className="sp-card-row-desc">
{draftConfig.connection_mode === "local"
? "Runs the inference server locally on your machine."
: "Forwards all requests to an external OpenAI-compatible endpoint."}
</div>
</div>
<div className="sp-card-row-control">
<div className="sp-segmented">
{(["local", "remote"] as const).map((mode) => {
const active = draftConfig.connection_mode === mode;
return (
<button
key={mode}
type="button"
className={`sp-segmented-btn${active ? " sp-segmented-btn--active" : ""}`}
onClick={() => updateDraft({ connection_mode: mode })}
disabled={busyAll}
>
{mode === "local" ? "Local API" : "Remote API"}
</button>
);
})}
</div>
</div>
</div>
{/* Local settings */}
{draftConfig.connection_mode === "local" && (
<>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Bind address</div>
<div className="sp-card-row-desc">Host and port for the local API</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
value={draftConfig.addr}
onChange={(e) => updateDraft({ addr: e.target.value })}
placeholder="127.0.0.1:8080"
disabled={busyAll}
/>
<p className="sp-hint mt-2">
Available at{" "}
<code>http://{draftConfig.addr || "127.0.0.1:8080"}</code>
</p>
</div>
</div>
</>
)}
{/* Remote settings */}
{draftConfig.connection_mode === "remote" && (
<>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Server URL</div>
<div className="sp-card-row-desc">Base URL of the remote API</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
value={draftConfig.server_url}
onChange={(e) => updateDraft({ server_url: e.target.value })}
placeholder="https://api.openai.com/v1"
disabled={busyAll}
/>
</div>
</div>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">API key</div>
<div className="sp-card-row-desc">Authentication key for the remote server</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
type="password"
value={draftConfig.api_key}
onChange={(e) => updateDraft({ api_key: e.target.value })}
placeholder="sk-…"
disabled={busyAll}
autoComplete="off"
/>
</div>
</div>
</>
)}
</div>
</div>
</>
)}
{/* ═══════════════════════════════════ ADVANCED SECTION ═══════════════════════ */}
{section === "advanced" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Advanced</h2>
<p className="sp-section-desc">HuggingFace access and diagnostic information.</p>
</div>
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
<div className="sp-card">
{/* HF Token */}
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">HuggingFace token</div>
<div className="sp-card-row-desc">Required for gated or private models</div>
</div>
<div className="sp-card-row-control">
{draftConfig.hugging_face_token && (
<span className="sp-status-pill sp-status-pill--success">Configured</span>
)}
</div>
</div>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Token value</div>
<div className="sp-card-row-desc">Generate at huggingface.co/settings/tokens</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
type="password"
value={draftConfig.hugging_face_token || ""}
onChange={(e) => updateDraft({ hugging_face_token: e.target.value })}
placeholder="hf_…"
disabled={busyAll}
autoComplete="off"
/>
</div>
</div>
{/* Diagnostics */}
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
<div className="sp-card-row-title">Diagnostics</div>
<dl className="sp-diag-list">
<div className="sp-diag-row">
<dt className="sp-diag-key">Runtime status</dt>
<dd className="sp-diag-val">{snapshot.status}</dd>
</div>
<div className="sp-diag-row">
<dt className="sp-diag-key">Binary path</dt>
<dd className="sp-diag-val sp-mono">
{snapshot.binary_path || "Not found"}
</dd>
</div>
{snapshot.resolved_url && (
<div className="sp-diag-row">
<dt className="sp-diag-key">Resolved endpoint</dt>
<dd className="sp-diag-val sp-accent">{snapshot.resolved_url}</dd>
</div>
)}
{snapshot.last_error && (
<div className="sp-diag-row">
<dt className="sp-diag-key">Last error</dt>
<dd className="sp-diag-val sp-danger">{snapshot.last_error.message}</dd>
</div>
)}
</dl>
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className="settings-action-bar">
<div className="action-bar-inner">
<button
type="button"
className="sp-btn sp-btn--ghost btn-discard"
onClick={resetDraft}
disabled={busyAll || !draftDirty}
>
Discard
</button>
<button
type="button"
className="sp-btn sp-btn--secondary btn-save"
onClick={() => void handleSave()}
disabled={busyAll || !draftDirty}
>
{saving ? "Saving…" : "Save"}
</button>
{isLocal && !isRunning && (
<button
type="button"
className="sp-btn sp-btn--primary btn-save"
onClick={() => void handleSaveAndStart()}
disabled={busyAll || !Boolean(draftConfig.model_path)}
>
{saving ? "Starting…" : "Save & Start"}
</button>
)}
{isLocal && isRunning && (
<button
type="button"
className="sp-btn sp-btn--danger btn-stop"
onClick={() => void handleStop()}
disabled={busyAll || isStopping}
>
{isStopping ? "Stopping…" : "Stop runtime"}
</button>
)}
</div>
</div>
</div>
</div>
);
}
+3
View File
@@ -54,3 +54,6 @@ html, body, #root {
/* ── Highlight.js theme override ── */ /* ── Highlight.js theme override ── */
.hljs { background: var(--code-bg) !important; } .hljs { background: var(--code-bg) !important; }
/* ── Tailwind utilities (preflight disabled — existing styles preserved) ── */
@tailwind utilities;
+26
View File
@@ -0,0 +1,26 @@
/** @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)",
},
},
},
};