diff --git a/.planning/debug/segunda-mensagem-chat.md b/.planning/debug/segunda-mensagem-chat.md new file mode 100644 index 0000000..efc6e31 --- /dev/null +++ b/.planning/debug/segunda-mensagem-chat.md @@ -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"] diff --git a/.planning/quick/2-preflight-llama-native-deps-startup/2-PLAN.md b/.planning/quick/2-preflight-llama-native-deps-startup/2-PLAN.md new file mode 100644 index 0000000..ea353fd --- /dev/null +++ b/.planning/quick/2-preflight-llama-native-deps-startup/2-PLAN.md @@ -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" +--- + + +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. + + + +@.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 + + + + + + Task 1: Definir contrato de preflight e pontos de integracao no startup local + + tauri-app/src-tauri/src/runtime/mod.rs + tauri-app/src-tauri/src/runtime/process.rs + tauri-app/src-tauri/src/runtime/state.rs + + +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. + + + cargo check -p tauri-app + + + - `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 + + + + + Task 2: Implementar verificacao/download das dependencias nativas do llama + + tauri-app/src-tauri/src/runtime/process.rs + crates/inference-core/src/engine.rs + crates/llama-sys/src/lib.rs + + +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. + + + cargo check -p llama-sys -p inference-core -p tauri-app + + + - 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 + + + + + Task 3: Cobrir regressao de startup e bootstrap do desktop + + tauri-app/src-tauri/src/runtime/state.rs + tauri-app/src-tauri/src/runtime/mod.rs + tauri-app/src-tauri/src/runtime/process.rs + + +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. + + + cargo test -p tauri-app runtime:: -- --nocapture + + + - 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 + + + + + + +- `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 + + + +- 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 + + + +Ao concluir a execucao futura, criar `.planning/quick/2-preflight-llama-native-deps-startup/2-SUMMARY.md` + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8d4f905 --- /dev/null +++ b/AGENTS.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index a405280..285a1c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 20f2936..42dcff4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/inference-core/src/profile.rs b/crates/inference-core/src/profile.rs index 78d6309..ce818e8 100644 --- a/crates/inference-core/src/profile.rs +++ b/crates/inference-core/src/profile.rs @@ -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(), }, diff --git a/crates/inference-server/Cargo.toml b/crates/inference-server/Cargo.toml index 4638fd8..e4bab54 100644 --- a/crates/inference-server/Cargo.toml +++ b/crates/inference-server/Cargo.toml @@ -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 diff --git a/crates/inference-server/build.rs b/crates/inference-server/build.rs index b45a82a..a801a4a 100644 --- a/crates/inference-server/build.rs +++ b/crates/inference-server/build.rs @@ -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"); } diff --git a/crates/inference-server/src/main.rs b/crates/inference-server/src/main.rs index 2adfcc3..bcec89d 100644 --- a/crates/inference-server/src/main.rs +++ b/crates/inference-server/src/main.rs @@ -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 { - 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 { + 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] diff --git a/crates/inference-server/src/routes.rs b/crates/inference-server/src/routes.rs index e609e20..5ce8a2d 100644 --- a/crates/inference-server/src/routes.rs +++ b/crates/inference-server/src/routes.rs @@ -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( diff --git a/crates/runtime-deps/Cargo.toml b/crates/runtime-deps/Cargo.toml new file mode 100644 index 0000000..8b1c3b8 --- /dev/null +++ b/crates/runtime-deps/Cargo.toml @@ -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 } diff --git a/crates/runtime-deps/src/lib.rs b/crates/runtime-deps/src/lib.rs new file mode 100644 index 0000000..e8e86eb --- /dev/null +++ b/crates/runtime-deps/src/lib.rs @@ -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 { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +#[derive(Debug, Deserialize)] +struct ManifestVariant { + id: String, + target: String, + gpu: bool, + dll: String, + archives: Vec, + required_files: Vec, +} + +#[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")); + } +} diff --git a/tauri-app/package-lock.json b/tauri-app/package-lock.json index fce8537..f2aefbd 100644 --- a/tauri-app/package-lock.json +++ b/tauri-app/package-lock.json @@ -8,10 +8,12 @@ "name": "forgelm-chat", "version": "0.1.0", "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" @@ -22,12 +24,28 @@ "@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" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -1034,6 +1052,44 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@oxc-project/types": { "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -1044,6 +1100,19 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -2132,6 +2201,47 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2142,6 +2252,43 @@ "node": ">=12" } }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", @@ -2165,6 +2312,32 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2199,6 +2372,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -2230,6 +2413,54 @@ "node": ">=18" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2251,6 +2482,19 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2307,6 +2551,20 @@ "node": ">=8" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", @@ -2412,6 +2670,46 @@ "node": ">=12.0.0" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2430,6 +2728,33 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2445,6 +2770,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2455,6 +2790,32 @@ "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -2477,6 +2838,68 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2484,6 +2907,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2828,6 +3261,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2850,6 +3303,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2879,6 +3341,43 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2886,6 +3385,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2912,6 +3423,36 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2936,6 +3477,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2963,6 +3511,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -2992,6 +3560,140 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3002,6 +3704,27 @@ "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3037,6 +3760,42 @@ "node": ">=0.10.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3047,6 +3806,38 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -3133,6 +3924,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3196,6 +4011,42 @@ "dev": true, "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3203,6 +4054,67 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3267,6 +4179,19 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -3293,6 +4218,13 @@ "node": ">=20" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3356,6 +4288,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/tauri-app/package.json b/tauri-app/package.json index 31ac5d6..2ea9039 100644 --- a/tauri-app/package.json +++ b/tauri-app/package.json @@ -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" diff --git a/tauri-app/pnpm-lock.yaml b/tauri-app/pnpm-lock.yaml index 5d4be67..8a70f08 100644 --- a/tauri-app/pnpm-lock.yaml +++ b/tauri-app/pnpm-lock.yaml @@ -45,15 +45,32 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.7.0(vite@5.4.21) + 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(jsdom@29.0.1)(vite@5.4.21) packages: + '@asamuzakjp/css-color@5.1.1': + resolution: {integrity: sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': 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/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + 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/css-color-parser@4.0.2': + 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/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -275,6 +332,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + 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/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -432,6 +498,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -526,6 +595,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dompurify@3.2.0': 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/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + 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/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.11: resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -566,12 +677,24 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -581,12 +704,22 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} electron-to-chromium@1.5.326: resolution: {integrity: sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.21.5: 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'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fsevents@2.3.3: 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'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.0.1: + 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 + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -626,14 +791,24 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@12.0.2: resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} engines: {node: '>= 18'} hasBin: true + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -645,13 +820,30 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@18.3.1: 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'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.60.0: resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -677,15 +877,61 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -723,11 +969,92 @@ packages: terser: optional: true + vitest@4.1.2: + 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 + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} snapshots: + '@asamuzakjp/css-color@5.1.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.4': + 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/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': 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/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -909,6 +1264,8 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@exodus/bytes@1.15.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1005,6 +1362,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true + '@standard-schema/spec@1.1.0': {} + '@tauri-apps/api@2.10.1': {} '@tauri-apps/cli-darwin-arm64@2.10.1': @@ -1079,6 +1438,13 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/dompurify@3.2.0': dependencies: dompurify: 3.3.3 @@ -1111,8 +1477,55 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.2': + 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/mocker@4.1.2(vite@5.4.21)': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.11: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.11 @@ -1123,20 +1536,40 @@ snapshots: caniuse-lite@1.0.30001781: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 electron-to-chromium@1.5.326: {} + entities@6.0.1: {} + + es-module-lexer@2.0.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1165,6 +1598,16 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fsevents@2.3.3: optional: true @@ -1172,8 +1615,42 @@ snapshots: highlight.js@11.11.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + is-potential-custom-element-name@1.0.1: {} + js-tokens@4.0.0: {} + jsdom@29.0.1: + 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(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.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' + jsesc@3.1.0: {} json5@2.2.3: {} @@ -1182,26 +1659,46 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + marked@12.0.2: {} + mdn-data@2.27.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} node-releases@2.0.36: {} + obug@2.1.1: {} + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@4.0.4: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + punycode@2.3.1: {} + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -1214,6 +1711,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + require-from-string@2.0.2: {} + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 @@ -1245,16 +1744,55 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 semver@6.3.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + typescript@5.9.3: {} + undici@7.24.6: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -1269,4 +1807,56 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@4.1.2(jsdom@29.0.1)(vite@5.4.21): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@5.4.21) + '@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 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} diff --git a/tauri-app/postcss.config.js b/tauri-app/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/tauri-app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/tauri-app/src-tauri/Cargo.lock b/tauri-app/src-tauri/Cargo.lock index f13cfc5..ae1c09c 100644 --- a/tauri-app/src-tauri/Cargo.lock +++ b/tauri-app/src-tauri/Cargo.lock @@ -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", +] diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index d363111..ae60a51 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -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" diff --git a/tauri-app/src-tauri/src/runtime/mod.rs b/tauri-app/src-tauri/src/runtime/mod.rs index 736ca07..d9b8af4 100644 --- a/tauri-app/src-tauri/src/runtime/mod.rs +++ b/tauri-app/src-tauri/src/runtime/mod.rs @@ -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())?; diff --git a/tauri-app/src-tauri/src/runtime/process.rs b/tauri-app/src-tauri/src/runtime/process.rs index cd852ee..4b55f96 100644 --- a/tauri-app/src-tauri/src/runtime/process.rs +++ b/tauri-app/src-tauri/src/runtime/process.rs @@ -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 { let exe_name = if cfg!(target_os = "windows") { @@ -52,7 +58,17 @@ pub fn find_localllm_binary() -> Option { None } -pub fn spawn_localllm(config: &RuntimeConfig) -> Result { +fn runtime_cache_root(app: &AppHandle) -> Result { + 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 { let model_path = config .model_path .as_deref() @@ -65,11 +81,28 @@ pub fn spawn_localllm(config: &RuntimeConfig) -> Result { .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())) } diff --git a/tauri-app/src-tauri/src/runtime/types.rs b/tauri-app/src-tauri/src/runtime/types.rs index d6a02e8..7c3336c 100644 --- a/tauri-app/src-tauri/src/runtime/types.rs +++ b/tauri-app/src-tauri/src/runtime/types.rs @@ -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() { diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 7190337..c825094 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -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(null); const [chats, setChats] = useState([]); 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 ( -
- setSidebarOpen(false)} - /> - {sidebarOpen && ( +
+ {view !== "settings" && ( + setView("settings")} + onCloseMobile={() => setSidebarOpen(false)} + /> + )} + {view !== "settings" && sidebarOpen && (
); diff --git a/tauri-app/src/components/ChatView.css b/tauri-app/src/components/ChatView.css index 1a97253..b2e8ed5 100644 --- a/tauri-app/src/components/ChatView.css +++ b/tauri-app/src/components/ChatView.css @@ -6,151 +6,464 @@ background: var(--bg); } -/* Top bar */ +/* ── Chat Layout (Tier-1 centered reading column) ── */ +.chat-layout-root { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + position: relative; +} + +.chat-scroll-area { + flex: 1; + overflow-y: auto; + scroll-behavior: smooth; +} + +/* Minimalist scrollbar */ +.chat-scroll-area::-webkit-scrollbar { + width: 6px; +} +.chat-scroll-area::-webkit-scrollbar-track { + background: transparent; +} +.chat-scroll-area::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} +.chat-scroll-area::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +.messages-container { + margin: 0 auto; + max-width: 1200px; + width: 100%; + padding: 40px 32px 180px 32px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.scroll-spacer { + height: 120px; + flex-shrink: 0; +} + +/* ── Fixed Input ── */ +.input-sticky-wrapper { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + z-index: 50; + background: linear-gradient(to bottom, transparent 0%, var(--bg) 30%, var(--bg) 100%); + padding-top: 40px; + pointer-events: none; +} + +.input-gradient-mask { + width: 100%; + height: 60px; + background: linear-gradient(to bottom, transparent, var(--bg)); + pointer-events: none; +} + +.input-content-constrained { + margin: 0 auto; + width: 100%; + max-width: 800px; + padding: 0 20px 32px 20px; + pointer-events: auto; + position: relative; + z-index: 51; +} + +/* ── Top bar ── */ .chat-topbar { display: flex; align-items: center; justify-content: space-between; - padding: 8px 20px; + padding: 0 16px; background: var(--bg2); border-bottom: 1px solid var(--border); - min-height: 44px; + height: 48px; + gap: 12px; + flex-shrink: 0; } -.chat-topbar-left { display: flex; align-items: center; gap: 10px; } -.chat-topbar-right { display: flex; align-items: center; gap: 14px; } +.chat-topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex-shrink: 0; } +.chat-topbar-center { display: flex; align-items: center; justify-content: center; flex: 1; min-width: 0; } +.chat-topbar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +/* Menu button (mobile) */ .chat-menu-btn { display: none; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 10px; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg3); - color: var(--text); + color: var(--text2); cursor: pointer; font-size: 1rem; - transition: background 0.15s, border-color 0.15s; + transition: background 0.15s, border-color 0.15s, color 0.15s; + flex-shrink: 0; +} +.chat-menu-btn:hover { background: var(--bg4); border-color: var(--border2); color: var(--text); } + +/* Model badge */ +.topbar-model-badge { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px 4px 8px; + max-width: 220px; } -.chat-menu-btn:hover { - background: var(--bg4); +.topbar-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text3); + flex-shrink: 0; + transition: background 0.3s, box-shadow 0.3s; +} +.topbar-status-dot.connected { + background: var(--success); + box-shadow: 0 0 6px rgba(48, 209, 88, 0.5); +} + +.topbar-model-name { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +/* Token summary in center */ +.chat-token-summary { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.7rem; + color: var(--text3); + font-variant-numeric: tabular-nums; +} +.chat-token-summary span { + background: var(--bg3); + border: 1px solid var(--border); + padding: 2px 8px; + border-radius: 999px; +} +.chat-token-summary .token-total { + color: var(--text2); border-color: var(--border2); } -.chat-token-summary { - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 0.72rem; - color: var(--text2); - font-variant-numeric: tabular-nums; -} - -.chat-token-summary span { - background: var(--bg4); - border: 1px solid var(--border); - padding: 3px 10px; - border-radius: 20px; -} - -.tps-badge { - font-size: 0.72rem; - background: var(--bg4); - border: 1px solid var(--border); - padding: 3px 10px; - border-radius: 20px; - color: var(--success); - font-variant-numeric: tabular-nums; -} - -.toggle-label { +/* Live token counter */ +.live-token-counter { display: flex; align-items: center; gap: 5px; + background: rgba(72, 166, 255, 0.08); + border: 1px solid rgba(72, 166, 255, 0.2); + border-radius: 999px; + padding: 3px 10px 3px 8px; + font-size: 0.7rem; + color: var(--accent); + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.live-token-pulse { + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; + animation: token-pulse 1s ease-in-out infinite; + flex-shrink: 0; +} +@keyframes token-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} + +/* TPS badge */ +.tps-badge { + font-size: 0.7rem; + background: rgba(48, 209, 88, 0.08); + border: 1px solid rgba(48, 209, 88, 0.2); + padding: 3px 9px; + border-radius: 999px; + color: var(--success); + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +/* Memory icon button */ +.topbar-icon-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text3); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.topbar-icon-btn:hover, +.topbar-icon-btn.active { + background: var(--bg3); + border-color: var(--border2); + color: var(--accent); +} +.memory-count-badge { + position: absolute; + top: -4px; + right: -4px; + background: var(--accent); + color: #fff; + font-size: 9px; + font-weight: 700; + border-radius: 999px; + min-width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + line-height: 1; +} + +/* Toggle pills */ +.topbar-toggles { + display: flex; + align-items: center; + gap: 4px; +} +.toggle-pill { + font-size: 0.7rem; + font-weight: 600; + font-family: inherit; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + letter-spacing: 0.01em; +} +.toggle-pill.on { + background: rgba(72, 166, 255, 0.12); + border-color: rgba(72, 166, 255, 0.35); + color: var(--accent); +} +.toggle-pill.off { + background: var(--bg3); + border-color: var(--border); + color: var(--text3); +} +.toggle-pill:hover { opacity: 0.8; } + +/* ── Memory Modal ── */ +.memory-modal-overlay { + position: absolute; + inset: 48px 0 0 0; + background: rgba(10, 16, 22, 0.6); + backdrop-filter: blur(4px); + z-index: 10; + display: flex; + align-items: flex-start; + justify-content: flex-end; + padding: 12px 16px 0; +} + +.memory-modal { + width: min(420px, 94vw); + max-height: calc(100vh - 120px); + background: var(--bg2); + border: 1px solid var(--border2); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.memory-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.memory-modal-title { + display: flex; + align-items: center; + gap: 7px; + color: var(--text); + font-size: 0.85rem; + font-weight: 600; + flex: 1; +} +.memory-modal-subtitle { + font-size: 0.7rem; + color: var(--text3); +} +.memory-modal-close { + background: none; + border: none; + color: var(--text3); + cursor: pointer; + font-size: 12px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} +.memory-modal-close:hover { background: var(--bg4); color: var(--text); } + +.memory-modal-body { + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.memory-empty { + padding: 24px 16px; + text-align: center; + color: var(--text3); + font-size: 0.82rem; + line-height: 1.6; +} +.memory-item { + display: flex; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg3); + align-items: flex-start; +} +.memory-item.user { border-color: rgba(72,166,255,0.15); } +.memory-item.assistant { border-color: var(--border); } +.memory-item-role { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; + margin-top: 1px; +} +.memory-item.user .memory-item-role { + background: rgba(72,166,255,0.12); + color: var(--accent); +} +.memory-item.assistant .memory-item-role { + background: rgba(255,255,255,0.06); + color: var(--text3); +} +.memory-item-content { font-size: 0.78rem; color: var(--text2); - cursor: pointer; - user-select: none; + line-height: 1.5; + word-break: break-word; } -.toggle-label input { cursor: pointer; accent-color: var(--accent); } -/* Messages */ +/* ── Messages area (legacy — kept for responsive override) ── */ .messages-area { flex: 1; overflow-y: auto; - padding: 24px 20px; + padding: 28px 20px; display: flex; flex-direction: column; - gap: 18px; + gap: 20px; } -/* Empty state */ +/* ── Empty state ── */ .empty-state { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - color: var(--text2); - text-align: center; padding: 40px; } .empty-state-panel { width: min(100%, 680px); - padding: 32px; + padding: 36px 32px; border: 1px solid var(--border); border-radius: 24px; background: - radial-gradient(circle at top, rgba(72, 166, 255, 0.16), transparent 38%), + radial-gradient(circle at top, rgba(72, 166, 255, 0.14), transparent 40%), linear-gradient(180deg, rgba(20, 32, 45, 0.95), rgba(10, 16, 22, 0.96)); box-shadow: var(--shadow-lg); display: flex; flex-direction: column; align-items: center; gap: 16px; + text-align: center; } .empty-icon { font-size: 2.5rem; } - .empty-kicker { - font-size: 0.74rem; + font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); } - -.empty-state h2 { font-size: 1.8rem; color: var(--text); font-weight: 700; max-width: 14ch; } -.empty-state p { font-size: 0.92rem; max-width: 54ch; line-height: 1.7; } +.empty-state h2 { font-size: 1.8rem; color: var(--text); font-weight: 700; max-width: 14ch; line-height: 1.25; } +.empty-state p { font-size: 0.9rem; max-width: 52ch; line-height: 1.7; color: var(--text2); } .empty-state-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; + margin-top: 4px; } .starter-chip { border: 1px solid var(--border2); background: rgba(15, 23, 33, 0.85); color: var(--text); - padding: 10px 14px; + padding: 9px 14px; border-radius: 999px; cursor: pointer; font: inherit; - font-size: 0.84rem; - transition: transform 0.12s ease, border-color 0.15s ease, background 0.15s ease; + font-size: 0.83rem; + transition: transform 0.12s ease, border-color 0.15s, background 0.15s; } - .starter-chip:hover { border-color: rgba(72, 166, 255, 0.5); background: rgba(20, 32, 45, 0.98); transform: translateY(-1px); } +/* Composer notice */ .composer-notice { width: 100%; display: flex; @@ -159,81 +472,64 @@ gap: 14px; margin-bottom: 12px; padding: 14px 16px; - border-radius: 16px; + border-radius: 14px; border: 1px solid var(--border2); background: rgba(20, 32, 45, 0.9); } - -.composer-notice strong { - display: block; - color: var(--text); - margin-bottom: 4px; -} - -.composer-notice p { - color: var(--text2); - font-size: 0.84rem; - line-height: 1.5; -} - -.composer-notice.warning { - border-color: rgba(255, 214, 10, 0.32); - background: rgba(72, 55, 4, 0.24); -} - -.composer-notice.info { - border-color: rgba(72, 166, 255, 0.3); - background: rgba(16, 38, 63, 0.32); -} +.composer-notice strong { display: block; color: var(--text); margin-bottom: 3px; font-size: 0.88rem; } +.composer-notice p { color: var(--text2); font-size: 0.82rem; line-height: 1.5; } +.composer-notice.warning { border-color: rgba(255,214,10,0.3); background: rgba(72,55,4,0.22); } +.composer-notice.info { border-color: rgba(72,166,255,0.28); background: rgba(16,38,63,0.3); } .notice-action-btn { border: 1px solid var(--border2); background: var(--bg3); color: var(--text); - border-radius: 12px; - padding: 10px 14px; + border-radius: 10px; + padding: 9px 13px; font: inherit; font-size: 0.82rem; white-space: nowrap; cursor: pointer; transition: background 0.15s, border-color 0.15s; + flex-shrink: 0; } +.notice-action-btn:hover { background: var(--bg4); border-color: rgba(72,166,255,0.4); } -.notice-action-btn:hover { - background: var(--bg4); - border-color: rgba(72, 166, 255, 0.45); -} - -/* Message wrappers */ +/* ── Message wrappers ── */ .msg-wrapper { display: flex; flex-direction: column; - max-width: 780px; + max-width: 800px; width: 100%; } -.msg-wrapper.user { align-self: flex-end; align-items: flex-end; } -.msg-wrapper.assistant { align-self: flex-start; align-items: flex-start; } +.msg-wrapper.user { align-self: flex-end; align-items: flex-end; } +.msg-wrapper.assistant { align-self: flex-start; align-items: flex-start; width: 100%; max-width: 100%; } -/* Bubbles */ +/* ── Bubbles ── */ .msg-bubble { - padding: 12px 16px; - border-radius: var(--radius); + border-radius: var(--radius-lg); font-size: 0.9rem; - line-height: 1.7; + line-height: 1.72; word-break: break-word; + max-width: min(100%, 72ch); + box-shadow: 0 10px 26px rgba(7, 12, 18, 0.18); } .msg-wrapper.user .msg-bubble { - background: var(--user-bg); - border: 1px solid var(--user-border); - border-bottom-right-radius: 3px; + background: linear-gradient(145deg, rgba(12, 34, 58, 0.98), rgba(20, 54, 92, 0.98)); + border: 1px solid rgba(72, 166, 255, 0.28); + border-bottom-right-radius: 4px; white-space: pre-wrap; + color: #f5f9ff; + box-shadow: 0 12px 28px rgba(6, 14, 24, 0.32); } .msg-wrapper.assistant .msg-bubble { - background: var(--asst-bg); - border: 1px solid var(--asst-border); - border-bottom-left-radius: 3px; + background: linear-gradient(180deg, rgba(15, 23, 33, 0.98), rgba(12, 19, 29, 0.98)); + border: 1px solid rgba(52, 71, 89, 0.82); + border-bottom-left-radius: 4px; + color: #eef5ff; } /* Markdown inside bubbles */ @@ -248,7 +544,7 @@ .msg-bubble h3 { font-size: 1em; } .msg-bubble p { margin: 0.4em 0; } .msg-bubble p:first-child { margin-top: 0; } -.msg-bubble p:last-child { margin-bottom: 0; } +.msg-bubble p:last-child { margin-bottom: 0; } .msg-bubble ul, .msg-bubble ol { padding-left: 1.4em; margin: 0.4em 0; } .msg-bubble li { margin: 0.2em 0; } .msg-bubble blockquote { @@ -261,10 +557,10 @@ .msg-bubble table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.85em; } .msg-bubble th, .msg-bubble td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; } .msg-bubble th { background: var(--bg3); font-weight: 600; } -.msg-bubble a { color: #7c9ef8; text-decoration: underline; } -.msg-bubble hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; } +.msg-bubble a { color: #7c9ef8; text-decoration: underline; } +.msg-bubble hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; } .msg-bubble strong { font-weight: 700; } -.msg-bubble em { font-style: italic; } +.msg-bubble em { font-style: italic; } /* Inline code */ .msg-bubble code:not(.hljs) { @@ -283,7 +579,6 @@ overflow: hidden; border: 1px solid var(--border); } - .msg-bubble .code-header { display: flex; align-items: center; @@ -292,9 +587,7 @@ padding: 5px 12px; font-size: 0.72rem; } - .msg-bubble .code-lang { color: var(--text3); font-family: monospace; } - .msg-bubble .copy-code-btn { background: none; border: 1px solid var(--border2); @@ -307,7 +600,6 @@ font-family: inherit; } .msg-bubble .copy-code-btn:hover { background: var(--bg4); color: var(--text); } - .msg-bubble pre { margin: 0; overflow-x: auto; } .msg-bubble pre code { display: block; @@ -317,163 +609,389 @@ line-height: 1.55; } -/* Thinking */ +/* ── Thinking bubble (loading dots) ── */ .thinking { display: flex; + align-items: center; gap: 5px; - padding: 14px 18px; - background: var(--asst-bg); - border: 1px solid var(--asst-border); - border-radius: var(--radius); - border-bottom-left-radius: 3px; + padding: 13px 18px; width: fit-content; } .thinking span { - width: 7px; height: 7px; - background: var(--text3); + width: 8px; + height: 8px; + background: #9fd0ff; border-radius: 50%; - animation: bounce 1.2s infinite; + opacity: 0.5; + animation: thinking-bounce 1.4s ease-in-out infinite; } -.thinking span:nth-child(2) { animation-delay: 0.2s; } -.thinking span:nth-child(3) { animation-delay: 0.4s; } -@keyframes bounce { - 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } - 40% { transform: translateY(-5px); opacity: 1; } +.thinking span:nth-child(2) { animation-delay: 0.16s; } +.thinking span:nth-child(3) { animation-delay: 0.32s; } +@keyframes thinking-bounce { + 0%, 80%, 100% { transform: translateY(0); opacity: 0.3; } + 40% { transform: translateY(-6px); opacity: 1; } } -.live-reasoning-panel { - margin-bottom: 8px; +@media (prefers-reduced-motion: reduce) { + .thinking span, + .msg-bubble, + .queue-clear-btn, + .notice-action-btn, + .send-btn, + .stop-btn { + animation: none; + transition: none; + } } -/* Input area */ +/* ── Queue indicator ── */ +.queue-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 20px; + background: rgba(72, 166, 255, 0.07); + border-top: 1px solid rgba(72, 166, 255, 0.15); + font-size: 0.75rem; + color: var(--accent); + flex-shrink: 0; +} +.queue-indicator-pulse { + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; + animation: token-pulse 1s ease-in-out infinite; + flex-shrink: 0; +} +.queue-indicator-text { flex: 1; font-weight: 500; } +.queue-clear-btn { + background: none; + border: 1px solid rgba(72, 166, 255, 0.25); + color: var(--text3); + border-radius: var(--radius-sm); + padding: 2px 8px; + font: inherit; + font-size: 0.7rem; + cursor: pointer; + transition: all 0.15s; +} +.queue-clear-btn:hover { border-color: var(--danger); color: var(--danger); background: rgba(240,64,64,0.06); } + +/* ── Input area ── */ .input-area { - padding: 12px 20px 14px; + padding: 10px 16px 14px; background: var(--bg2); border-top: 1px solid var(--border); + flex-shrink: 0; } -.input-row { +/* ── Input wrapper (transparent container) ── */ +.input-wrapper { display: flex; + flex-direction: column; + width: 100%; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +/* Model capabilities — floating above the pill */ +.model-capabilities { + display: flex; + align-items: center; gap: 8px; + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 8px; + padding-left: 12px; + user-select: none; +} +.capability-group { + display: inline-flex; + align-items: center; + gap: 6px; +} +.capability-group .label { + font-weight: 500; + letter-spacing: 0.05em; +} +.capability-group svg { + --phosphor-color-primary: rgba(255, 255, 255, 0.35); + --phosphor-color-secondary: rgba(255, 255, 255, 0.15); +} +.separator { + opacity: 0.4; +} + +/* The typing pill — the ONLY element with background */ +.input-box { + display: flex; align-items: flex-end; + gap: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + padding: 10px 16px; + transition: border-color 0.2s; +} +.input-box:focus-within { + border-color: rgba(255, 255, 255, 0.25); } -.input-textarea { - flex: 1; - background: var(--bg3); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text); - font-size: 0.9rem; - font-family: inherit; - padding: 10px 14px; - resize: none; - min-height: 44px; - max-height: 200px; - outline: none; - line-height: 1.5; - transition: border-color 0.15s; +/* Attachment action buttons */ +.attachment-actions { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 4px; + flex-shrink: 0; } -.input-textarea:focus { border-color: var(--accent); } -.input-textarea:disabled { opacity: 0.5; } - -.send-btn { - background: var(--accent); - color: #fff; +.attachment-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; border: none; - border-radius: var(--radius); - padding: 10px 18px; - font-size: 0.88rem; - font-weight: 600; + border-radius: 8px; + cursor: not-allowed; + opacity: 0.5; + color: rgba(255, 255, 255, 0.4); + padding: 0; +} +.attachment-actions button svg { + --phosphor-color-primary: currentColor; + --phosphor-color-secondary: rgba(255, 255, 255, 0.2); +} + +/* Textarea inside the pill */ +.input-box textarea { + flex: 1; + align-self: center; + background: transparent; + border: none; + color: #fff; + font-size: 15px; + line-height: 1.5; + font-family: inherit; + resize: none; + outline: none; + min-height: 24px; + max-height: 200px; + padding: 8px 0; + margin: 0; +} +.input-box textarea::placeholder { color: rgba(255, 255, 255, 0.35); } +.input-box textarea:disabled { opacity: 0.45; } + +/* Meta row below the pill */ +.input-meta-row { + display: flex; + align-items: center; + padding: 6px 12px 0 12px; +} +.input-meta-left { + display: flex; + align-items: center; + gap: 8px; +} +.token-estimate { + font-size: 0.68rem; + color: var(--text3); + font-variant-numeric: tabular-nums; +} +.queue-hint { + font-size: 0.68rem; + color: var(--accent); + font-weight: 500; +} + +/* ── Queue Popover ── */ +.queue-container { + position: relative; + margin-top: 8px; + padding-left: 12px; +} + +.queue-toggle { + background: transparent; + border: none; + color: #3b82f6; + font-size: 12px; + font-weight: 500; cursor: pointer; - height: 44px; - white-space: nowrap; - transition: background 0.15s, transform 0.1s; + padding: 4px 8px; + border-radius: 6px; + transition: background 0.2s; font-family: inherit; } -.send-btn:hover:not(:disabled) { background: var(--accent2); } -.send-btn:active:not(:disabled) { transform: scale(0.97); } -.send-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.queue-toggle:hover { + background: rgba(59, 130, 246, 0.1); +} + +.queue-popover { + position: absolute; + bottom: 100%; + left: 12px; + margin-bottom: 12px; + width: 320px; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5); + overflow: hidden; + z-index: 100; + animation: popoverFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.queue-header { + padding: 12px 16px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.35); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.02); +} + +.queue-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 200px; + overflow-y: auto; +} + +.queue-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-size: 14px; + color: rgba(255, 255, 255, 0.75); +} +.queue-item:last-child { + border-bottom: none; +} + +.queue-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + padding-right: 12px; +} + +.queue-remove { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.25); + cursor: pointer; + border-radius: 6px; + transition: all 0.2s ease; + flex-shrink: 0; + font-size: 14px; + line-height: 1; +} +.queue-remove:hover { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} + +@keyframes popoverFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Send button — round, inside the pill */ +.send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.5); + border: none; + cursor: default; + pointer-events: none; + transition: background 0.2s, transform 0.2s, opacity 0.2s; + padding: 0; + flex-shrink: 0; + margin-bottom: 2px; +} +.send-btn svg { + --phosphor-color-primary: rgba(255, 255, 255, 0.5); + --phosphor-color-secondary: rgba(255, 255, 255, 0.15); + display: block; +} +.send-btn:not(:disabled) { + background: var(--text, #eef5ff); + cursor: pointer; + pointer-events: auto; +} +.send-btn:not(:disabled) svg { + --phosphor-color-primary: var(--bg, #0a1016); + --phosphor-color-secondary: rgba(0, 0, 0, 0.2); +} +.send-btn:not(:disabled):hover { + transform: scale(1.08); + opacity: 0.9; +} +.send-btn:not(:disabled):active { + transform: scale(0.95); +} .stop-btn { background: var(--bg4); color: var(--danger); - border: 1px solid var(--danger); - border-radius: var(--radius); - padding: 10px 18px; - font-size: 0.88rem; + border: 1px solid rgba(240, 64, 64, 0.35); + border-radius: 999px; + padding: 6px 14px; + font-size: 0.8rem; font-weight: 600; cursor: pointer; - height: 44px; white-space: nowrap; font-family: inherit; transition: background 0.15s; + flex-shrink: 0; + margin-bottom: 2px; } -.stop-btn:hover { background: rgba(240,64,64,0.1); } - -.input-meta { - display: flex; - align-items: center; - margin-top: 5px; - padding: 0 2px; -} - -.char-count { font-size: 0.7rem; color: var(--text3); margin-left: auto; } +.stop-btn:hover { background: rgba(240, 64, 64, 0.1); } +/* ── Responsive ── */ @media (max-width: 960px) { - .chat-menu-btn { - display: inline-flex; - } + .chat-menu-btn { display: inline-flex; } .chat-topbar { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .chat-topbar-left, - .chat-topbar-right { - width: 100%; - } - - .chat-topbar-right { - justify-content: space-between; + height: auto; + padding: 8px 12px; flex-wrap: wrap; + gap: 8px; } - .messages-area { - padding: 20px 14px; - } + .chat-topbar-left, .chat-topbar-right { flex: 1; } + .chat-topbar-center { order: 3; width: 100%; justify-content: flex-start; } - .empty-state { - padding: 20px 14px; - } + .messages-container { padding: 20px 16px 0 16px; gap: 24px; } + .scroll-spacer { height: 100px; } + .input-content-constrained { padding: 0 12px 20px 12px; } - .empty-state-panel { - padding: 24px 18px; - border-radius: 20px; - } - - .empty-state h2 { - font-size: 1.45rem; - } - - .composer-notice { - flex-direction: column; - align-items: flex-start; - } - - .input-area { - padding: 12px 14px 16px; - } - - .input-row { - flex-direction: column; - align-items: stretch; - } - - .send-btn, - .stop-btn { - width: 100%; - } + .empty-state { padding: 20px 12px; } + .empty-state-panel { padding: 24px 18px; border-radius: 20px; } + .empty-state h2 { font-size: 1.45rem; } + .composer-notice { flex-direction: column; align-items: flex-start; } } diff --git a/tauri-app/src/components/ChatView.tsx b/tauri-app/src/components/ChatView.tsx index b930451..b015558 100644 --- a/tauri-app/src/components/ChatView.tsx +++ b/tauri-app/src/components/ChatView.tsx @@ -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(null); const [useStream, setUseStream] = useState(true); const [useMemory, setUseMemory] = useState(true); + const [streamTokenCount, setStreamTokenCount] = useState(0); + const [messageQueue, setMessageQueue] = useState([]); + const [showMemoryModal, setShowMemoryModal] = useState(false); + const [isQueueOpen, setIsQueueOpen] = useState(false); + const messagesEndRef = useRef(null); const textareaRef = useRef(null); const abortRef = useRef(null); const activeChatIdRef = useRef(chatId); const pendingRequestChatIdRef = useRef(null); const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {}); + const messageQueueRef = useRef([]); + const sendRef = useRef<(text: string, fromChatId?: number) => Promise>(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((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((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( "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 (
- {/* Top bar */} + {/* ── Top bar ── */}
{onOpenSidebar && ( - )} - {chatId !== null && chatTotals.total_tokens > 0 && ( -
- Sent {chatTotals.prompt_tokens} - Generated {chatTotals.completion_tokens} - Total {chatTotals.total_tokens} + {model && ( +
+
+ {modelShort}
)} - {tps !== null && ( - {tps.toFixed(1)} tok/s +
+ +
+ {chatId !== null && chatTotals.total_tokens > 0 && ( +
+ ↑ {chatTotals.prompt_tokens.toLocaleString()} + ↓ {chatTotals.completion_tokens.toLocaleString()} + Σ {chatTotals.total_tokens.toLocaleString()} +
)}
+
- - + {generating && streamTokenCount > 0 && ( +
+ + {streamTokenCount.toLocaleString()} tokens +
+ )} + {tps !== null && !generating && ( + {tps.toFixed(1)} tok/s + )} + + + +
+ + +
- {/* Messages */} -
- {chatId === null && messages.length === 0 && ( -
-
-
⚡
- Local-first coding workspace -

Start with a prompt that moves the project forward.

-

- ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect, - iterate, and debug without losing context. -

-
- {starterPrompts.map((prompt) => ( - - ))} + {/* ── Memory Modal ── */} + {showMemoryModal && ( +
setShowMemoryModal(false)}> +
e.stopPropagation()}> +
+
+ + + + + + Memory Context
+ + {memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode + + +
+
+ {memoryMessages.length === 0 ? ( +
No messages in context yet. Start a conversation to populate memory.
+ ) : ( + memoryMessages.map((msg, i) => ( +
+ {msg.role} + + {msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content} + +
+ )) + )} +
+
+
+ )} + + {/* ── Chat Layout ── */} +
+ {/* Scrollable message area */} +
+
+ {chatId === null && messages.length === 0 && ( +
+
+
⚡
+ Local-first AI workspace +

Start with a prompt that moves the project forward.

+

+ ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect, + iterate, and debug without losing context. +

+
+ {starterPrompts.map((prompt) => ( + + ))} +
+ {composerNotice && ( +
+
+ {composerNotice.title} +

{composerNotice.description}

+
+ {onOpenSidebar && ( + + )} +
+ )} +
+
+ )} + + {messages.map((msg) => ( + + ))} + + {/* Live streaming bubble */} + {generating && (streamReasoning || streamContent || streamThinkingOpen) && ( +
+
+ {(streamReasoning || streamThinkingOpen) && ( +
setShowLiveReasoning(event.currentTarget.open)} + > + + + + {streamReasoning && ( +
+ )} +
+ )} + + {streamContent && ( +
+ )} + + {!streamContent && streamThinkingOpen && ( +
+ +
+ )} +
+
+ )} + + {generating && !streamReasoning && !streamContent && !streamThinkingOpen && ( +
+
+ +
+
+ )} + +
+
+
+
+ + {/* ── Message Queue indicator ── */} + {messageQueue.length > 0 && ( +
+ + + {messageQueue.length} message{messageQueue.length > 1 ? "s" : ""} queued + + +
+ )} + + {/* ── Fixed Input ── */} +
+
+
+
{composerNotice && (
@@ -407,107 +608,107 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea )}
)} -
-
- )} - {messages.map((msg) => ( - - ))} + {/* Model capabilities — floating above the pill */} +
+ + In + + + + · + + Out + + +
- {/* Live streaming bubble */} - {generating && (streamReasoning || streamContent || streamThinkingOpen) && ( -
- {(streamReasoning || streamThinkingOpen) && ( -
- - {showLiveReasoning && streamReasoning && ( -
+ {/* The typing pill */} +
+
+ + + +
+ +