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:
1 parent
9da25c3688
commit
01b629cb4a
41 files changed
+6864
-822
No files matched your search
@@ -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>
|
||||
@@ -0,0 +1,58 @@
|
||||
# context-mode — MANDATORY routing rules
|
||||
|
||||
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
|
||||
|
||||
## BLOCKED commands — do NOT attempt these
|
||||
|
||||
### curl / wget — BLOCKED
|
||||
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
|
||||
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
|
||||
|
||||
### Inline HTTP — BLOCKED
|
||||
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
|
||||
Instead use:
|
||||
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
|
||||
|
||||
### Direct web fetching — BLOCKED
|
||||
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
|
||||
|
||||
## REDIRECTED tools — use sandbox equivalents
|
||||
|
||||
### Shell (>20 lines output)
|
||||
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
|
||||
For everything else, use:
|
||||
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
|
||||
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
|
||||
|
||||
### File reading (for analysis)
|
||||
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
|
||||
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
|
||||
|
||||
### grep / search (large results)
|
||||
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
|
||||
|
||||
## Tool selection hierarchy
|
||||
|
||||
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
|
||||
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
|
||||
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
|
||||
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
|
||||
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
|
||||
|
||||
## Output constraints
|
||||
|
||||
- Keep responses under 500 words.
|
||||
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
|
||||
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
|
||||
|
||||
## ctx commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
|
||||
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
|
||||
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||
Generated
+285
-11
@@ -92,6 +92,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
@@ -202,6 +211,15 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -296,6 +314,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -305,6 +332,64 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -457,6 +542,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -758,7 +853,7 @@ dependencies = [
|
||||
"llama-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -774,13 +869,15 @@ dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
"bytes",
|
||||
"dirs",
|
||||
"futures",
|
||||
"hyper",
|
||||
"inference-core",
|
||||
"metrics",
|
||||
"runtime-deps",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
@@ -840,6 +937,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -971,6 +1077,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1064,6 +1176,17 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -1095,6 +1218,19 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runtime-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -1220,6 +1356,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1319,13 +1466,33 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1535,6 +1702,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1612,6 +1785,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@@ -1801,13 +1980,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1819,34 +2007,67 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -1859,24 +2080,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2060,8 +2305,37 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/llama-sys",
|
||||
"crates/runtime-deps",
|
||||
"crates/inference-core",
|
||||
"crates/inference-server",
|
||||
"crates/api-contracts",
|
||||
@@ -47,6 +48,9 @@ tokio-stream = "0.1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
sha2 = "0.10"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
dirs = "5"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
@@ -11,29 +11,34 @@ pub enum RuntimeProfile {
|
||||
|
||||
impl RuntimeProfile {
|
||||
/// Returns sensible defaults for each profile.
|
||||
///
|
||||
/// `n_ctx` sets the KV cache / context window size. Larger values allow longer
|
||||
/// conversations but consume more VRAM. Override at runtime with `LOCALLLM_CTX`.
|
||||
/// `n_batch` controls the prefill chunk size — kept independent of `n_ctx` to
|
||||
/// avoid pre-allocating the full window as a single batch buffer (OOM risk).
|
||||
pub fn defaults(&self) -> ProfileDefaults {
|
||||
match self {
|
||||
Self::Turbo4B => ProfileDefaults {
|
||||
n_gpu_layers: 999,
|
||||
n_ctx: 4096,
|
||||
n_batch: 512,
|
||||
n_ubatch: 128,
|
||||
n_ctx: 131_072, // 128 K — covers very long conversations; needs ~4 GB KV VRAM at FP16
|
||||
n_batch: 2048, // prefill chunk size; independent of n_ctx
|
||||
n_ubatch: 512,
|
||||
flash_attn: true,
|
||||
n_threads: 8,
|
||||
},
|
||||
Self::Balanced9B => ProfileDefaults {
|
||||
n_gpu_layers: 999,
|
||||
n_ctx: 2048,
|
||||
n_batch: 256,
|
||||
n_ubatch: 64,
|
||||
n_ctx: 65_536, // 64 K
|
||||
n_batch: 2048,
|
||||
n_ubatch: 256,
|
||||
flash_attn: true,
|
||||
n_threads: 8,
|
||||
},
|
||||
Self::CpuFallback => ProfileDefaults {
|
||||
n_gpu_layers: 0,
|
||||
n_ctx: 2048,
|
||||
n_batch: 128,
|
||||
n_ubatch: 32,
|
||||
n_ctx: 16_384, // 16 K — CPU KV cache is RAM-backed; keep conservative
|
||||
n_batch: 512,
|
||||
n_ubatch: 128,
|
||||
flash_attn: false,
|
||||
n_threads: num_cpus(),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ path = "src/main.rs"
|
||||
inference-core = { path = "../inference-core" }
|
||||
api-contracts = { path = "../api-contracts" }
|
||||
metrics = { path = "../metrics" }
|
||||
runtime-deps = { path = "../runtime-deps" }
|
||||
dirs = { workspace = true }
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
hyper.workspace = true
|
||||
|
||||
@@ -1,38 +1,4 @@
|
||||
fn main() {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let target_dir = std::path::Path::new(&out_dir)
|
||||
.ancestors()
|
||||
.nth(3) // Volta 3 níveis de `target/debug/build/inference-server-XYZ/out` para `target/debug/` ou `target/release/`
|
||||
.unwrap();
|
||||
|
||||
let vendor_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("vendor")
|
||||
.join("llama-bin");
|
||||
|
||||
if vendor_dir.exists() {
|
||||
println!(
|
||||
"cargo:warning=Copying DLLs from {:?} to {:?}",
|
||||
vendor_dir, target_dir
|
||||
);
|
||||
|
||||
// Vamos copiar todos os arquivos .dll
|
||||
if let Ok(entries) = std::fs::read_dir(&vendor_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("dll") {
|
||||
let file_name = path.file_name().unwrap();
|
||||
let dest = target_dir.join(file_name);
|
||||
let _ = std::fs::copy(&path, &dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuildar apenas se houver mudanças nessa build file ou nos bins da vendor
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=../../vendor/llama-bin");
|
||||
println!("cargo:rerun-if-changed=../../vendor/llama-runtime/manifest.json");
|
||||
}
|
||||
@@ -3,8 +3,12 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use runtime_deps::{
|
||||
apply_runtime_to_current_process, ensure_llama_runtime, recommended_variant_override_hint,
|
||||
EnsureRuntimeRequest,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use inference_core::{EngineConfig, InferenceEngine, RuntimeProfile};
|
||||
use metrics::counters::PerfCounters;
|
||||
@@ -17,12 +21,25 @@ use routes::{build_router, AppState};
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// ── Tracing ──────────────────────────────────────────────────────────
|
||||
fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
// Set LOCALLLM_LOG_FORMAT=json for machine-readable output (e.g. log aggregators).
|
||||
// Set RUST_LOG=debug for verbose output.
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
match std::env::var("LOCALLLM_LOG_FORMAT").as_deref() {
|
||||
Ok("json") => {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt::layer().json().with_current_span(true).with_span_list(true))
|
||||
.init();
|
||||
}
|
||||
_ => {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt::layer().with_target(true))
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Engine config (reads env vars; sane defaults for dev) ────────────
|
||||
let model_path = std::env::var("LOCALLLM_MODEL")
|
||||
@@ -62,6 +79,45 @@ async fn main() -> Result<()> {
|
||||
let server_config = Arc::new(ServerConfig::from_env(model_name.clone()));
|
||||
|
||||
// ── Load engine ──────────────────────────────────────────────────────
|
||||
tracing::info!(
|
||||
model = %model_path.display(),
|
||||
profile = ?profile,
|
||||
n_gpu_layers = config.n_gpu_layers,
|
||||
n_ctx = config.n_ctx,
|
||||
n_batch = config.n_batch,
|
||||
n_ubatch = config.n_ubatch,
|
||||
n_threads = config.n_threads,
|
||||
n_threads_batch = config.n_threads_batch,
|
||||
flash_attn = config.flash_attn,
|
||||
use_mmap = config.use_mmap,
|
||||
"engine_config"
|
||||
);
|
||||
let binary_path = std::env::current_exe()?;
|
||||
let runtime_cache_root = dirs::data_local_dir()
|
||||
.unwrap_or_else(|| {
|
||||
binary_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.to_path_buf()
|
||||
})
|
||||
.join("ForgeLM");
|
||||
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
|
||||
cache_root: &runtime_cache_root,
|
||||
gpu_layers: config.n_gpu_layers,
|
||||
variant_override: None,
|
||||
binary_path: Some(&binary_path),
|
||||
})
|
||||
.map_err(|error| anyhow::anyhow!(
|
||||
"failed to prepare llama runtime dependencies: {error}. {}",
|
||||
recommended_variant_override_hint()
|
||||
))?;
|
||||
apply_runtime_to_current_process(&runtime);
|
||||
tracing::info!(
|
||||
dll = %runtime.dll_path.display(),
|
||||
variant = runtime.variant_id,
|
||||
source = ?runtime.source,
|
||||
"llama_runtime_ready"
|
||||
);
|
||||
let mut engine = InferenceEngine::load(config)?;
|
||||
engine.warmup()?;
|
||||
|
||||
@@ -81,6 +137,14 @@ async fn main() -> Result<()> {
|
||||
let router = build_router(state);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
tracing::info!(
|
||||
bind_addr = %addr,
|
||||
model = %model_name,
|
||||
api_key_set = std::env::var("LOCALLLM_API_KEY").is_ok(),
|
||||
log_format = std::env::var("LOCALLLM_LOG_FORMAT").as_deref().unwrap_or("pretty"),
|
||||
"server_ready"
|
||||
);
|
||||
|
||||
// ── Startup banner ───────────────────────────────────────────────────
|
||||
let display_addr = if addr.ip().is_unspecified() {
|
||||
format!("http://127.0.0.1:{}", addr.port())
|
||||
@@ -125,8 +189,20 @@ fn env_u32(key: &str, default: u32) -> u32 {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn resolve_batch_size(n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 {
|
||||
explicit_batch.unwrap_or_else(|| n_ctx.max(default_n_batch).max(1))
|
||||
/// Choose the prefill batch size.
|
||||
///
|
||||
/// The batch size controls how many tokens are processed in a single `llama_decode`
|
||||
/// call during prefill. It must not be confused with the context window (`n_ctx`):
|
||||
/// a large `n_ctx` does NOT require an equally large `n_batch` — `decode_prefill`
|
||||
/// already chunks the prompt into `n_batch`-sized slices.
|
||||
///
|
||||
/// Setting `n_batch = n_ctx` for a 128 K context would pre-allocate ~128 K token
|
||||
/// slots in the batch buffer, consuming gigabytes of VRAM at startup before any
|
||||
/// request arrives. Instead we cap auto-selection at `MAX_AUTO_BATCH` and let
|
||||
/// callers pass `LOCALLLM_BATCH` when they need a larger explicit override.
|
||||
fn resolve_batch_size(_n_ctx: u32, default_n_batch: u32, explicit_batch: Option<u32>) -> u32 {
|
||||
const MAX_AUTO_BATCH: u32 = 4096;
|
||||
explicit_batch.unwrap_or_else(|| default_n_batch.min(MAX_AUTO_BATCH).max(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -134,8 +210,11 @@ mod tests {
|
||||
use super::resolve_batch_size;
|
||||
|
||||
#[test]
|
||||
fn defaults_batch_to_context_size_when_ctx_is_larger() {
|
||||
assert_eq!(resolve_batch_size(16_384, 512, None), 16_384);
|
||||
fn caps_auto_batch_at_max_regardless_of_ctx_size() {
|
||||
// Large n_ctx must NOT bloat n_batch — that would OOM the batch buffer.
|
||||
assert_eq!(resolve_batch_size(131_072, 512, None), 512);
|
||||
assert_eq!(resolve_batch_size(131_072, 4096, None), 4096);
|
||||
assert_eq!(resolve_batch_size(131_072, 8192, None), 4096); // capped at MAX_AUTO_BATCH
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -397,9 +397,17 @@ async fn stream_response(
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream)
|
||||
let response = Sse::new(stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
.into_response();
|
||||
|
||||
axum::response::Response::builder()
|
||||
.status(response.status())
|
||||
.header("content-type", "text/event-stream; charset=utf-8")
|
||||
.header("cache-control", "no-cache")
|
||||
.header("connection", "keep-alive")
|
||||
.body(response.into_body())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn blocking_response(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "runtime-deps"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
@@ -0,0 +1,614 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const MANIFEST_JSON: &str = include_str!("../../../vendor/llama-runtime/manifest.json");
|
||||
const LOCKFILE_NAME: &str = ".install.lock";
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const LOCK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsureRuntimeRequest<'a> {
|
||||
pub cache_root: &'a Path,
|
||||
pub gpu_layers: i32,
|
||||
pub variant_override: Option<&'a str>,
|
||||
pub binary_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedRuntime {
|
||||
pub dll_path: PathBuf,
|
||||
pub library_dir: PathBuf,
|
||||
pub variant_id: String,
|
||||
pub source: RuntimeSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RuntimeSource {
|
||||
EnvOverride,
|
||||
AdjacentBinary,
|
||||
LegacyVendor,
|
||||
ManagedCache,
|
||||
}
|
||||
|
||||
pub fn ensure_llama_runtime(request: &EnsureRuntimeRequest<'_>) -> Result<ResolvedRuntime> {
|
||||
if let Some(runtime) = resolve_from_env_override()? {
|
||||
return Ok(runtime);
|
||||
}
|
||||
|
||||
let manifest = load_manifest()?;
|
||||
let variant = select_variant(&manifest, request.gpu_layers, request.variant_override)?;
|
||||
|
||||
if let Some(runtime) = resolve_from_adjacent_binary(request.binary_path, variant)? {
|
||||
return Ok(runtime);
|
||||
}
|
||||
|
||||
if let Some(runtime) = resolve_from_legacy_vendor(request.binary_path, variant)? {
|
||||
return Ok(runtime);
|
||||
}
|
||||
let install_dir = request
|
||||
.cache_root
|
||||
.join("llama-runtime")
|
||||
.join(&manifest.release)
|
||||
.join(&variant.id);
|
||||
let dll_path = install_dir.join(&variant.dll);
|
||||
|
||||
if installation_is_valid(&install_dir, variant) {
|
||||
return Ok(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: install_dir,
|
||||
variant_id: variant.id.clone(),
|
||||
source: RuntimeSource::ManagedCache,
|
||||
});
|
||||
}
|
||||
|
||||
fs::create_dir_all(install_dir.parent().unwrap_or(request.cache_root)).with_context(|| {
|
||||
format!(
|
||||
"failed to create runtime cache root at {}",
|
||||
request.cache_root.display()
|
||||
)
|
||||
})?;
|
||||
let _lock = InstallationLock::acquire(&install_dir)?;
|
||||
|
||||
if installation_is_valid(&install_dir, variant) {
|
||||
return Ok(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: install_dir,
|
||||
variant_id: variant.id.clone(),
|
||||
source: RuntimeSource::ManagedCache,
|
||||
});
|
||||
}
|
||||
|
||||
install_variant(&install_dir, variant)?;
|
||||
|
||||
Ok(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: install_dir,
|
||||
variant_id: variant.id.clone(),
|
||||
source: RuntimeSource::ManagedCache,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_runtime_to_command(command: &mut Command, runtime: &ResolvedRuntime) {
|
||||
command.env("LLAMA_DLL_PATH", &runtime.dll_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = prepend_path_entry(&runtime.library_dir, std::env::var_os("PATH"));
|
||||
command.env("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_runtime_to_current_process(runtime: &ResolvedRuntime) {
|
||||
std::env::set_var("LLAMA_DLL_PATH", &runtime.dll_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = prepend_path_entry(&runtime.library_dir, std::env::var_os("PATH"));
|
||||
std::env::set_var("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recommended_variant_override_hint() -> &'static str {
|
||||
"Set FORGELM_LLAMA_RUNTIME_VARIANT to a manifest variant id like windows-x64-cpu or windows-x64-cuda-12.4."
|
||||
}
|
||||
|
||||
fn resolve_from_env_override() -> Result<Option<ResolvedRuntime>> {
|
||||
let Ok(raw_path) = std::env::var("LLAMA_DLL_PATH") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dll_path = PathBuf::from(raw_path);
|
||||
if !dll_path.exists() {
|
||||
bail!(
|
||||
"LLAMA_DLL_PATH points to {}, but that file does not exist",
|
||||
dll_path.display()
|
||||
);
|
||||
}
|
||||
let library_dir = dll_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| anyhow!("LLAMA_DLL_PATH must point to a file, not a root path"))?;
|
||||
Ok(Some(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir,
|
||||
variant_id: "env-override".to_string(),
|
||||
source: RuntimeSource::EnvOverride,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_from_adjacent_binary(
|
||||
binary_path: Option<&Path>,
|
||||
variant: &ManifestVariant,
|
||||
) -> Result<Option<ResolvedRuntime>> {
|
||||
let Some(binary_path) = binary_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(binary_dir) = binary_path.parent() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dll_path = binary_dir.join(default_dll_name());
|
||||
if dll_path.exists() && verify_required_files(binary_dir, variant).is_ok() {
|
||||
return Ok(Some(ResolvedRuntime {
|
||||
dll_path,
|
||||
library_dir: binary_dir.to_path_buf(),
|
||||
variant_id: "adjacent-binary".to_string(),
|
||||
source: RuntimeSource::AdjacentBinary,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn resolve_from_legacy_vendor(
|
||||
binary_path: Option<&Path>,
|
||||
variant: &ManifestVariant,
|
||||
) -> Result<Option<ResolvedRuntime>> {
|
||||
let Some(binary_path) = binary_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut dir = binary_path.parent();
|
||||
while let Some(current) = dir {
|
||||
let candidate = current
|
||||
.join("vendor")
|
||||
.join("llama-bin")
|
||||
.join(default_dll_name());
|
||||
let Some(library_dir) = candidate.parent() else {
|
||||
dir = current.parent();
|
||||
continue;
|
||||
};
|
||||
if candidate.exists() && verify_required_files(library_dir, variant).is_ok() {
|
||||
return Ok(Some(ResolvedRuntime {
|
||||
dll_path: candidate.clone(),
|
||||
library_dir: library_dir.to_path_buf(),
|
||||
variant_id: "legacy-vendor".to_string(),
|
||||
source: RuntimeSource::LegacyVendor,
|
||||
}));
|
||||
}
|
||||
dir = current.parent();
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn load_manifest() -> Result<Manifest> {
|
||||
serde_json::from_str(MANIFEST_JSON).context("failed to parse embedded llama runtime manifest")
|
||||
}
|
||||
|
||||
fn select_variant<'a>(
|
||||
manifest: &'a Manifest,
|
||||
gpu_layers: i32,
|
||||
variant_override: Option<&str>,
|
||||
) -> Result<&'a ManifestVariant> {
|
||||
let env_override = std::env::var("FORGELM_LLAMA_RUNTIME_VARIANT").ok();
|
||||
if let Some(override_id) = variant_override.or(env_override.as_deref()) {
|
||||
return manifest
|
||||
.variants
|
||||
.iter()
|
||||
.find(|variant| variant.id == override_id)
|
||||
.ok_or_else(|| anyhow!("unknown runtime variant '{override_id}'"));
|
||||
}
|
||||
|
||||
let target = current_target();
|
||||
let want_gpu = gpu_layers > 0;
|
||||
manifest
|
||||
.variants
|
||||
.iter()
|
||||
.find(|variant| variant.target == target && variant.gpu == want_gpu)
|
||||
.or_else(|| {
|
||||
manifest
|
||||
.variants
|
||||
.iter()
|
||||
.find(|variant| variant.target == target && !variant.gpu)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("no runtime bundle configured for target {target}"))
|
||||
}
|
||||
|
||||
fn install_variant(install_dir: &Path, variant: &ManifestVariant) -> Result<()> {
|
||||
if let Some(parent) = install_dir.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create runtime install parent at {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let staging_dir = install_dir.with_extension("staging");
|
||||
if staging_dir.exists() {
|
||||
fs::remove_dir_all(&staging_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to clean previous staging dir {}",
|
||||
staging_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::create_dir_all(&staging_dir)
|
||||
.with_context(|| format!("failed to create staging dir {}", staging_dir.display()))?;
|
||||
|
||||
for archive in &variant.archives {
|
||||
let archive_path = download_archive_to_file(archive, &staging_dir)?;
|
||||
extract_zip_file(&archive_path, &staging_dir)?;
|
||||
}
|
||||
|
||||
verify_required_files(&staging_dir, variant)?;
|
||||
|
||||
if install_dir.exists() {
|
||||
fs::remove_dir_all(install_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to replace old runtime dir {}",
|
||||
install_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::rename(&staging_dir, install_dir).or_else(|rename_error| {
|
||||
copy_dir_recursive(&staging_dir, install_dir)
|
||||
.and_then(|_| {
|
||||
fs::remove_dir_all(&staging_dir)
|
||||
.with_context(|| format!("failed to remove staging dir {}", staging_dir.display()))
|
||||
})
|
||||
.map_err(|copy_error| {
|
||||
anyhow!(
|
||||
"failed to finalize runtime install via rename ({rename_error}) and copy fallback ({copy_error})"
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_archive_to_file(archive: &ManifestArchive, staging_dir: &Path) -> Result<PathBuf> {
|
||||
let downloads_dir = staging_dir.join(".downloads");
|
||||
fs::create_dir_all(&downloads_dir)
|
||||
.with_context(|| format!("failed to create downloads dir {}", downloads_dir.display()))?;
|
||||
let archive_name = archive
|
||||
.url
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| anyhow!("archive url has no file name: {}", archive.url))?;
|
||||
let archive_path = downloads_dir.join(archive_name);
|
||||
|
||||
let response = ureq::get(&archive.url)
|
||||
.timeout(DOWNLOAD_TIMEOUT)
|
||||
.call()
|
||||
.with_context(|| format!("failed to download {}", archive.url))?;
|
||||
let mut reader = response.into_reader();
|
||||
let mut file = File::create(&archive_path)
|
||||
.with_context(|| format!("failed to create archive file {}", archive_path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut total_bytes = 0u64;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("failed to stream downloaded bytes from {}", archive.url))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
total_bytes += read as u64;
|
||||
if total_bytes > archive.size {
|
||||
bail!(
|
||||
"downloaded archive {} exceeded declared size {} bytes",
|
||||
archive.url,
|
||||
archive.size
|
||||
);
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
file.write_all(&buffer[..read]).with_context(|| {
|
||||
format!(
|
||||
"failed to write downloaded archive to {}",
|
||||
archive_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if total_bytes != archive.size {
|
||||
bail!(
|
||||
"downloaded archive {} size mismatch: expected {} bytes, got {}",
|
||||
archive.url,
|
||||
archive.size,
|
||||
total_bytes
|
||||
);
|
||||
}
|
||||
|
||||
let expected = &archive.sha256;
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != *expected {
|
||||
bail!(
|
||||
"downloaded archive {} failed sha256 verification: expected {}, got {}",
|
||||
archive.url,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
Ok(archive_path)
|
||||
}
|
||||
|
||||
fn extract_zip_file(archive_path: &Path, dest_dir: &Path) -> Result<()> {
|
||||
let file = File::open(archive_path)
|
||||
.with_context(|| format!("failed to open archive {}", archive_path.display()))?;
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(BufReader::new(file)).context("failed to open zip archive")?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.context("failed to read zip entry")?;
|
||||
let Some(relative_path) = entry.enclosed_name().map(|path| path.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output_path = dest_dir.join(relative_path);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output_path).with_context(|| {
|
||||
format!("failed to create extracted dir {}", output_path.display())
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create parent dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut output = File::create(&output_path).with_context(|| {
|
||||
format!("failed to create extracted file {}", output_path.display())
|
||||
})?;
|
||||
io::copy(&mut entry, &mut output)
|
||||
.with_context(|| format!("failed to extract {}", output_path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_required_files(install_dir: &Path, variant: &ManifestVariant) -> Result<()> {
|
||||
for required_file in &variant.required_files {
|
||||
let candidate = install_dir.join(required_file);
|
||||
if !candidate.exists() {
|
||||
bail!(
|
||||
"runtime bundle {} is missing required file {}",
|
||||
variant.id,
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn installation_is_valid(install_dir: &Path, variant: &ManifestVariant) -> bool {
|
||||
install_dir.exists() && verify_required_files(install_dir, variant).is_ok()
|
||||
}
|
||||
|
||||
fn default_dll_name() -> &'static str {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"llama.dll"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"libllama.so"
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"libllama.dylib"
|
||||
}
|
||||
}
|
||||
|
||||
fn current_target() -> &'static str {
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"windows-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
"windows-aarch64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"linux-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"macos-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"macos-aarch64"
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_path_entry(entry: &Path, existing: Option<OsString>) -> OsString {
|
||||
let mut paths = vec![entry.to_path_buf()];
|
||||
if let Some(existing) = existing {
|
||||
paths.extend(std::env::split_paths(&existing));
|
||||
}
|
||||
std::env::join_paths(paths).unwrap_or_else(|_| OsString::from(entry.as_os_str()))
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
||||
fs::create_dir_all(destination)
|
||||
.with_context(|| format!("failed to create {}", destination.display()))?;
|
||||
for entry in
|
||||
fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let source_path = entry.path();
|
||||
let destination_path = destination.join(entry.file_name());
|
||||
if source_path.is_dir() {
|
||||
copy_dir_recursive(&source_path, &destination_path)?;
|
||||
} else {
|
||||
fs::copy(&source_path, &destination_path).with_context(|| {
|
||||
format!(
|
||||
"failed to copy {} to {}",
|
||||
source_path.display(),
|
||||
destination_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct InstallationLock {
|
||||
path: PathBuf,
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl InstallationLock {
|
||||
fn acquire(install_dir: &Path) -> Result<Self> {
|
||||
fs::create_dir_all(install_dir.parent().unwrap_or(install_dir)).with_context(|| {
|
||||
format!("failed to create lock parent for {}", install_dir.display())
|
||||
})?;
|
||||
|
||||
let lock_path = install_dir.join(LOCKFILE_NAME);
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
path: lock_path,
|
||||
_file: file,
|
||||
})
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
if lock_is_stale(&lock_path)? {
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
continue;
|
||||
}
|
||||
if started.elapsed() >= LOCK_TIMEOUT {
|
||||
bail!(
|
||||
"timed out waiting for runtime install lock at {}",
|
||||
lock_path.display()
|
||||
);
|
||||
}
|
||||
thread::sleep(LOCK_POLL_INTERVAL);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| {
|
||||
format!(
|
||||
"failed to acquire runtime install lock at {}",
|
||||
lock_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InstallationLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_is_stale(lock_path: &Path) -> Result<bool> {
|
||||
let metadata = match fs::metadata(lock_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| {
|
||||
format!("failed to read lock metadata for {}", lock_path.display())
|
||||
})
|
||||
}
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.with_context(|| format!("failed to read lock timestamp for {}", lock_path.display()))?;
|
||||
Ok(modified.elapsed().unwrap_or_default() > LOCK_TIMEOUT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Manifest {
|
||||
release: String,
|
||||
variants: Vec<ManifestVariant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManifestVariant {
|
||||
id: String,
|
||||
target: String,
|
||||
gpu: bool,
|
||||
dll: String,
|
||||
archives: Vec<ManifestArchive>,
|
||||
required_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManifestArchive {
|
||||
url: String,
|
||||
sha256: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{current_target, load_manifest, prepend_path_entry, select_variant, Manifest};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
load_manifest().expect("manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_cpu_variant_for_local_cpu_mode() {
|
||||
let manifest = manifest();
|
||||
let variant = select_variant(&manifest, 0, None).expect("variant");
|
||||
assert_eq!(variant.target, current_target());
|
||||
assert!(!variant.gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_gpu_variant_when_gpu_layers_are_requested() {
|
||||
let manifest = manifest();
|
||||
let variant = select_variant(&manifest, 32, None).expect("variant");
|
||||
assert_eq!(variant.target, current_target());
|
||||
assert!(variant.gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_explicit_variant_override() {
|
||||
let manifest = manifest();
|
||||
let variant = select_variant(&manifest, 0, Some("windows-x64-cpu")).expect("variant");
|
||||
assert_eq!(variant.id, "windows-x64-cpu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepends_runtime_dir_to_existing_path() {
|
||||
let joined = prepend_path_entry(
|
||||
Path::new("C:/runtime"),
|
||||
Some(OsString::from("C:/windows;C:/tools")),
|
||||
);
|
||||
let rendered = joined.to_string_lossy();
|
||||
assert!(rendered.starts_with("C:/runtime"));
|
||||
assert!(rendered.contains("C:/windows"));
|
||||
}
|
||||
}
|
||||
Generated
+939
File diff suppressed because it is too large.
Load diff
@@ -11,10 +11,12 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.0.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -25,7 +27,10 @@
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.2"
|
||||
|
||||
Generated
+590
@@ -45,15 +45,32 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.1
|
||||
version: 4.7.0([email protected])
|
||||
jsdom:
|
||||
specifier: ^29.0.1
|
||||
version: 29.0.1
|
||||
typescript:
|
||||
specifier: ^5.5.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.4.8
|
||||
version: 5.4.21
|
||||
vitest:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2([email protected])([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -137,6 +154,46 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bramus/[email protected]':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -275,6 +332,15 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -432,6 +498,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
@@ -526,6 +595,12 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
@@ -553,11 +628,47 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@@ -566,12 +677,24 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -581,12 +704,22 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -596,6 +729,22 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -609,9 +758,25 @@ packages:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -626,14 +791,24 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -645,13 +820,30 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
@@ -665,11 +857,19 @@ packages:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -677,15 +877,61 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -723,11 +969,92 @@ packages:
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.2
|
||||
'@vitest/browser-preview': 4.1.2
|
||||
'@vitest/browser-webdriverio': 4.1.2
|
||||
'@vitest/ui': 4.1.2
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-color-parser': 4.0.2(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
dependencies:
|
||||
'@asamuzakjp/nwsapi': 2.3.9
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.2.1
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -840,6 +1167,34 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bramus/[email protected]':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.0.2
|
||||
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -909,6 +1264,8 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1005,6 +1362,8 @@ snapshots:
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@tauri-apps/[email protected]': {}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
@@ -1079,6 +1438,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
dompurify: 3.3.3
|
||||
@@ -1111,8 +1477,55 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.2
|
||||
'@vitest/utils': 4.1.2
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.2
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.2
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.2
|
||||
'@vitest/utils': 4.1.2
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.2
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.11
|
||||
@@ -1123,20 +1536,40 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
@@ -1165,6 +1598,16 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -1172,8 +1615,42 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.1.1
|
||||
'@asamuzakjp/dom-selector': 7.0.4
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.2([email protected])
|
||||
'@exodus/bytes': 1.15.0
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.7
|
||||
parse5: 8.0.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.1
|
||||
undici: 7.24.6
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1182,26 +1659,46 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -1214,6 +1711,8 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -1245,16 +1744,55 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tldts-core: 7.0.27
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tldts: 7.0.27
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
@@ -1269,4 +1807,56 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.2
|
||||
'@vitest/mocker': 4.1.2([email protected])
|
||||
'@vitest/pretty-format': 4.1.2
|
||||
'@vitest/runner': 4.1.2
|
||||
'@vitest/snapshot': 4.1.2
|
||||
'@vitest/spy': 4.1.2
|
||||
'@vitest/utils': 4.1.2
|
||||
es-module-lexer: 2.0.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.0.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 5.4.21
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
jsdom: 29.0.1
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Generated
+197
-8
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -521,6 +530,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -565,13 +585,34 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -582,7 +623,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -830,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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -118,7 +118,7 @@ fn start_runtime_inner(
|
||||
inner.config.clone()
|
||||
};
|
||||
|
||||
let child = match spawn_localllm(&config) {
|
||||
let child = match spawn_localllm(app, &config) {
|
||||
Ok(child) => child,
|
||||
Err(message) => {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::types::RuntimeConfig;
|
||||
use runtime_deps::{
|
||||
apply_runtime_to_command, ensure_llama_runtime, recommended_variant_override_hint,
|
||||
EnsureRuntimeRequest,
|
||||
};
|
||||
|
||||
pub fn find_localllm_binary() -> Option<PathBuf> {
|
||||
let exe_name = if cfg!(target_os = "windows") {
|
||||
@@ -52,7 +58,17 @@ pub fn find_localllm_binary() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
|
||||
fn runtime_cache_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve app data dir: {error}"))?;
|
||||
std::fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|error| format!("Cannot create app data dir: {error}"))?;
|
||||
Ok(app_data_dir.join("runtime-cache"))
|
||||
}
|
||||
|
||||
pub fn spawn_localllm(app: &AppHandle, config: &RuntimeConfig) -> Result<Child, String> {
|
||||
let model_path = config
|
||||
.model_path
|
||||
.as_deref()
|
||||
@@ -65,11 +81,28 @@ pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Command::new(&binary_path)
|
||||
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
|
||||
cache_root: &runtime_cache_root(app)?,
|
||||
gpu_layers: config.gpu_layers,
|
||||
variant_override: None,
|
||||
binary_path: Some(&binary_path),
|
||||
})
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Failed to prepare llama runtime dependencies: {error}\n{}",
|
||||
recommended_variant_override_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut command = Command::new(&binary_path);
|
||||
command
|
||||
.env("LOCALLLM_MODEL", model_path)
|
||||
.env("LOCALLLM_ADDR", &config.addr)
|
||||
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
|
||||
.env("LOCALLLM_CTX", config.ctx_size.to_string())
|
||||
.env("LOCALLLM_CTX", config.ctx_size.to_string());
|
||||
apply_runtime_to_command(&mut command, &runtime);
|
||||
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model_library::types::RemotePreset;
|
||||
|
||||
pub const DEFAULT_CTX_SIZE: u32 = 4096;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionMode {
|
||||
@@ -102,7 +104,7 @@ impl Default for RuntimeConfig {
|
||||
models_dir: None,
|
||||
addr: Self::default_addr(),
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
ctx_size: DEFAULT_CTX_SIZE,
|
||||
extra_model_paths: Vec::new(),
|
||||
selected_model_id: None,
|
||||
remote_presets: Vec::new(),
|
||||
@@ -113,7 +115,14 @@ impl Default for RuntimeConfig {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConnectionMode, RuntimeConfig};
|
||||
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE};
|
||||
|
||||
#[test]
|
||||
fn runtime_config_defaults_to_safe_context_window() {
|
||||
let config = RuntimeConfig::default();
|
||||
|
||||
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_deserializes_old_shape_with_new_defaults() {
|
||||
|
||||
+34
-26
@@ -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
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Edit mode */
|
||||
/* ── Edit mode ── */
|
||||
.msg-edit-container {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.msg-edit-textarea {
|
||||
@@ -12,17 +12,19 @@
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
padding: 10px 14px;
|
||||
padding: 11px 14px;
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
box-shadow: 0 0 0 3px rgba(72,166,255,0.1);
|
||||
}
|
||||
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -31,13 +33,18 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
min-height: 44px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent2); }
|
||||
.btn-primary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.22);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg4);
|
||||
@@ -45,108 +52,286 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
min-height: 44px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg5); color: var(--text); }
|
||||
.btn-secondary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
min-height: 44px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text2); }
|
||||
|
||||
/* Message actions */
|
||||
.reasoning-panel {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
.btn-ghost:hover { color: var(--text); background: rgba(237, 245, 255, 0.05); border-color: rgba(93, 114, 136, 0.28); }
|
||||
.btn-ghost:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
|
||||
}
|
||||
|
||||
.reasoning-toggle {
|
||||
display: inline-flex;
|
||||
/* ── Thinking inside bubble ── */
|
||||
.msg-thinking {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.msg-thinking > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.msg-thinking > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(124, 158, 248, 0.08);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.75rem;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
user-select: none;
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
|
||||
.reasoning-toggle:hover {
|
||||
background: rgba(124, 158, 248, 0.12);
|
||||
color: var(--text);
|
||||
.thinking-summary:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.reasoning-content {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(124, 158, 248, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text2);
|
||||
font-size: 0.84rem;
|
||||
.thinking-summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.thinking-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 0.2s ease-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-thinking[open] .thinking-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 4px 16px 16px 16px;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.6;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.thinking-content > :first-child {
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.1);
|
||||
margin-left: 16px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ── Message bubble layout ── */
|
||||
.msg-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
padding: 13px 16px;
|
||||
}
|
||||
|
||||
/* ── Message metrics ── */
|
||||
.message-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--text3);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding-left: 12px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
font-family: monospace;
|
||||
color: #a8b8cc;
|
||||
}
|
||||
|
||||
.message-metrics span {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
/* ── Message footer ── */
|
||||
.msg-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* ── Compact actions for user bubble (floating bar) ── */
|
||||
.msg-wrapper.user {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.msg-wrapper.user:hover .compact-actions,
|
||||
.msg-wrapper.user:focus-within .compact-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.compact-actions .action-btn {
|
||||
min-height: 28px;
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #c8d7ea;
|
||||
}
|
||||
.compact-actions .action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.compact-actions .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
.compact-actions .action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(72, 166, 255, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
.compact-actions .action-btn.copied {
|
||||
color: var(--success);
|
||||
background: rgba(48, 209, 88, 0.12);
|
||||
}
|
||||
.compact-actions .action-btn.danger {
|
||||
color: #f4c2c2;
|
||||
}
|
||||
.compact-actions .action-btn.danger:hover {
|
||||
background: rgba(240, 64, 64, 0.12);
|
||||
color: #ffd7d7;
|
||||
}
|
||||
|
||||
/* ── Message actions (assistant) ── */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.msg-bubble:hover .msg-actions,
|
||||
.msg-bubble:focus-within .msg-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.msg-wrapper:hover .msg-actions { opacity: 1; }
|
||||
.msg-wrapper:focus-within .msg-actions { opacity: 1; }
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
min-width: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #c8d7ea;
|
||||
border-radius: 0;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: background 0.2s ease-out, color 0.2s ease-out;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--border2); }
|
||||
.action-btn.copied { color: var(--success); border-color: var(--success); }
|
||||
.action-btn.danger:hover { background: rgba(240,64,64,0.1); color: var(--danger); border-color: var(--danger); }
|
||||
.action-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
.action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
|
||||
opacity: 1;
|
||||
}
|
||||
.action-btn.copied {
|
||||
color: var(--success);
|
||||
background: rgba(48, 209, 88, 0.08);
|
||||
}
|
||||
.action-btn.danger {
|
||||
color: #f4c2c2;
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
background: rgba(240, 64, 64, 0.1);
|
||||
color: #ffd7d7;
|
||||
}
|
||||
|
||||
@media (hover: none), (max-width: 960px) {
|
||||
.msg-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.msg-actions {
|
||||
opacity: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1 1 132px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action-btn,
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import type { Message } from "../lib/types";
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("renders accessible thinking and action controls for assistant messages", async () => {
|
||||
const message: Message = {
|
||||
id: 12,
|
||||
chat_id: 4,
|
||||
role: "assistant",
|
||||
content: "Final answer",
|
||||
reasoning_content: "Reasoning details",
|
||||
prompt_tokens: 18,
|
||||
completion_tokens: 24,
|
||||
total_tokens: 42,
|
||||
tokens_per_second: 6.4,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MessageBubble
|
||||
message={message}
|
||||
onDelete={vi.fn()}
|
||||
onEditAssistant={vi.fn()}
|
||||
renderMarkdown={(text) => `<p>${text}</p>`}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const summary = container.querySelector("summary.reasoning-toggle");
|
||||
expect(summary?.getAttribute("aria-label")).toBe("Toggle thinking details");
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
expect(buttons.some((node) => node.getAttribute("aria-label") === "Copy message")).toBe(true);
|
||||
expect(buttons.some((node) => node.getAttribute("aria-label") === "Edit message")).toBe(true);
|
||||
expect(buttons.some((node) => node.getAttribute("aria-label") === "Delete message")).toBe(true);
|
||||
|
||||
expect(container.textContent).toContain("Sent 18");
|
||||
expect(container.textContent).toContain("Generated 24");
|
||||
expect(container.textContent).toContain("Total 42");
|
||||
expect(container.textContent).toContain("6.4 tok/s");
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const reasoningRef = useRef<HTMLDivElement>(null);
|
||||
const thinkingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Wire up copy-code buttons inside rendered markdown
|
||||
useEffect(() => {
|
||||
@@ -36,8 +35,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!reasoningRef.current) return;
|
||||
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
|
||||
if (!thinkingRef.current) return;
|
||||
const buttons = thinkingRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
|
||||
buttons.forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const code = btn.getAttribute("data-code") || "";
|
||||
@@ -90,6 +89,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
(message.total_tokens ?? 0) > 0
|
||||
);
|
||||
|
||||
const showFooter = message.role === "assistant";
|
||||
|
||||
return (
|
||||
<div className={`msg-wrapper ${message.role}`}>
|
||||
{editing ? (
|
||||
@@ -118,56 +119,101 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasReasoning && (
|
||||
<div className="reasoning-panel">
|
||||
<button
|
||||
className="reasoning-toggle"
|
||||
onClick={() => setShowReasoning((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{showReasoning ? "▼" : "▶"}</span>
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
{showReasoning && (
|
||||
<div
|
||||
ref={reasoningRef}
|
||||
className="reasoning-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className="msg-bubble"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="msg-bubble">{message.content}</div>
|
||||
)}
|
||||
>
|
||||
{hasReasoning && (
|
||||
<details className="msg-thinking">
|
||||
<summary className="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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user