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
+6864 -822

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",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -202,6 +211,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -296,6 +314,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -305,6 +332,64 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -457,6 +542,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -758,7 +853,7 @@ dependencies = [
"llama-sys",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
@@ -774,13 +869,15 @@ dependencies = [
"async-stream",
"axum",
"bytes",
"dirs",
"futures",
"hyper",
"inference-core",
"metrics",
"runtime-deps",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tower",
@@ -840,6 +937,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libredox"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"libc",
]
[[package]]
name = "litemap"
version = "0.8.1"
@@ -971,6 +1077,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1064,6 +1176,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -1095,6 +1218,19 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "runtime-deps"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs",
"serde",
"serde_json",
"sha2",
"ureq",
"zip",
]
[[package]]
name = "rustls"
version = "0.23.37"
@@ -1220,6 +1356,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -1319,13 +1466,33 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1535,6 +1702,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1612,6 +1785,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
@@ -1801,13 +1980,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -1819,34 +2007,67 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -1859,24 +2080,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -2060,8 +2305,37 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+4
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/llama-sys",
"crates/runtime-deps",
"crates/inference-core",
"crates/inference-server",
"crates/api-contracts",
@@ -47,6 +48,9 @@ tokio-stream = "0.1"
bytes = "1"
clap = { version = "4", features = ["derive"] }
ureq = { version = "2", features = ["json"] }
sha2 = "0.10"
zip = { version = "2", default-features = false, features = ["deflate"] }
dirs = "5"
[profile.dev]
opt-level = 0
+14 -9
View File
@@ -11,29 +11,34 @@ pub enum RuntimeProfile {
impl RuntimeProfile {
/// Returns sensible defaults for each profile.
///
/// `n_ctx` sets the KV cache / context window size. Larger values allow longer
/// conversations but consume more VRAM. Override at runtime with `LOCALLLM_CTX`.
/// `n_batch` controls the prefill chunk size — kept independent of `n_ctx` to
/// avoid pre-allocating the full window as a single batch buffer (OOM risk).
pub fn defaults(&self) -> ProfileDefaults {
match self {
Self::Turbo4B => ProfileDefaults {
n_gpu_layers: 999,
n_ctx: 4096,
n_batch: 512,
n_ubatch: 128,
n_ctx: 131_072, // 128 K — covers very long conversations; needs ~4 GB KV VRAM at FP16
n_batch: 2048, // prefill chunk size; independent of n_ctx
n_ubatch: 512,
flash_attn: true,
n_threads: 8,
},
Self::Balanced9B => ProfileDefaults {
n_gpu_layers: 999,
n_ctx: 2048,
n_batch: 256,
n_ubatch: 64,
n_ctx: 65_536, // 64 K
n_batch: 2048,
n_ubatch: 256,
flash_attn: true,
n_threads: 8,
},
Self::CpuFallback => ProfileDefaults {
n_gpu_layers: 0,
n_ctx: 2048,
n_batch: 128,
n_ubatch: 32,
n_ctx: 16_384, // 16 K — CPU KV cache is RAM-backed; keep conservative
n_batch: 512,
n_ubatch: 128,
flash_attn: false,
n_threads: num_cpus(),
},
+2
View File
@@ -12,6 +12,8 @@ path = "src/main.rs"
inference-core = { path = "../inference-core" }
api-contracts = { path = "../api-contracts" }
metrics = { path = "../metrics" }
runtime-deps = { path = "../runtime-deps" }
dirs = { workspace = true }
tokio.workspace = true
axum.workspace = true
hyper.workspace = true
+1 -35
View File
@@ -1,38 +1,4 @@
fn main() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let target_dir = std::path::Path::new(&out_dir)
.ancestors()
.nth(3) // Volta 3 níveis de `target/debug/build/inference-server-XYZ/out` para `target/debug/` ou `target/release/`
.unwrap();
let vendor_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.parent()
.unwrap()
.parent()
.unwrap()
.join("vendor")
.join("llama-bin");
if vendor_dir.exists() {
println!(
"cargo:warning=Copying DLLs from {:?} to {:?}",
vendor_dir, target_dir
);
// Vamos copiar todos os arquivos .dll
if let Ok(entries) = std::fs::read_dir(&vendor_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("dll") {
let file_name = path.file_name().unwrap();
let dest = target_dir.join(file_name);
let _ = std::fs::copy(&path, &dest);
}
}
}
}
// Rebuildar apenas se houver mudanças nessa build file ou nos bins da vendor
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../vendor/llama-bin");
println!("cargo:rerun-if-changed=../../vendor/llama-runtime/manifest.json");
}
+90 -11
View File
@@ -3,8 +3,12 @@ use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use runtime_deps::{
apply_runtime_to_current_process, ensure_llama_runtime, recommended_variant_override_hint,
EnsureRuntimeRequest,
};
use tokio::sync::Mutex;
use tracing_subscriber::{fmt, EnvFilter};
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use inference_core::{EngineConfig, InferenceEngine, RuntimeProfile};
use metrics::counters::PerfCounters;
@@ -17,12 +21,25 @@ use routes::{build_router, AppState};
#[tokio::main]
async fn main() -> Result<()> {
// ── Tracing ──────────────────────────────────────────────────────────
fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(false)
.init();
// Set LOCALLLM_LOG_FORMAT=json for machine-readable output (e.g. log aggregators).
// Set RUST_LOG=debug for verbose output.
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
match std::env::var("LOCALLLM_LOG_FORMAT").as_deref() {
Ok("json") => {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().json().with_current_span(true).with_span_list(true))
.init();
}
_ => {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().with_target(true))
.init();
}
}
// ── Engine config (reads env vars; sane defaults for dev) ────────────
let model_path = std::env::var("LOCALLLM_MODEL")
@@ -62,6 +79,45 @@ async fn main() -> Result<()> {
let server_config = Arc::new(ServerConfig::from_env(model_name.clone()));
// ── Load engine ──────────────────────────────────────────────────────
tracing::info!(
model = %model_path.display(),
profile = ?profile,
n_gpu_layers = config.n_gpu_layers,
n_ctx = config.n_ctx,
n_batch = config.n_batch,
n_ubatch = config.n_ubatch,
n_threads = config.n_threads,
n_threads_batch = config.n_threads_batch,
flash_attn = config.flash_attn,
use_mmap = config.use_mmap,
"engine_config"
);
let binary_path = std::env::current_exe()?;
let runtime_cache_root = dirs::data_local_dir()
.unwrap_or_else(|| {
binary_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf()
})
.join("ForgeLM");
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
cache_root: &runtime_cache_root,
gpu_layers: config.n_gpu_layers,
variant_override: None,
binary_path: Some(&binary_path),
})
.map_err(|error| anyhow::anyhow!(
"failed to prepare llama runtime dependencies: {error}. {}",
recommended_variant_override_hint()
))?;
apply_runtime_to_current_process(&runtime);
tracing::info!(
dll = %runtime.dll_path.display(),
variant = runtime.variant_id,
source = ?runtime.source,
"llama_runtime_ready"
);
let mut engine = InferenceEngine::load(config)?;
engine.warmup()?;
@@ -81,6 +137,14 @@ async fn main() -> Result<()> {
let router = build_router(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
bind_addr = %addr,
model = %model_name,
api_key_set = std::env::var("LOCALLLM_API_KEY").is_ok(),
log_format = std::env::var("LOCALLLM_LOG_FORMAT").as_deref().unwrap_or("pretty"),
"server_ready"
);
// ── Startup banner ───────────────────────────────────────────────────
let display_addr = if addr.ip().is_unspecified() {
format!("http://127.0.0.1:{}", addr.port())
@@ -125,8 +189,20 @@ fn env_u32(key: &str, default: u32) -> u32 {
.unwrap_or(default)
}
fn resolve_batch_size(n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 {
explicit_batch.unwrap_or_else(|| n_ctx.max(default_n_batch).max(1))
/// Choose the prefill batch size.
///
/// The batch size controls how many tokens are processed in a single `llama_decode`
/// call during prefill. It must not be confused with the context window (`n_ctx`):
/// a large `n_ctx` does NOT require an equally large `n_batch` — `decode_prefill`
/// already chunks the prompt into `n_batch`-sized slices.
///
/// Setting `n_batch = n_ctx` for a 128 K context would pre-allocate ~128 K token
/// slots in the batch buffer, consuming gigabytes of VRAM at startup before any
/// request arrives. Instead we cap auto-selection at `MAX_AUTO_BATCH` and let
/// callers pass `LOCALLLM_BATCH` when they need a larger explicit override.
fn resolve_batch_size(_n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 {
const MAX_AUTO_BATCH: u32 = 4096;
explicit_batch.unwrap_or_else(|| default_n_batch.min(MAX_AUTO_BATCH).max(1))
}
#[cfg(test)]
@@ -134,8 +210,11 @@ mod tests {
use super::resolve_batch_size;
#[test]
fn defaults_batch_to_context_size_when_ctx_is_larger() {
assert_eq!(resolve_batch_size(16_384, 512, None), 16_384);
fn caps_auto_batch_at_max_regardless_of_ctx_size() {
// Large n_ctx must NOT bloat n_batch — that would OOM the batch buffer.
assert_eq!(resolve_batch_size(131_072, 512, None), 512);
assert_eq!(resolve_batch_size(131_072, 4096, None), 4096);
assert_eq!(resolve_batch_size(131_072, 8192, None), 4096); // capped at MAX_AUTO_BATCH
}
#[test]
+10 -2
View File
@@ -397,9 +397,17 @@ async fn stream_response(
}
};
Sse::new(stream)
let response = Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
.into_response();
axum::response::Response::builder()
.status(response.status())
.header("content-type", "text/event-stream; charset=utf-8")
.header("cache-control", "no-cache")
.header("connection", "keep-alive")
.body(response.into_body())
.unwrap()
}
async fn blocking_response(
+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"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0",
"highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -25,7 +27,10 @@
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.27",
"jsdom": "^29.0.1",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.5.3",
"vite": "^5.4.8",
"vitest": "^4.1.2"
+590
View File
@@ -45,15 +45,32 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0([email protected])
jsdom:
specifier: ^29.0.1
version: 29.0.1
typescript:
specifier: ^5.5.3
version: 5.9.3
vite:
specifier: ^5.4.8
version: 5.4.21
vitest:
specifier: ^4.1.2
version: 4.1.2([email protected])([email protected])
packages:
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/[email protected]':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -137,6 +154,46 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bramus/[email protected]':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/[email protected]':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/[email protected]':
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/[email protected]':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@esbuild/[email protected]':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -275,6 +332,15 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/[email protected]':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -432,6 +498,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -526,6 +595,12 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/[email protected]':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
@@ -553,11 +628,47 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/[email protected]':
resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
'@vitest/[email protected]':
resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/[email protected]':
resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
'@vitest/[email protected]':
resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
'@vitest/[email protected]':
resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
'@vitest/[email protected]':
resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
'@vitest/[email protected]':
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
[email protected]:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
engines: {node: '>=6.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
[email protected]:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -566,12 +677,24 @@ packages:
[email protected]:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
[email protected]:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
[email protected]:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -581,12 +704,22 @@ packages:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
[email protected]:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
[email protected]:
resolution: {integrity: sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==}
[email protected]:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
[email protected]:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -596,6 +729,22 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
[email protected]:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -609,9 +758,25 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
[email protected]:
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
[email protected]:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -626,14 +791,24 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
engines: {node: '>= 18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -645,13 +820,30 @@ packages:
[email protected]:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
[email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
[email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -665,11 +857,19 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
[email protected]:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -677,15 +877,61 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
[email protected]:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
[email protected]:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
[email protected]:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
[email protected]:
resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
engines: {node: '>=20.18.1'}
[email protected]:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -723,11 +969,92 @@ packages:
terser:
optional: true
[email protected]:
resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.2
'@vitest/browser-preview': 4.1.2
'@vitest/browser-webdriverio': 4.1.2
'@vitest/ui': 4.1.2
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
[email protected]:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
[email protected]:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
snapshots:
'@asamuzakjp/[email protected]':
dependencies:
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-color-parser': 4.0.2(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.2.7
'@asamuzakjp/[email protected]':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
'@asamuzakjp/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -840,6 +1167,34 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bramus/[email protected]':
dependencies:
css-tree: 3.2.1
'@csstools/[email protected]': {}
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
'@csstools/css-tokenizer': 4.0.0
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
'@csstools/css-tokenizer': 4.0.0
'@csstools/[email protected](@csstools/[email protected])':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/[email protected]([email protected])':
optionalDependencies:
css-tree: 3.2.1
'@csstools/[email protected]': {}
'@esbuild/[email protected]':
optional: true
@@ -909,6 +1264,8 @@ snapshots:
'@esbuild/[email protected]':
optional: true
'@exodus/[email protected]': {}
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1005,6 +1362,8 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@standard-schema/[email protected]': {}
'@tauri-apps/[email protected]': {}
'@tauri-apps/[email protected]':
@@ -1079,6 +1438,13 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/[email protected]':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
dompurify: 3.3.3
@@ -1111,8 +1477,55 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/[email protected]':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.2
'@vitest/utils': 4.1.2
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/[email protected]([email protected])':
dependencies:
'@vitest/spy': 4.1.2
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21
'@vitest/[email protected]':
dependencies:
tinyrainbow: 3.1.0
'@vitest/[email protected]':
dependencies:
'@vitest/utils': 4.1.2
pathe: 2.0.3
'@vitest/[email protected]':
dependencies:
'@vitest/pretty-format': 4.1.2
'@vitest/utils': 4.1.2
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/[email protected]': {}
'@vitest/[email protected]':
dependencies:
'@vitest/pretty-format': 4.1.2
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
require-from-string: 2.0.2
[email protected]:
dependencies:
baseline-browser-mapping: 2.10.11
@@ -1123,20 +1536,40 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
[email protected]:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]:
optionalDependencies:
'@types/trusted-types': 2.0.7
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -1165,6 +1598,16 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.8
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.4
[email protected]:
optional: true
@@ -1172,8 +1615,42 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@exodus/bytes': 1.15.0
transitivePeerDependencies:
- '@noble/hashes'
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@asamuzakjp/css-color': 5.1.1
'@asamuzakjp/dom-selector': 7.0.4
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.2([email protected])
'@exodus/bytes': 1.15.0
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.24.6
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
[email protected]: {}
[email protected]: {}
@@ -1182,26 +1659,46 @@ snapshots:
dependencies:
js-tokens: 4.0.0
[email protected]: {}
[email protected]:
dependencies:
yallist: 3.1.1
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
entities: 6.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]([email protected]):
dependencies:
loose-envify: 1.4.0
@@ -1214,6 +1711,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.8
@@ -1245,16 +1744,55 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.0
fsevents: 2.3.3
[email protected]:
dependencies:
xmlchars: 2.2.0
[email protected]:
dependencies:
loose-envify: 1.4.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
picomatch: 4.0.4
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
tldts-core: 7.0.27
[email protected]:
dependencies:
tldts: 7.0.27
[email protected]:
dependencies:
punycode: 2.3.1
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
browserslist: 4.28.1
@@ -1269,4 +1807,56 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
[email protected]([email protected])([email protected]):
dependencies:
'@vitest/expect': 4.1.2
'@vitest/mocker': 4.1.2([email protected])
'@vitest/pretty-format': 4.1.2
'@vitest/runner': 4.1.2
'@vitest/snapshot': 4.1.2
'@vitest/spy': 4.1.2
'@vitest/utils': 4.1.2
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 5.4.21
why-is-node-running: 2.3.0
optionalDependencies:
jsdom: 29.0.1
transitivePeerDependencies:
- msw
[email protected]:
dependencies:
xml-name-validator: 5.0.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@exodus/bytes': 1.15.0
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
[email protected]:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
+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"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -521,6 +530,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -565,13 +585,34 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -582,7 +623,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -830,6 +871,7 @@ name = "forgelm-chat"
version = "0.1.0"
dependencies = [
"reqwest 0.12.28",
"runtime-deps",
"serde",
"serde_json",
"tauri",
@@ -1380,7 +1422,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.6",
]
[[package]]
@@ -2787,6 +2829,17 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -2882,7 +2935,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.6",
]
[[package]]
@@ -2957,6 +3010,19 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "runtime-deps"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
"serde",
"serde_json",
"sha2",
"ureq",
"zip",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -2978,6 +3044,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3655,7 +3722,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3705,7 +3772,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4268,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2",
@@ -4366,6 +4433,24 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4678,6 +4763,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@@ -4917,6 +5011,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -4968,6 +5071,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5025,6 +5143,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5043,6 +5167,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5061,6 +5191,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5091,6 +5227,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5109,6 +5251,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5127,6 +5275,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5145,6 +5299,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5295,7 +5455,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dom_query",
"dpi",
"dunce",
@@ -5453,8 +5613,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+1
View File
@@ -25,6 +25,7 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime-deps = { path = "../../crates/runtime-deps" }
[profile.release]
panic = "abort"
+1 -1
View File
@@ -118,7 +118,7 @@ fn start_runtime_inner(
inner.config.clone()
};
let child = match spawn_localllm(&config) {
let child = match spawn_localllm(app, &config) {
Ok(child) => child,
Err(message) => {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
+36 -3
View File
@@ -1,7 +1,13 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use tauri::{AppHandle, Manager};
use super::types::RuntimeConfig;
use runtime_deps::{
apply_runtime_to_command, ensure_llama_runtime, recommended_variant_override_hint,
EnsureRuntimeRequest,
};
pub fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") {
@@ -52,7 +58,17 @@ pub fn find_localllm_binary() -> Option<PathBuf> {
None
}
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
fn runtime_cache_root(app: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| format!("Cannot resolve app data dir: {error}"))?;
std::fs::create_dir_all(&app_data_dir)
.map_err(|error| format!("Cannot create app data dir: {error}"))?;
Ok(app_data_dir.join("runtime-cache"))
}
pub fn spawn_localllm(app: &AppHandle, config: &RuntimeConfig) -> Result<Child, String> {
let model_path = config
.model_path
.as_deref()
@@ -65,11 +81,28 @@ pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
.to_string()
})?;
Command::new(&binary_path)
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
cache_root: &runtime_cache_root(app)?,
gpu_layers: config.gpu_layers,
variant_override: None,
binary_path: Some(&binary_path),
})
.map_err(|error| {
format!(
"Failed to prepare llama runtime dependencies: {error}\n{}",
recommended_variant_override_hint()
)
})?;
let mut command = Command::new(&binary_path);
command
.env("LOCALLLM_MODEL", model_path)
.env("LOCALLLM_ADDR", &config.addr)
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string());
apply_runtime_to_command(&mut command, &runtime);
command
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
}
+11 -2
View File
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use crate::model_library::types::RemotePreset;
pub const DEFAULT_CTX_SIZE: u32 = 4096;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionMode {
@@ -102,7 +104,7 @@ impl Default for RuntimeConfig {
models_dir: None,
addr: Self::default_addr(),
gpu_layers: 0,
ctx_size: 4096,
ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: Vec::new(),
selected_model_id: None,
remote_presets: Vec::new(),
@@ -113,7 +115,14 @@ impl Default for RuntimeConfig {
#[cfg(test)]
mod tests {
use super::{ConnectionMode, RuntimeConfig};
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE};
#[test]
fn runtime_config_defaults_to_safe_context_window() {
let config = RuntimeConfig::default();
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
}
#[test]
fn runtime_config_deserializes_old_shape_with_new_defaults() {
+34 -26
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import Sidebar from "./components/Sidebar";
import ChatView from "./components/ChatView";
import SettingsPage from "./features/settings/SettingsPage";
import { useDB } from "./hooks/useDB";
import { useModels } from "./hooks/useModels";
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
@@ -13,6 +14,7 @@ export default function App() {
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [view, setView] = useState<"chat" | "settings">("chat");
const runtime = useRuntimeManager();
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
@@ -65,22 +67,24 @@ export default function App() {
}
return (
<div className={`app-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<Sidebar
chats={chats}
currentChatId={currentChatId}
onSelectChat={handleSelectChat}
onNewChat={newChat}
onDeleteChat={deleteChat}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
runtime={runtime}
onCloseMobile={() => setSidebarOpen(false)}
/>
{sidebarOpen && (
<div className={`app-layout${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
{view !== "settings" && (
<Sidebar
chats={chats}
currentChatId={currentChatId}
onSelectChat={handleSelectChat}
onNewChat={newChat}
onDeleteChat={deleteChat}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
onOpenSettings={() => setView("settings")}
onCloseMobile={() => setSidebarOpen(false)}
/>
)}
{view !== "settings" && sidebarOpen && (
<button
type="button"
className="app-sidebar-backdrop"
@@ -89,16 +93,20 @@ export default function App() {
/>
)}
<div className="app-main">
<ChatView
db={db!}
chatId={currentChatId}
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
/>
{view === "settings" ? (
<SettingsPage runtime={runtime} onClose={() => setView("chat")} />
) : (
<ChatView
db={db!}
chatId={currentChatId}
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
/>
)}
</div>
</div>
);
File diff suppressed because it is too large. Load diff
+395 -194
View File
@@ -5,6 +5,7 @@ import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
import MessageBubble from "./MessageBubble";
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react";
import "./ChatView.css";
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 [useStream, setUseStream] = 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 textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const activeChatIdRef = useRef<number | null>(chatId);
const pendingRequestChatIdRef = useRef<number | null>(null);
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 starterPrompts = getSuggestedPrompts();
const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0;
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => {
totals.prompt_tokens += message.prompt_tokens ?? 0;
totals.completion_tokens += message.completion_tokens ?? 0;
totals.total_tokens += message.total_tokens ?? 0;
return totals;
const chatTotals = messages.reduce<ChatTokenTotals>((acc, msg) => {
acc.prompt_tokens += msg.prompt_tokens ?? 0;
acc.completion_tokens += msg.completion_tokens ?? 0;
acc.total_tokens += msg.total_tokens ?? 0;
return acc;
}, { 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(); }, []);
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
console.log(`[ChatView] loadMessages: chatId=${targetChatId}`);
if (targetChatId == null) { console.trace("[ChatView] loadMessages: no chatId — clearing messages, called from"); setMessages([]); return; }
if (targetChatId == null) { setMessages([]); return; }
const rows = db.query<Message>(
"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);
}, [db, chatId]);
// Keep ref always pointing to latest loadMessages so async closures don't go stale
loadMessagesRef.current = loadMessages;
useEffect(() => { loadMessages(); }, [loadMessages]);
@@ -69,18 +88,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
useEffect(() => {
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 = null;
messageQueueRef.current = [];
setMessageQueue([]);
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
setStreamTokenCount(0);
pendingRequestChatIdRef.current = null;
return;
}
@@ -92,6 +113,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
setStreamTokenCount(0);
}
}, [chatId]);
@@ -109,7 +131,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const getOrCreateChat = useCallback((firstMessage?: string): number => {
if (chatId != null) return chatId;
const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
console.log(`[ChatView] getOrCreateChat: created chat id=${newId}`);
onChatCreated(newId);
return newId;
}, [chatId, db, onChatCreated]);
@@ -127,30 +148,27 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const limit = useMemory ? MAX_MEMORY : 1;
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 ?",
[cid, limit]
[cid, limit],
).reverse();
}, [db, useMemory]);
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) { console.warn("[ChatView] send: blocked — empty text or already generating"); return; }
if (getComposerNotice(model, server)) { console.warn("[ChatView] send: blocked — composerNotice active"); return; }
if (!text.trim() || generating) return;
if (getComposerNotice(model, server)) return;
setGenerating(true);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setStreamTokenCount(0);
const cid = fromChatId ?? getOrCreateChat(text);
console.log(`[ChatView] send: using chatId=${cid}`);
activeChatIdRef.current = cid;
pendingRequestChatIdRef.current = cid;
// Save user message
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]);
console.log(`[ChatView] send: inserted user message, chat now has ${count?.c} messages`);
if (count?.c === 1) ensureChatTitle(cid, text);
loadMessagesRef.current(cid);
@@ -162,7 +180,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
abortRef.current = new AbortController();
console.log(`[ChatView] send: fetching ${server}/v1/chat/completions — stream=${useStream}, messages=${apiMessages.length}`);
try {
const res = await fetch(`${server}/v1/chat/completions`, {
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) {
const err = await res.json().catch(() => ({}));
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;
if (useStream) {
console.log("[ChatView] send: starting stream read");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
const decoder = new TextDecoder('utf-8');
let buf = "";
while (true) {
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 });
const lines = buf.split("\n");
buf = lines.pop()!;
@@ -203,12 +218,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") { console.log("[ChatView] send: received [DONE]"); continue; }
if (data === "[DONE]") continue;
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
tokenCount++;
setStreamTokenCount(tokenCount);
fullContent += delta;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
@@ -216,17 +232,14 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
if (chunk.usage) { usage = chunk.usage; console.log("[ChatView] send: received usage", usage); }
} catch (parseErr) { console.warn("[ChatView] send: failed to parse chunk", parseErr, data); }
if (chunk.usage) usage = chunk.usage;
} catch { /* ignore malformed chunks */ }
}
}
console.log(`[ChatView] send: stream complete — fullContent.length=${fullContent.length}`);
} else {
console.log("[ChatView] send: reading blocking response");
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage;
console.log(`[ChatView] send: blocking response — fullContent.length=${fullContent.length}, usage=`, usage);
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
@@ -234,13 +247,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
if (parsed.reasoning) setShowLiveReasoning(true);
}
// Save assistant message — only if there is real content to persist
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
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);
if (finalContent || parsed.reasoning) {
db.run(
`INSERT INTO messages (
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]);
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
onChatUpdated();
console.log(`[ChatView] send: assistant message saved to DB for chat ${cid}`);
} catch (err: unknown) {
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', ?)", [
cid,
`⚠️ Error: ${err.message}`,
]);
onChatUpdated();
} else {
console.log("[ChatView] send: aborted");
}
} 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);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setStreamTokenCount(0);
if (activeChatIdRef.current === cid) {
console.log(`[ChatView] send: reloading messages for chat ${cid}`);
loadMessagesRef.current(cid);
} else {
console.warn(`[ChatView] send: skipping reload — active chat changed to ${activeChatIdRef.current}`);
}
pendingRequestChatIdRef.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]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
const handleSend = () => {
const text = input.trim();
if (!text || composerNotice) return;
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);
};
const handleStop = () => { abortRef.current?.abort(); };
const handleStop = () => {
abortRef.current?.abort();
setQueue([]);
};
const handleDeleteMessage = (id: number) => {
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("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
loadMessages();
send(newContent, msg.chat_id);
if (generating) {
setQueue([...messageQueueRef.current, newContent]);
} else {
send(newContent, msg.chat_id);
}
};
const handleEditAssistant = (id: number, newContent: string) => {
@@ -332,68 +351,250 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
loadMessages();
};
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model;
return (
<div className="chat-view">
{/* Top bar */}
{/* ── Top bar ── */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{onOpenSidebar && (
<button
type="button"
className="chat-menu-btn"
onClick={onOpenSidebar}
aria-label="Open navigation"
>
<button type="button" className="chat-menu-btn" onClick={onOpenSidebar} aria-label="Open navigation">
☰
</button>
)}
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span>Sent {chatTotals.prompt_tokens}</span>
<span>Generated {chatTotals.completion_tokens}</span>
<span>Total {chatTotals.total_tokens}</span>
{model && (
<div className="topbar-model-badge">
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
{tps !== null && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
</div>
<div className="chat-topbar-center">
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
<span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
</div>
)}
</div>
<div className="chat-topbar-right">
<label className="toggle-label">
<input type="checkbox" checked={useStream} onChange={(e) => setUseStream(e.target.checked)} />
Streaming
</label>
<label className="toggle-label">
<input type="checkbox" checked={useMemory} onChange={(e) => setUseMemory(e.target.checked)} />
Memory
</label>
{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>
)}
<button
type="button"
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
onClick={() => setShowMemoryModal(!showMemoryModal)}
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
aria-label="View memory context"
>
<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
</button>
</div>
</div>
</div>
{/* Messages */}
<div className="messages-area">
{chatId === null && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first coding workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
className="starter-chip"
onClick={() => handleStarterPrompt(prompt)}
>
{prompt}
</button>
))}
{/* ── Memory Modal ── */}
{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 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first AI workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="starter-chip" onClick={() => handleStarterPrompt(prompt)}>
{prompt}
</button>
))}
</div>
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
renderMarkdown={renderMarkdown}
/>
))}
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
<div className="msg-bubble">
{(streamReasoning || streamThinkingOpen) && (
<details
className="msg-thinking"
open={showLiveReasoning}
onToggle={(event) => setShowLiveReasoning(event.currentTarget.open)}
>
<summary className="thinking-summary" aria-label="Toggle live thinking details">
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
<span className="reasoning-live-dot" aria-hidden="true" />
Thinking{streamThinkingOpen ? "..." : ""}
</summary>
{streamReasoning && (
<div
className="thinking-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
)}
</details>
)}
{streamContent && (
<div
className="msg-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
</div>
</div>
)}
<div ref={messagesEndRef} />
<div className="scroll-spacer" />
</div>
</div>
{/* ── Message Queue indicator ── */}
{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 && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
@@ -407,107 +608,107 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)}
</div>
)}
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
renderMarkdown={renderMarkdown}
/>
))}
{/* 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>
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
{(streamReasoning || streamThinkingOpen) && (
<div className="reasoning-panel live-reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowLiveReasoning((value) => !value)}
type="button"
>
<span>{showLiveReasoning ? "▼" : "▶"}</span>
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span>
</button>
{showLiveReasoning && streamReasoning && (
<div
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
{/* 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
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Message..."
}
rows={1}
disabled={Boolean(composerNotice)}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<ArrowUp size={18} weight="duotone" />
</button>
)}
</div>
)}
{streamContent && (
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><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 ref={messagesEndRef} />
</div>
{/* Input */}
<div className="input-area">
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
<div className="input-row">
<textarea
ref={textareaRef}
className="input-textarea"
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={composerNotice ? `${composerNotice.title}. ${composerNotice.description}` : "Message… (Enter to send, Shift+Enter for new line)"}
rows={1}
disabled={generating}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop}>■ Stop</button>
) : (
<button className="send-btn" onClick={handleSend} disabled={!input.trim() || Boolean(composerNotice)}>
Send ↑
</button>
)}
</div>
<div className="input-meta">
<span className="char-count">{input.length} chars</span>
</div>
</div>
</div>
+248 -63
View File
@@ -1,7 +1,7 @@
/* Edit mode */
/* ── Edit mode ── */
.msg-edit-container {
width: 100%;
max-width: 780px;
max-width: 800px;
}
.msg-edit-textarea {
@@ -12,17 +12,19 @@
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
padding: 10px 14px;
padding: 11px 14px;
resize: none;
min-height: 80px;
outline: none;
line-height: 1.6;
box-shadow: 0 0 0 3px rgba(72,166,255,0.1);
}
.msg-edit-actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.btn-primary {
@@ -31,13 +33,18 @@
border: none;
border-radius: var(--radius-sm);
padding: 6px 14px;
min-height: 44px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
}
.btn-primary:hover { background: var(--accent2); }
.btn-primary:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.22);
}
.btn-secondary {
background: var(--bg4);
@@ -45,108 +52,286 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 14px;
min-height: 44px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
}
.btn-secondary:hover { background: var(--bg5); color: var(--text); }
.btn-secondary:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
}
.btn-ghost {
background: none;
color: var(--text3);
border: none;
background: transparent;
color: var(--text2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 6px 10px;
min-height: 44px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: color 0.15s;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.btn-ghost:hover { color: var(--text2); }
/* Message actions */
.reasoning-panel {
width: 100%;
margin-bottom: 8px;
.btn-ghost:hover { color: var(--text); background: rgba(237, 245, 255, 0.05); border-color: rgba(93, 114, 136, 0.28); }
.btn-ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
}
.reasoning-toggle {
display: inline-flex;
/* ── Thinking inside bubble ── */
.msg-thinking {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: transparent;
}
.msg-thinking > summary {
list-style: none;
}
.msg-thinking > summary::-webkit-details-marker {
display: none;
}
.thinking-summary {
display: flex;
align-items: center;
gap: 8px;
background: rgba(124, 158, 248, 0.08);
color: var(--text2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.75rem;
padding: 10px 16px;
cursor: pointer;
font-family: inherit;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
user-select: none;
transition: color 0.2s ease-out;
}
.reasoning-toggle:hover {
background: rgba(124, 158, 248, 0.12);
color: var(--text);
.thinking-summary:hover {
color: rgba(255, 255, 255, 0.8);
}
.reasoning-content {
margin-top: 8px;
padding: 12px 14px;
background: rgba(124, 158, 248, 0.06);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text2);
font-size: 0.84rem;
.thinking-summary:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
}
.thinking-chevron {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 1.8;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.2s ease-out;
flex-shrink: 0;
}
.msg-thinking[open] .thinking-chevron {
transform: rotate(90deg);
}
.thinking-content {
padding: 4px 16px 16px 16px;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
font-style: normal;
}
.thinking-content > :first-child {
border-left: 2px solid rgba(255, 255, 255, 0.1);
margin-left: 16px;
padding-left: 12px;
}
/* ── Message bubble layout ── */
.msg-bubble {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.msg-content {
padding: 13px 16px;
}
/* ── Message metrics ── */
.message-metrics {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
color: var(--text3);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
padding-left: 12px;
font-size: 0.75rem;
opacity: 0.7;
font-family: monospace;
color: #a8b8cc;
}
.message-metrics span {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
/* ── Message footer ── */
.msg-footer {
display: flex;
justify-content: space-between;
align-items: stretch;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.12);
min-height: 32px;
}
/* ── Compact actions for user bubble (floating bar) ── */
.msg-wrapper.user {
position: relative;
margin-bottom: 24px;
}
.compact-actions {
position: absolute;
bottom: -12px;
right: 0;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 8px;
background: var(--bg2);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
transition: all 0.2s ease-out;
z-index: 10;
}
.msg-wrapper.user:hover .compact-actions,
.msg-wrapper.user:focus-within .compact-actions {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.compact-actions .action-btn {
min-height: 28px;
min-width: 28px;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: #c8d7ea;
}
.compact-actions .action-btn svg {
width: 14px;
height: 14px;
}
.compact-actions .action-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.compact-actions .action-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(72, 166, 255, 0.4);
opacity: 1;
}
.compact-actions .action-btn.copied {
color: var(--success);
background: rgba(48, 209, 88, 0.12);
}
.compact-actions .action-btn.danger {
color: #f4c2c2;
}
.compact-actions .action-btn.danger:hover {
background: rgba(240, 64, 64, 0.12);
color: #ffd7d7;
}
/* ── Message actions (assistant) ── */
.msg-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.15s;
transition: opacity 0.2s ease-out;
}
.msg-bubble:hover .msg-actions,
.msg-bubble:focus-within .msg-actions {
opacity: 1;
}
.msg-wrapper:hover .msg-actions { opacity: 1; }
.msg-wrapper:focus-within .msg-actions { opacity: 1; }
.action-btn {
background: none;
border: 1px solid var(--border);
color: var(--text2);
border-radius: var(--radius-sm);
padding: 3px 9px;
font-size: 0.72rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
min-width: 36px;
background: transparent;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.08);
color: #c8d7ea;
border-radius: 0;
padding: 0 12px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.2s ease-out, color 0.2s ease-out;
font-family: inherit;
white-space: nowrap;
}
.action-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--border2); }
.action-btn.copied { color: var(--success); border-color: var(--success); }
.action-btn.danger:hover { background: rgba(240,64,64,0.1); color: var(--danger); border-color: var(--danger); }
.action-btn:first-child {
border-left: none;
}
.action-btn svg {
width: 16px;
height: 16px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
.action-btn:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
opacity: 1;
}
.action-btn.copied {
color: var(--success);
background: rgba(48, 209, 88, 0.08);
}
.action-btn.danger {
color: #f4c2c2;
}
.action-btn.danger:hover {
background: rgba(240, 64, 64, 0.1);
color: #ffd7d7;
}
@media (hover: none), (max-width: 960px) {
.msg-actions { opacity: 1; }
}
@media (max-width: 640px) {
.msg-actions {
opacity: 1;
width: 100%;
}
.action-btn {
flex: 1 1 132px;
}
}
@media (prefers-reduced-motion: reduce) {
.action-btn,
.btn-primary,
.btn-secondary,
.btn-ghost {
animation: none;
transition: none;
}
}
@@ -0,0 +1,72 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MessageBubble from "./MessageBubble";
import type { Message } from "../lib/types";
describe("MessageBubble", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(() => Promise.resolve()),
},
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("renders accessible thinking and action controls for assistant messages", async () => {
const message: Message = {
id: 12,
chat_id: 4,
role: "assistant",
content: "Final answer",
reasoning_content: "Reasoning details",
prompt_tokens: 18,
completion_tokens: 24,
total_tokens: 42,
tokens_per_second: 6.4,
created_at: Date.now(),
};
await act(async () => {
root.render(
<MessageBubble
message={message}
onDelete={vi.fn()}
onEditAssistant={vi.fn()}
renderMarkdown={(text) => `<p>${text}</p>`}
/>,
);
});
const summary = container.querySelector("summary.reasoning-toggle");
expect(summary?.getAttribute("aria-label")).toBe("Toggle thinking details");
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.some((node) => node.getAttribute("aria-label") === "Copy message")).toBe(true);
expect(buttons.some((node) => node.getAttribute("aria-label") === "Edit message")).toBe(true);
expect(buttons.some((node) => node.getAttribute("aria-label") === "Delete message")).toBe(true);
expect(container.textContent).toContain("Sent 18");
expect(container.textContent).toContain("Generated 24");
expect(container.textContent).toContain("Total 42");
expect(container.textContent).toContain("6.4 tok/s");
});
});
+93 -47
View File
@@ -14,10 +14,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const reasoningRef = useRef<HTMLDivElement>(null);
const thinkingRef = useRef<HTMLDivElement>(null);
// Wire up copy-code buttons inside rendered markdown
useEffect(() => {
@@ -36,8 +35,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
});
useEffect(() => {
if (!reasoningRef.current) return;
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
if (!thinkingRef.current) return;
const buttons = thinkingRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute("data-code") || "";
@@ -90,6 +89,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
(message.total_tokens ?? 0) > 0
);
const showFooter = message.role === "assistant";
return (
<div className={`msg-wrapper ${message.role}`}>
{editing ? (
@@ -118,56 +119,101 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
) : (
<>
{hasReasoning && (
<div className="reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowReasoning((value) => !value)}
type="button"
>
<span>{showReasoning ? "▼" : "▶"}</span>
<span>Thinking</span>
</button>
{showReasoning && (
<div
ref={reasoningRef}
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
/>
)}
</div>
)}
{message.role === "assistant" ? (
<div
ref={bubbleRef}
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
) : (
<div className="msg-bubble">{message.content}</div>
)}
>
{hasReasoning && (
<details className="msg-thinking">
<summary className="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>
)}
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
<div
className="msg-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
{showFooter && (
<div className="msg-footer">
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</div>
)}
{!hasUsage && <div />}
<div className="msg-actions" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
<button
type="button"
className="action-btn"
onClick={startEdit}
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
</svg>
</button>
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
</svg>
</button>
</div>
</div>
)}
</div>
) : (
<>
<div className="msg-bubble user-bubble">
<div className="msg-content">{message.content}</div>
</div>
<div className="msg-actions compact-actions" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
<button
type="button"
className="action-btn"
onClick={startEdit}
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
</svg>
</button>
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
</svg>
</button>
</div>
</>
)}
<div className="msg-actions">
<button className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage}>
{copied ? "✓ Copied" : "⎘ Copy"}
</button>
<button className="action-btn" onClick={startEdit}>
✎ Edit{onEditAndResend ? " & Resend" : ""}
</button>
<button className="action-btn danger" onClick={() => onDelete(message.id)}>
✕ Delete
</button>
</div>
</>
)}
</div>
+52 -125
View File
@@ -1,9 +1,7 @@
.sidebar {
width: 280px;
min-width: 280px;
background:
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
var(--bg2);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
@@ -13,7 +11,7 @@
.sidebar-header-actions {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
}
.sidebar-close-btn {
@@ -24,7 +22,7 @@
.sidebar-header {
display: flex;
align-items: center;
padding: 14px 14px 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
@@ -33,32 +31,15 @@
.sidebar-brand {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
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 {
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #f5fbff, #89b5d9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.icon-btn {
@@ -66,16 +47,15 @@
border: none;
color: var(--text3);
cursor: pointer;
font-size: 15px;
width: 36px;
height: 36px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
padding: 0;
}
.icon-btn:hover {
@@ -83,61 +63,16 @@
color: var(--text);
}
/* ── Settings panel ── */
.settings-panel {
padding: 10px 12px 12px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
background: var(--bg3);
animation: slide-down 0.15s ease;
flex-shrink: 0;
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.settings-label {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 4px;
}
.settings-input {
background: var(--bg4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.settings-input:focus {
border-color: var(--accent);
background: var(--bg5);
}
/* ── Model section ── */
.model-section {
padding: 8px 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.model-section-label {
font-size: 10px;
font-weight: 700;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -147,14 +82,7 @@
.model-row {
display: flex;
align-items: center;
gap: 7px;
}
.model-help {
margin-top: 8px;
font-size: 11px;
color: var(--text3);
line-height: 1.5;
gap: 8px;
}
.status-dot {
@@ -186,56 +114,67 @@
.model-select {
flex: 1;
background: var(--bg4);
border: 1px solid var(--border);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
padding: 6px 28px 6px 9px;
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s;
transition: border-color 0.15s, background 0.15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.model-select:hover {
background-color: var(--bg3);
border-color: var(--border);
}
.model-select:focus {
border-color: var(--accent);
background-color: var(--bg3);
}
.model-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Sidebar actions ── */
.sidebar-actions {
padding: 10px 12px 6px;
padding: 10px 16px 6px;
flex-shrink: 0;
}
.new-chat-btn {
width: 100%;
background: linear-gradient(135deg, #2b88e6, #49c0ff);
color: #fff;
border: none;
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 9px 12px;
padding: 8px 12px;
font-size: 13px;
font-weight: 700;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
justify-content: flex-start;
gap: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
font-family: inherit;
letter-spacing: 0.01em;
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
height: 36px;
}
.new-chat-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(72,166,255,0.4);
background: var(--bg4);
color: var(--text);
border-color: var(--border);
}
.new-chat-btn:active {
@@ -244,12 +183,12 @@
/* ── Chat list ── */
.chat-list-section-label {
font-size: 10px;
font-weight: 700;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 12px 4px;
padding: 12px 16px 4px;
flex-shrink: 0;
}
@@ -279,7 +218,6 @@
color: var(--text2);
transition: background 0.12s, color 0.12s;
position: relative;
border-left: 2px solid transparent;
}
.chat-item-button {
@@ -287,7 +225,6 @@
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: transparent;
border: 0;
@@ -295,6 +232,7 @@
cursor: pointer;
font: inherit;
text-align: left;
border-radius: var(--radius-sm);
}
.chat-item:hover {
@@ -308,19 +246,8 @@
}
.chat-item.active {
background: rgba(91,106,240,0.1);
background: var(--bg4);
color: var(--text);
border-left-color: var(--accent);
}
.chat-item-icon {
font-size: 13px;
flex-shrink: 0;
opacity: 0.5;
}
.chat-item.active .chat-item-icon {
opacity: 0.8;
}
.chat-item-title {
@@ -329,6 +256,7 @@
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 1.4;
}
.chat-item-del {
@@ -337,7 +265,6 @@
border: none;
color: var(--text3);
cursor: pointer;
font-size: 12px;
width: 28px;
height: 28px;
display: flex;
@@ -346,8 +273,8 @@
border-radius: 4px;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
margin-right: 6px;
padding: 0;
margin-right: 4px;
}
.chat-item:hover .chat-item-del {
@@ -366,7 +293,7 @@
/* ── Footer ── */
.sidebar-footer {
padding: 8px 14px;
padding: 8px 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
+14 -48
View File
@@ -1,7 +1,5 @@
import { useState } from "react";
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import RuntimeWorkspace from "../features/runtime/components/RuntimeWorkspace";
import type { RuntimeConfig, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot } from "../features/runtime/types";
import { Plus, Settings, X, Trash2, RefreshCw } from "lucide-react";
import "./Sidebar.css";
interface Props {
@@ -15,59 +13,33 @@ interface Props {
onModelChange: (v: string) => void;
status: ServerStatus;
onRefreshModels: () => void;
runtime: {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
draftOpen: boolean;
draftFields: RuntimeDraftFieldState;
loading: boolean;
busy: boolean;
activeLifecycleCommand: RuntimeSnapshot["active_command"];
lastLifecycleResult: RuntimeSnapshot["last_command_result"];
lifecycleBusy: boolean;
applyImpact: "immediate" | "restart_required" | "save_before_apply";
openDraft: () => void;
closeDraft: () => void;
resetDraft: () => void;
updateDraft: (patch: Partial<RuntimeConfig>) => void;
updateDraftFieldMeta: (field: keyof RuntimeDraftFieldState & string, patch: Partial<RuntimeDraftFieldMeta>) => void;
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal: () => Promise<unknown>;
stopLocal: () => Promise<unknown>;
};
onOpenSettings: () => void;
onCloseMobile: () => void;
}
export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
models, selectedModel, onModelChange, status, onRefreshModels,
runtime,
onOpenSettings,
onCloseMobile,
}: Props) {
const modelCountLabel = models.length === 1 ? "1 model available" : `${models.length} models available`;
return (
<>
<aside className="sidebar">
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-icon">⚡</div>
<span className="brand-name">ForgeLM</span>
</div>
<div className="sidebar-header-actions">
<button
className="icon-btn"
onClick={() => {
runtime.openDraft();
}}
title="Runtime settings"
aria-label="Open runtime settings"
onClick={onOpenSettings}
title="Settings"
aria-label="Open settings"
type="button"
>
⚙
<Settings size={16} strokeWidth={1.5} />
</button>
<button
className="icon-btn sidebar-close-btn"
@@ -76,17 +48,14 @@ export default function Sidebar({
aria-label="Close navigation"
type="button"
>
✕
<X size={16} strokeWidth={1.5} />
</button>
</div>
</div>
{/* ── Unified Runtime Workspace ── */}
<RuntimeWorkspace runtime={runtime} />
{/* ── Active model selector ── */}
<div className="model-section">
<div className="model-section-label">Active model</div>
<div className="model-section-label">Model</div>
<div className="model-row">
<div className={`status-dot status-${status}`} title={status} aria-hidden="true" />
<select
@@ -94,7 +63,7 @@ export default function Sidebar({
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
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"
>
{models.length === 0 ? (
@@ -112,18 +81,16 @@ export default function Sidebar({
aria-label="Refresh models"
type="button"
>
↻
<RefreshCw size={14} strokeWidth={1.5} />
</button>
</div>
<div className="model-help">
{models.length === 0 ? "Start the runtime to load available models." : modelCountLabel}
</div>
</div>
{/* ── New chat ── */}
<div className="sidebar-actions">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<span>+</span> New Chat
<Plus size={16} strokeWidth={1.5} />
<span>New Chat</span>
</button>
</div>
@@ -148,7 +115,6 @@ export default function Sidebar({
onClick={() => onSelectChat(chat.id)}
aria-current={chat.id === currentChatId ? "page" : undefined}
>
<span className="chat-item-icon">💬</span>
<span className="chat-item-title">{chat.title}</span>
</button>
<button
@@ -158,7 +124,7 @@ export default function Sidebar({
title="Delete conversation"
aria-label={`Delete conversation ${chat.title}`}
>
✕
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
))}
@@ -16,6 +16,7 @@ import {
resolveDraftModelSelection,
toModelFile,
} from "../runtime-settings-form";
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime-defaults";
import {
createRuntimeDraftFieldState,
type ModelLibraryState,
@@ -31,7 +32,6 @@ import {
} from "../types";
import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
const REMOTE_PRESET_TEMPLATES = [
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
@@ -799,10 +799,10 @@ export default function RuntimeSettingsDrawer({
<input
id="runtime-context"
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
min={512}
min={MIN_CTX_SIZE}
step={512}
value={draftConfig.ctx_size}
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
onChange={(event) => updateDraft({ ctx_size: Math.max(MIN_CTX_SIZE, parseInt(event.target.value, 10) || DEFAULT_CTX_SIZE) })}
type="number"
/>
</div>
@@ -28,6 +28,11 @@
gap: 10px;
}
.runtime-card-header-left {
min-width: 0;
flex: 1;
}
.runtime-card-kicker {
font-size: 10px;
font-weight: 700;
@@ -42,6 +47,25 @@
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
display: flex;
align-items: center;
gap: 7px;
}
/* Pulsing green dot when runtime is running */
.runtime-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #30d158;
flex-shrink: 0;
animation: runtime-pulse 2s ease-in-out infinite;
}
@keyframes runtime-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(48, 209, 88, 0.5); }
50% { box-shadow: 0 0 0 5px rgba(48, 209, 88, 0); }
}
.runtime-card-mode {
@@ -92,6 +116,13 @@
padding: 7px 10px;
}
.runtime-card-meta-model {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-card-note {
display: block;
margin-top: 10px;
@@ -177,12 +208,63 @@
border-color: var(--accent);
}
.runtime-card-btn-primary {
/* Start — green */
.runtime-card-btn-start {
color: #fff;
border-color: rgba(48, 209, 88, 0.5);
background: linear-gradient(135deg, #1ea84d, #30d158);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.runtime-card-btn-start:hover:not(:disabled) {
background: linear-gradient(135deg, #25c05a, #3fe068);
}
/* Stop — red */
.runtime-card-btn-stop {
color: #fff;
border-color: rgba(240, 64, 64, 0.5);
background: linear-gradient(135deg, #c0302e, #f04040);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.runtime-card-btn-stop:hover:not(:disabled) {
background: linear-gradient(135deg, #d03838, #f55555);
}
/* Setup — blue (same as primary) */
.runtime-card-btn-setup {
color: #fff;
border-color: rgba(72, 166, 255, 0.6);
background: linear-gradient(135deg, #2b88e6, #46c2ff);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.runtime-card-btn-primary:hover:not(:disabled) {
.runtime-card-btn-setup:hover:not(:disabled) {
background: linear-gradient(135deg, #3193f5, #55cbff);
}
/* Spinner for in-progress lifecycle */
.runtime-btn-spinner {
display: inline-block;
width: 11px;
height: 11px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: runtime-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes runtime-spin {
to { transform: rotate(360deg); }
}
@@ -62,26 +62,35 @@ export default function RuntimeStatusCard({
? "runtime-card-result-warning"
: "runtime-card-result-success";
const isRunning = isLocal && snapshot.process_running;
const isStopping = isLocal && snapshot.status === "stopping";
const primaryVariant = needsSetup ? "setup" : isRunning ? "stop" : "start";
return (
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
<div className="runtime-card-header">
<div>
<div className="runtime-card-header-left">
<div className="runtime-card-kicker">Runtime Manager</div>
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
<div className="runtime-card-title">
{isRunning && !isStopping && <span className="runtime-status-dot" aria-hidden="true" />}
{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}
</div>
</div>
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
</span>
</div>
<p className="runtime-card-copy">
<p className="runtime-card-copy" title={snapshot.config.connection_mode === "local" ? snapshot.resolved_url ?? "" : snapshot.config.server_url ?? ""}>
{snapshot.config.connection_mode === "local"
? `Managed endpoint ${snapshot.resolved_url}`
: snapshot.config.server_url || "Configure a remote API endpoint"}
</p>
<div className="runtime-card-meta" aria-label="Runtime details">
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
<span className="runtime-card-meta-model" title={snapshot.config.model_path ?? ""}>
{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}
</span>
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
</div>
@@ -104,7 +113,13 @@ export default function RuntimeStatusCard({
Manage
</button>
{snapshot.config.connection_mode === "local" && (
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
<button
className={`runtime-card-btn runtime-card-btn-${primaryVariant}`}
onClick={primaryAction}
type="button"
disabled={primaryDisabled}
>
{lifecycleBusy ? <span className="runtime-btn-spinner" aria-hidden="true" /> : null}
{primaryLabel}
</button>
)}
@@ -0,0 +1,3 @@
export const MIN_CTX_SIZE = 512;
export const DEFAULT_CTX_SIZE = 4096;
export const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
@@ -9,6 +9,7 @@ import {
toModelFile,
type RuntimeSettingsFormState,
} from "./runtime-settings-form";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import {
createRuntimeDraftState,
@@ -185,6 +186,17 @@ describe("runtime settings form helpers", () => {
});
});
it("falls back to the shared desktop default context size when the draft value is empty", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
snapshot.config,
createFormState({ ctxSize: 0 }),
32,
);
expect(patch.ctx_size).toBe(DEFAULT_CTX_SIZE);
});
it("keeps saved local values untouched when editing remote mode", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
@@ -4,6 +4,7 @@ import type {
RuntimeModelFile,
StartPolicy,
} from "./types";
import { DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "./runtime-defaults";
function normalizeUrl(value: string): string {
const trimmed = value.trim();
@@ -126,7 +127,7 @@ export function createRuntimeConfigPatch(
form.mode === "local"
? Math.max(0, Math.min(form.gpuLayers, maxLayers))
: snapshotConfig.gpu_layers,
ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size,
ctx_size: form.mode === "local" ? Math.max(MIN_CTX_SIZE, form.ctxSize || DEFAULT_CTX_SIZE) : snapshotConfig.ctx_size,
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
extra_model_paths:
form.mode === "local"
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
import {
createDefaultRuntimeSnapshot,
createRuntimeLifecycleResult,
getRuntimeConnection,
getRuntimeStartGuard,
getRuntimeStateLabel,
getRuntimeStatusTone,
} from "./runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
@@ -42,6 +50,10 @@ function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
}
describe("getRuntimeConnection", () => {
it("creates local snapshots with the shared desktop default context window", () => {
expect(createDefaultRuntimeSnapshot().config.ctx_size).toBe(DEFAULT_CTX_SIZE);
});
it("prefers the resolved local runtime url in local mode", () => {
const connection = getRuntimeConnection(createSnapshot({
resolved_url: "http://127.0.0.1:9000",
@@ -90,6 +102,10 @@ describe("getRuntimeStateLabel", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
});
it("reports local processes that lost readiness as not ready", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("Not ready");
});
it("reports local background start progress", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
@@ -113,6 +129,12 @@ describe("getRuntimeStateLabel", () => {
});
});
describe("getRuntimeStatusTone", () => {
it("marks local not-ready snapshots as danger", () => {
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
});
});
describe("getRuntimeStartGuard", () => {
it("requires a saved model before starting the local runtime", () => {
const guard = getRuntimeStartGuard(createSnapshot({
@@ -13,6 +13,7 @@ import type {
RuntimeWorkspaceSummary,
} from "./types";
import { scanRuntimeModels } from "./api";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
@@ -179,6 +180,10 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
return "Remote API";
}
if (snapshot.status === "ready" && !snapshot.ready) {
return snapshot.process_running ? "Not ready" : "Local API offline";
}
switch (snapshot.status) {
case "ready":
return "Ready";
@@ -200,6 +205,10 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
return "neutral";
}
if (snapshot.status === "ready" && !snapshot.ready) {
return snapshot.process_running ? "danger" : "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
@@ -299,7 +308,7 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
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 ── */
.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)",
},
},
},
};