feat: integrate desktop runtime and root workspace updates
This commit is contained in:
1 parent
f5a4a9ee97
commit
168623c459
93 files changed
+19540
-1208
No files matched your search
Submodule
+1
Submodule .claude/worktrees/vibrant-austin added at d78012373a.
@@ -1,243 +1,98 @@
|
||||
# ForgeLM
|
||||
|
||||
High-performance local inference runtime for small and mid-sized language models, designed for efficient execution on commodity hardware.
|
||||
ForgeLM e um workspace Rust para inferencia local de LLMs com tres superficies principais:
|
||||
|
||||
## Overview
|
||||
- uma API HTTP local compativel com partes da API de chat da OpenAI
|
||||
- uma CLI (`localllm`) para operacao, diagnostico e automacao
|
||||
- um app desktop Tauri para runtime management e chat persistente
|
||||
|
||||
ForgeLM is a local-first inference runtime built for developers who need a clean, controllable, and efficient backend for running quantized language models on consumer GPUs and constrained systems.
|
||||
O projeto hoje e organizado para rodar localmente, com foco em modelos `.gguf`, operacao local-first e uma ponte entre backend Rust, CLI e interface desktop.
|
||||
|
||||
The project is centered on a practical engineering goal: extracting high throughput and low latency from compact and mid-sized models without turning the runtime into an opaque monolith or an overextended product shell.
|
||||
## O que existe hoje
|
||||
|
||||
The initial focus is on text-only inference for models in the range of Qwen 4B / 9B class deployments, with special attention to NVIDIA GPUs such as the RTX 3070 and to fallback paths for weaker hardware.
|
||||
- `crates/inference-server`: servidor HTTP com `GET /health`, `GET /v1/models` e `POST /v1/chat/completions`
|
||||
- `crates/localllm-cli`: CLI para status, modelos, auth, config, benchmark, logs e doctor
|
||||
- `tauri-app`: app desktop com runtime manager, chat UI, armazenamento local e integracao com o backend
|
||||
- `crates/api-contracts`: contratos de request/response compartilhados
|
||||
- `crates/inference-core`: engine, perfis de runtime e pipeline de inferencia
|
||||
|
||||
ForgeLM is not intended to be a desktop UI product. It is intended to be a modular inference backend that can be embedded, extended, benchmarked, and reasoned about as infrastructure.
|
||||
## Inicio rapido
|
||||
|
||||
---
|
||||
### 1. Subir a API local
|
||||
|
||||
## Design Goals
|
||||
Defina pelo menos o caminho do modelo e execute o servidor:
|
||||
|
||||
ForgeLM is built around a small number of non-negotiable goals:
|
||||
```bash
|
||||
set LOCALLLM_MODEL=D:\caminho\modelo.gguf
|
||||
cargo run -p inference-server
|
||||
```
|
||||
|
||||
- **High throughput**
|
||||
- Prioritize sustained token generation performance for small and mid-sized models.
|
||||
Validacoes basicas:
|
||||
|
||||
- **Low latency**
|
||||
- Minimize time-to-first-token and reduce generation overhead in the hot path.
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/health
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
```
|
||||
|
||||
- **Efficient memory usage**
|
||||
- Make disciplined use of VRAM and RAM through careful model loading, batching strategy, and KV-cache policies.
|
||||
### 2. Usar a CLI
|
||||
|
||||
- **Predictable architecture**
|
||||
- Keep the inference path understandable, inspectable, and modular.
|
||||
```bash
|
||||
cargo run -p localllm-cli -- status
|
||||
cargo run -p localllm-cli -- models list
|
||||
```
|
||||
|
||||
- **Hardware realism**
|
||||
- Optimize for hardware that developers actually own, including 8 GB consumer GPUs and CPU fallback scenarios.
|
||||
### 3. Abrir o app desktop
|
||||
|
||||
- **Separation of concerns**
|
||||
- Keep the inference core, API layer, model registry, and tooling clearly separated.
|
||||
```bash
|
||||
cd tauri-app
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
- **Open integration surface**
|
||||
- Provide a clean local API compatible with common inference client patterns.
|
||||
No app, abra o Runtime Manager, configure modo local ou remoto e depois use o chat.
|
||||
|
||||
---
|
||||
## Requisitos
|
||||
|
||||
## Project Scope
|
||||
- Rust toolchain atual para o workspace
|
||||
- Node.js/NPM para o app Tauri
|
||||
- um modelo `.gguf` local para o runtime
|
||||
- GPU NVIDIA e opcional; o projeto tambem possui perfil CPU fallback
|
||||
|
||||
ForgeLM focuses on the runtime layer of local language model serving.
|
||||
## Estrutura do repositorio
|
||||
|
||||
### In scope
|
||||
- `crates/api-contracts/` - contratos HTTP e tipos compartilhados
|
||||
- `crates/inference-core/` - engine, sessoes, sampler e perfis de runtime
|
||||
- `crates/inference-server/` - servidor HTTP local
|
||||
- `crates/localllm-cli/` - CLI de operacao
|
||||
- `crates/metrics/` - contadores e medicao de throughput
|
||||
- `crates/llama-sys/` - bindings/integracao de baixo nivel
|
||||
- `tauri-app/` - app desktop Tauri + frontend React
|
||||
- `docs/` - documentacao detalhada
|
||||
|
||||
- Local text inference
|
||||
- GGUF-based model execution
|
||||
- High-performance inference backends
|
||||
- OpenAI-compatible HTTP API
|
||||
- Streaming responses via SSE
|
||||
- Runtime tuning for throughput and memory efficiency
|
||||
- Model registry and download workflow
|
||||
- Benchmarking and observability
|
||||
## Documentacao
|
||||
|
||||
### Out of scope for the initial versions
|
||||
- `docs/README.md` - indice da documentacao
|
||||
- `docs/api/overview.md` - visao geral do backend e da API
|
||||
- `docs/api/endpoints.md` - referencia HTTP
|
||||
- `docs/api/configuration.md` - variaveis de ambiente e configuracao do servidor
|
||||
- `docs/cli/overview.md` - visao geral da CLI
|
||||
- `docs/cli/commands.md` - referencia de comandos e exemplos
|
||||
- `docs/app/overview.md` - visao geral do app desktop
|
||||
- `docs/app/user-guide.md` - guia de uso do app
|
||||
- `docs/app/backend-commands.md` - comandos Tauri expostos pelo backend do app
|
||||
- `docs/developer/architecture.md` - arquitetura do workspace e fluxos entre modulos
|
||||
|
||||
- Full desktop product UX
|
||||
- Multimodal inference
|
||||
- Enterprise orchestration features
|
||||
- Training and fine-tuning workflows
|
||||
- Plugin-heavy product abstractions
|
||||
- Broad feature surface at the expense of runtime efficiency
|
||||
## Estado atual e limitacoes
|
||||
|
||||
---
|
||||
- A API atual expoe apenas tres endpoints publicos: `GET /health`, `GET /v1/models` e `POST /v1/chat/completions`.
|
||||
- A compatibilidade com OpenAI e parcial: o projeto foca no fluxo de chat, nao em toda a superficie da plataforma.
|
||||
- A CLI possui uma superficie ampla, mas a execucao ao vivo hoje esta mais consolidada em `status`, `models list` e `bench throughput`.
|
||||
- O app desktop controla o runtime local no modo `local` e pode consumir uma API ja existente no modo `remote`.
|
||||
- O armazenamento do chat no frontend usa `sql.js` carregado por CDN e persistencia em `localStorage`.
|
||||
|
||||
## Core Principles
|
||||
## Leitura recomendada
|
||||
|
||||
### 1. The hot path must remain small
|
||||
|
||||
The token generation path is where performance is won or lost. ForgeLM keeps this path narrow:
|
||||
|
||||
- model execution in a native backend
|
||||
- Rust in the serving and orchestration layer
|
||||
- no Python in the inference hot path
|
||||
- controlled allocation and bounded runtime overhead
|
||||
|
||||
### 2. Performance comes before feature breadth
|
||||
|
||||
A runtime that supports everything but performs poorly is not useful for the target use case. ForgeLM deliberately starts with a constrained feature set in order to produce measurable runtime efficiency first.
|
||||
|
||||
### 3. Public architecture should remain understandable
|
||||
|
||||
The repository is intended to be inspectable by engineers. The design should be explainable without product-language shortcuts, hidden runtime behavior, or unnecessary framework complexity.
|
||||
|
||||
### 4. Consumer hardware is a first-class target
|
||||
|
||||
Many local inference projects are evaluated on unrealistic hardware assumptions. ForgeLM explicitly targets practical systems, including 8 GB GPUs and weaker fallback environments.
|
||||
|
||||
---
|
||||
|
||||
## Technical Direction
|
||||
|
||||
ForgeLM is structured as a layered system.
|
||||
|
||||
### Inference Core
|
||||
|
||||
The inference core is built around a native execution backend for quantized GGUF models. The initial backend strategy is centered on `llama.cpp`-class execution, selected for its strong hardware coverage, mature quantized model support, and practical performance characteristics.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- model loading
|
||||
- tokenizer access
|
||||
- context management
|
||||
- decode loop execution
|
||||
- GPU offloading control
|
||||
- sampling configuration
|
||||
- KV-cache handling
|
||||
|
||||
### Serving Layer
|
||||
|
||||
The HTTP serving layer is implemented in Rust and is responsible for:
|
||||
|
||||
- request validation
|
||||
- session orchestration
|
||||
- streaming token delivery
|
||||
- concurrency control
|
||||
- backpressure
|
||||
- metrics and health endpoints
|
||||
|
||||
This layer exists to expose the runtime cleanly without polluting the core execution path.
|
||||
|
||||
### Model Registry
|
||||
|
||||
Model search, metadata resolution, and download management are intentionally isolated from the hot path. Registry operations may use a separate process and different tooling, but they do not participate in token generation.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- model discovery
|
||||
- metadata normalization
|
||||
- artifact download
|
||||
- integrity verification
|
||||
- local registry indexing
|
||||
|
||||
### Tooling and Benchmarks
|
||||
|
||||
Performance claims without reproducible measurement are not useful. Benchmarking and observability are part of the project rather than afterthoughts.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- throughput measurement
|
||||
- latency measurement
|
||||
- memory usage tracking
|
||||
- benchmark replay
|
||||
- runtime profiling
|
||||
|
||||
---
|
||||
|
||||
## Initial Optimization Target
|
||||
|
||||
The first optimization target is the practical deployment envelope of small and mid-sized text models on commodity NVIDIA hardware.
|
||||
|
||||
That means prioritizing:
|
||||
|
||||
- compact and mid-sized Qwen-class models
|
||||
- strong throughput on RTX 3070-class systems
|
||||
- viable degraded execution on weaker hardware
|
||||
- text-only serving before broader capability expansion
|
||||
|
||||
In practical terms, the project is designed to explore the trade space between:
|
||||
|
||||
- model size
|
||||
- quantization level
|
||||
- context length
|
||||
- GPU offloading depth
|
||||
- batching strategy
|
||||
- latency
|
||||
- aggregate throughput
|
||||
- output quality
|
||||
|
||||
---
|
||||
|
||||
## Architectural Priorities
|
||||
|
||||
The implementation roadmap is shaped by the following priorities:
|
||||
|
||||
### Text-only first
|
||||
|
||||
Text inference is the fastest path to a stable and high-performance serving core. Additional modalities, if ever added, must not distort the original runtime goals.
|
||||
|
||||
### Single-model efficiency before multi-model orchestration
|
||||
|
||||
Before adding model multiplexing or dynamic model switching, the runtime must demonstrate excellent single-model performance and stable serving behavior.
|
||||
|
||||
### Controlled concurrency before maximum concurrency
|
||||
|
||||
Concurrency is useful only when it improves real system throughput without collapsing latency or wasting memory. The project favors disciplined scheduling over superficial parallelism.
|
||||
|
||||
### Measured optimization over speculative abstraction
|
||||
|
||||
Every substantial optimization should be benchmarked. Every abstraction in the serving path should justify its cost.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Stack
|
||||
|
||||
The current technical direction is:
|
||||
|
||||
### Native backend
|
||||
- C/C++
|
||||
- `llama.cpp`-class runtime backend
|
||||
- CUDA path for NVIDIA acceleration
|
||||
|
||||
### Serving layer
|
||||
- Rust
|
||||
- Tokio
|
||||
- Axum
|
||||
- Hyper
|
||||
- FFI bindings generated with `bindgen`
|
||||
|
||||
### Registry and model management
|
||||
- Python
|
||||
- isolated background process or service
|
||||
- Hugging Face integration for model discovery and retrieval
|
||||
|
||||
### UI
|
||||
- minimal local web interface for runtime validation
|
||||
- not a product-grade desktop shell
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```text
|
||||
forgelm/
|
||||
├── Cargo.toml
|
||||
├── crates/
|
||||
│ ├── backend-sys/
|
||||
│ ├── inference-core/
|
||||
│ ├── inference-server/
|
||||
│ ├── api-contracts/
|
||||
│ └── metrics/
|
||||
├── registry/
|
||||
│ ├── service.py
|
||||
│ └── requirements.txt
|
||||
├── ui/
|
||||
│ └── index.html
|
||||
├── vendor/
|
||||
│ └── backend/
|
||||
└── models/
|
||||
- Quer entender a API? Comece em `docs/api/overview.md`.
|
||||
- Quer operar o sistema por terminal? Va para `docs/cli/overview.md`.
|
||||
- Quer usar o app desktop? Leia `docs/app/user-guide.md`.
|
||||
- Quer contribuir no codigo? Comece em `docs/developer/architecture.md`.
|
||||
@@ -1,9 +1,10 @@
|
||||
# Autoresearch Dashboard: localllm-cli-ureq-transport
|
||||
# Autoresearch Dashboard: tauri-runtime-lifecycle-acceptance
|
||||
|
||||
**Runs:** 1 | **Kept:** 1 | **Discarded:** 0 | **Crashed:** 0
|
||||
**Baseline:** cli_surface_score: 100.0% (#39)
|
||||
**Best:** cli_surface_score: 100.0% (#39, +0.0%)
|
||||
**Runs:** 2 | **Kept:** 2 | **Discarded:** 0 | **Crashed:** 0
|
||||
**Baseline:** runtime_lifecycle_score: 100.0% (#1)
|
||||
**Best:** runtime_lifecycle_score: 100.0% (#1, +0.0%)
|
||||
|
||||
| # | commit | cli_surface_score | status | description |
|
||||
|---|--------|-------------------|--------|-------------|
|
||||
| 39 | 9860d64 | 100.0% (+0.0%) | keep | add ureq http transport and execute_live_command |
|
||||
| # | commit | runtime_lifecycle_score | status | description |
|
||||
|---|--------|-------------------------|--------|-------------|
|
||||
| 1 | af2c8d3 | 100.0% (+0.0%) | keep | align the Tauri lifecycle harness and keep the local CTA visible during mode switches |
|
||||
| 2 | af2c8d3 | 100.0% (+0.0%) | keep | cover saved local startup transitions and local onboarding from browse to ready |
|
||||
@@ -55,3 +55,10 @@
|
||||
{"run":38,"commit":"6920e2a","metric":100.0,"metrics":{"passed_cases":66,"failed_cases":0,"total_cases":66},"status":"keep","description":"add cli live execution pipeline","timestamp":1774566821,"segment":16}
|
||||
{"type":"config","name":"localllm-cli-ureq-transport","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
|
||||
{"run":39,"commit":"9860d64","metric":100.0,"metrics":{"passed_cases":69,"failed_cases":0,"total_cases":69},"status":"keep","description":"add ureq http transport and execute_live_command","timestamp":1774568181,"segment":17}
|
||||
{"type":"config","name":"tauri-runtime-manager-unification","metricName":"runtime_surface_score","metricUnit":"%","bestDirection":"higher"}
|
||||
{"run":40,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"unified_control_plane":1,"design_refactor_done":1},"status":"keep","description":"unify runtime manager config/start flow and refactor runtime UI","timestamp":1774578913,"segment":18}
|
||||
{"run":41,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"health_probe_passed":1},"status":"keep","description":"replace tcp-only readiness with /health readiness probe","timestamp":1774579405,"segment":18}
|
||||
{"run":42,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"runtime_smoke_passed":1},"status":"keep","description":"add runtime UI smoke coverage and preserve saved model on empty rescans","timestamp":1774579810,"segment":18}
|
||||
{"type":"config","name":"tauri-runtime-lifecycle-acceptance","metricName":"runtime_lifecycle_score","metricUnit":"%","bestDirection":"higher"}
|
||||
{"run":43,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1},"status":"keep","description":"align the Tauri lifecycle harness and keep the local CTA visible during mode switches","timestamp":1774585583,"segment":19}
|
||||
{"run":44,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1,"saved_startup_flow_passed":1,"local_onboarding_flow_passed":1},"status":"keep","description":"cover saved local startup transitions and local onboarding from browse to ready","timestamp":1774586404,"segment":19}
|
||||
+39
-64
@@ -1,80 +1,55 @@
|
||||
# Autoresearch: localllm-cli-control-surface
|
||||
# Autoresearch: tauri-runtime-lifecycle-acceptance
|
||||
|
||||
## Objective
|
||||
Implement a full management CLI on top of the current local backend version, starting with
|
||||
a professional command surface for status, model operations, auth, config, logs, health,
|
||||
and throughput benchmarking. This segment focuses first on the CLI surface and contracts,
|
||||
so later experiments can add real execution behavior without redesigning the UX shape.
|
||||
Harden the Tauri desktop runtime manager around lifecycle acceptance so the saved
|
||||
runtime flow stays trustworthy from configuration through apply/start behavior. This
|
||||
segment focuses on the app-surface contract: local/remote mode transitions should show
|
||||
the right controls immediately, local startup should stay driven by the saved runtime
|
||||
state, and the verification harness should score the desktop runtime instead of the old
|
||||
CLI surface.
|
||||
|
||||
## Metrics
|
||||
- **Primary**: cli_surface_score (percent, higher is better)
|
||||
- **Secondary**: passed_cases, failed_cases, total_cases
|
||||
- **Primary**: runtime_lifecycle_score (percent, higher is better)
|
||||
- **Secondary**: unit_passed, build_passed, cargo_check_passed, local_mode_cta_passed, saved_startup_flow_passed, local_onboarding_flow_passed
|
||||
|
||||
## How to Run
|
||||
`./autoresearch.sh` - runs targeted ignored tests for CLI command-surface behavior and outputs `METRIC name=number` lines.
|
||||
- `./autoresearch.sh`
|
||||
- `cd tauri-app && npm test -- src/features/runtime/runtime-utils.test.ts src/features/runtime/components/RuntimeSurface.test.tsx`
|
||||
- `cd tauri-app && npm run build`
|
||||
- `cargo check --manifest-path tauri-app/src-tauri/Cargo.toml`
|
||||
|
||||
## Files in Scope
|
||||
- `crates/localllm-cli/` - new CLI crate, parser, command UX, output modes, and CLI-focused tests
|
||||
- `crates/inference-server/src/main.rs` - current backend env/config seam the CLI will eventually manage
|
||||
- `crates/inference-server/src/routes.rs` - existing inference endpoints the CLI can interrogate today
|
||||
- `crates/inference-server/src/compat.rs` - existing auth/config knobs the CLI must understand and expose coherently
|
||||
- `crates/api-contracts/` - shared contracts the CLI may eventually consume
|
||||
- `experiments/bench_cli_surface.py` - benchmark harness for the autoresearch loop
|
||||
- `autoresearch.sh` - loop benchmark entrypoint and fast preflight
|
||||
- `tauri-app/src/App.tsx` - app shell ownership for runtime and model connectivity
|
||||
- `tauri-app/src/components/Sidebar.tsx` - sidebar entry point for runtime controls
|
||||
- `tauri-app/src/features/runtime/` - unified runtime domain, UI, utils, and Tauri API wrappers
|
||||
- `tauri-app/src/index.css` - global visual tokens for the desktop app
|
||||
- `tauri-app/src/hooks/useModels.ts` - model loading through resolved runtime connection
|
||||
- `tauri-app/src-tauri/src/runtime/` - backend source of truth for runtime config/state
|
||||
- `tauri-app/src-tauri/src/lib.rs` - command wiring and legacy compatibility seams
|
||||
|
||||
## Off Limits
|
||||
- `models/` model binaries
|
||||
- `vendor/` third-party sources and prebuilt llama binaries
|
||||
- `registry/` Python registry service unless a later CLI experiment explicitly needs to replace or wire it
|
||||
- `ui/` frontend files
|
||||
- Tauri app implementation in this segment
|
||||
- unrelated latency or template experiments
|
||||
- unrelated CLI autoresearch files and crates unless a runtime experiment explicitly needs them
|
||||
- unrelated latency/template experiments
|
||||
|
||||
## Constraints
|
||||
- Preserve the currently working backend behavior.
|
||||
- Regular test suite must stay green for keeps.
|
||||
- Prefer a stable, professional CLI contract over premature execution internals.
|
||||
- Commands should map cleanly to the product plan discussed with the user: status, models, auth, config, bench, logs, doctor.
|
||||
- The benchmark is test-driven: keep/discard follows the CLI surface suite score first.
|
||||
- A justified CLI-focused dependency is acceptable if it reduces maintenance cost.
|
||||
- Backend/Tauri remains the source of truth for runtime config and process state.
|
||||
- The frontend should expose one startup/config flow, not parallel state machines.
|
||||
- Remote mode must never silently fall back to localhost.
|
||||
- Verification must include `npm test`, `npm run build`, and `cargo check`.
|
||||
- Preserve existing chat/model behavior outside the runtime-management refactor.
|
||||
|
||||
## What's Been Tried
|
||||
- Segment 5 pivots autoresearch from template hardening to productization, specifically a complete CLI-first control surface on top of the current backend version.
|
||||
- The current repo has no CLI crate, no Tauri app, and no control-plane crate; the backend is still configured almost entirely by `LOCALLLM_*` environment variables and exposes only `/health`, `/v1/models`, and `/v1/chat/completions`.
|
||||
- The first benchmark for this segment is intentionally surface-oriented: ignored tests score whether the CLI presents the expected professional command set before deeper execution behavior is implemented.
|
||||
- Baseline run 15 (`fd773a9`) scored only `18.182%`: the new CLI scaffold currently exposes `status` and `models list`, but the broader management surface (`models scan/load/watch/download`, `auth`, `config`, `bench`, `logs`, `doctor`) is still absent.
|
||||
- Run 16 (`70cc59a`) expanded the Clap parser to the full intended command surface and raised segment 5 from `18.182%` to `100.0%` (11/11 passing), giving the CLI a stable UX contract before execution handlers are wired.
|
||||
- Segment 6 raises the bar from command presence to semantic usefulness: the benchmark now checks whether key workflows produce meaningful command summaries that preserve defaults and high-value arguments, as a stepping stone toward real execution planning.
|
||||
- Baseline run 17 (`53a7dbb`) scored `68.75%`: the parser already accepted the workflows, but `render_command_summary()` still threw away important semantic detail such as nested command paths, output format, alias selection, and throughput defaults.
|
||||
- Run 18 (`66b30de`) enriched semantic summaries across the CLI and raised segment 6 from `68.75%` to `100.0%` (16/16 passing), so the next bar should move from summary correctness to execution-planning or output-contract semantics.
|
||||
- Segment 7 raises the bar from semantic summaries to structured execution plans, checking whether key commands preserve actionable details in a machine-usable plan that can later drive real handlers.
|
||||
- Baseline run 19 (`9154a7b`) scored `69.565%`: the CLI still passes all parser and summary tests, but every new plan-oriented workflow fails because `plan_command()` currently returns `SummaryOnly` for all commands.
|
||||
- Run 20 (`bc584be`) introduced concrete `CommandPlan` variants for status, model scan/load, auth enable, config set, throughput bench, and logs, raising segment 7 from `69.565%` to `100.0%` (23/23 passing).
|
||||
- Segment 8 raises the bar from machine-usable plans to execution contracts, checking whether key workflows can already describe the concrete HTTP/local action they intend to perform at the backend/control-plane seam.
|
||||
- Baseline run 21 (`3d1460b`) scored `76.667%`: the CLI already preserves parser, summary, and plan semantics, but `execution_contract()` still collapses the new workflows to `SummaryOnly { command: "unplanned" }` instead of concrete HTTP/local contracts.
|
||||
- Run 22 (`38acb52`) mapped the highest-value `CommandPlan` variants into concrete `ExecutionContract` values for `/health`, `/v1/chat/completions`, local model scan/load, local auth/config mutation, and local log reads, raising segment 8 from `76.667%` to `100.0%` (30/30 passing).
|
||||
- Segment 9 raises the bar from contract shape to execution stubs, checking whether the same high-value workflows can already describe concrete HTTP method/path/body details or named local actions that a future real executor can run directly.
|
||||
- Baseline run 23 (`7030290`) scored `83.333%`: parser, summaries, plans, and execution contracts still pass, but `execution_stub()` is only a placeholder and returns `SummaryOnly { command: "unimplemented" }` instead of concrete HTTP/local stubs for the highest-value workflows.
|
||||
- Run 24 (`50d435a`) mapped the highest-value `ExecutionContract` variants into concrete `ExecutionStub` values for `GET /health`, `POST /v1/chat/completions`, local model scan/load, local auth config, and local log tail actions, raising segment 9 from `83.333%` to `100.0%` (36/36 passing).
|
||||
- Segment 10 raises the bar from execution stubs to dry-run previews, checking whether the same high-value workflows can expand into full previewable HTTP URLs or local-effect descriptions that are ready to show in a future `--dry-run` UX.
|
||||
- Baseline run 25 (`cd480c4`) scored `85.714%`: parser, summaries, plans, contracts, and stubs still pass, but `execution_preview()` remained a placeholder and returned `SummaryOnly { command: "preview-unimplemented" }` instead of final dry-run previews.
|
||||
- Run 26 (`c8ef7cf`) mapped the highest-value execution stubs into concrete `ExecutionPreview` values with full health/chat URLs and readable local-effect descriptions, raising segment 10 from `85.714%` to `100.0%` (42/42 passing).
|
||||
- Segment 11 raises the bar from preview objects to final `--dry-run` output, checking whether the CLI can now render those previews as actual text/json UX for the same high-value workflows.
|
||||
- Baseline run 27 (`7738acf`) scored `87.5%`: the CLI already had parser/summary/plan/contract/stub/preview layers, but `render_execution_preview()` still collapsed everything to `DRY RUN {summary}` instead of final text/json dry-run output.
|
||||
- Run 28 (`99e5041`) rendered the highest-value `ExecutionPreview` values into final user-facing text/json dry-run output, raising segment 11 from `87.5%` to `100.0%` (48/48 passing).
|
||||
- Segment 12 raises the bar from dry-run UX to the first real backend-call specifications for commands that can already work today, specifically `status`, `models list`, and `bench throughput`.
|
||||
- Baseline run 29 (`1ff5ba3`) scored `94.118%`: every earlier CLI layer still passed, but `backend_invocation()` was still a placeholder and the new backend-facing tests for live status/models/bench requests failed.
|
||||
- Run 30 (`bf40b2f`) added concrete backend invocation specs for `GET /health`, `GET /v1/models`, and `POST /v1/chat/completions` with preserved `--model`, token budget, server base URL, and auth presence, raising segment 12 from `94.118%` to `100.0%` (51/51 passing).
|
||||
- Segment 13 raises the bar again from backend invocation specs to executable backend request construction, checking whether the same live-compatible commands can now produce final HTTP method/url/header/body request objects with deterministic JSON payloads.
|
||||
- Run 31 (`f970c05`) scored `94.444%` because `backend_request()` was still a placeholder and the new backend-request tests failed for `status`, `models list`, and `bench throughput`.
|
||||
- Run 32 (`5de35c5`) introduced concrete executable backend request specs with bearer/content-type headers and deterministic JSON benchmark payloads, raising segment 13 from `94.444%` to `100.0%` (54/54 passing).
|
||||
- Segment 14 raises the bar from executable backend request specs to backend response interpretation, checking whether the same live-compatible commands can parse real response bodies into CLI-facing previews for health, model lists, and throughput usage metrics.
|
||||
- Run 33 (`8cf03e3`) scored `94.737%` because `backend_response_preview()` was still a placeholder and the new response-parsing tests failed for `status`, `models list`, and `bench throughput`.
|
||||
- Run 34 (`0506a4c`) introduced CLI-local backend response previews for health payloads, model list ids/count, and throughput usage metrics, raising segment 14 from `94.737%` to `100.0%` (57/57 passing).
|
||||
- Segment 15 raises the bar from internal backend response previews to final user-facing live output rendering, checking whether the same live-compatible commands can render text/json output directly from parsed backend responses.
|
||||
- Run 35 (`e1ada2c`) scored `90.476%` because `render_backend_response()` still collapsed the live-compatible workflows to `LIVE {summary}` instead of final text/json output.
|
||||
- Run 36 (`c54107d`) rendered the first live-compatible backend responses into final user-facing text/json output, raising segment 15 from `90.476%` to `100.0%` (63/63 passing).
|
||||
- Segment 16 raises the bar from live output rendering to the first real executable handlers, checking whether the same live-compatible workflows can execute through an injectable transport seam and return the final rendered output end-to-end.
|
||||
- Run 37 (`388733a`) scored `95.455%` because `execute_live_command_with()` still ignored the transport response and returned `LIVE EXEC {summary}` instead of final live output.
|
||||
- Run 38 (`6920e2a`) wired the injected transport response into `render_backend_response()`, raising segment 16 from `95.455%` to `100.0%` (66/66 passing).
|
||||
- Segment 17 adds `ureq` as the real HTTP transport and implements `execute_live_command(cli)` that delegates to `execute_live_command_with()` using `ureq`. Run 39 (`9860d64`) scored `100.0%` (69/69 passing) as a direct keep — the CLI is now functionally complete for the commands the current backend supports.
|
||||
- Earlier autoresearch segments 0-17 focused on latency, dynamic chat templates, and a CLI management surface. The latest completed CLI segment (`localllm-cli-ureq-transport`) reached `100.0%` on run 39 (`9860d64`), but that objective is now stale for the desktop app work.
|
||||
- The Tauri chat layer was previously fixed so new conversations persist correctly, `<think>...</think>` content is separated into a collapsible thinking panel, and per-message/per-chat token accounting is stored and rendered.
|
||||
- The first runtime-manager slice introduced a backend runtime domain in `tauri-app/src-tauri/src/runtime/`, a frontend runtime feature in `tauri-app/src/features/runtime/`, a compact sidebar status card, and a settings drawer.
|
||||
- Review of that slice exposed two major correctness issues that were fixed: remote mode no longer falls back to localhost, and runtime config changes that would drift away from a running child process are now blocked with a restart-required error.
|
||||
- Even after those fixes, the runtime manager still failed in practice because the app kept two frontend control planes: the new runtime card/drawer started the backend from `RuntimeState.inner.config`, while the embedded legacy `ServerPanel` still edited local model/runtime settings through `save_config`, which only wrote the config file and did not update the in-memory runtime state used by the new start buttons.
|
||||
- The latest experiment resolves that split by removing `useServerProcess` and the embedded legacy `ServerPanel` from the active sidebar flow, moving local runtime configuration into the unified runtime drawer, and making the sidebar card either open setup or start the saved runtime config from the same domain.
|
||||
- The runtime visuals were also refactored toward a cleaner AI-desktop feel with updated runtime card/drawer styling, a more deliberate font stack, cooler blue-cyan accents, and less of the earlier purple-heavy sidebar look.
|
||||
- The current experiment hardens readiness detection in `tauri-app/src-tauri/src/runtime/health.rs` so startup now waits for a successful `GET /health` response with `{"status":"ok"}` instead of treating any open TCP port as ready. Rust tests now cover healthy HTTP, unhealthy payloads, and plain-open-port false positives.
|
||||
- The latest experiment adds app-surface smoke coverage for `RuntimeStatusCard` and `RuntimeSettingsDrawer`, then fixes a real regression in `chooseModel()` so a previously saved model path is preserved when a folder rescan returns no models. This keeps the unified drawer start action available for valid saved configs instead of silently disabling it.
|
||||
- The current experiment re-initializes the harness around the Tauri desktop surface by replacing the stale CLI benchmark with `experiments/bench_tauri_runtime_surface.py`, updating `autoresearch.sh` to score runtime-focused frontend tests plus build/check verification, and fixing a real drawer regression where switching from remote to local mode hid the primary local CTA until after save.
|
||||
- The latest experiment extends the runtime lifecycle acceptance contract with two hook-backed smoke checks: a saved local config must surface `stopped -> starting -> ready` through `useRuntimeManager`, and local onboarding via folder browse must auto-select a model, persist it, and reach a ready local runtime from the same drawer flow.
|
||||
- Current keep criteria for this segment are: the desktop runtime benchmark targets the active Tauri surface, switching into local mode keeps the apply/start CTA visible immediately, saved local startup exposes `starting` before `ready`, onboarding can browse/save/start from one local flow, and verification stays green across Vitest, TypeScript/Vite build, and Rust compilation.
|
||||
+2
-2
@@ -6,7 +6,7 @@ ROOT="$SCRIPT_DIR"
|
||||
mkdir -p "$ROOT/experiments"
|
||||
|
||||
echo "[INFO] Fast preflight (cargo check)..." >&2
|
||||
cargo check -p localllm-cli --quiet
|
||||
cargo check --manifest-path "$ROOT/tauri-app/src-tauri/Cargo.toml" --quiet
|
||||
|
||||
python "$ROOT/experiments/bench_cli_surface.py" \
|
||||
python "$ROOT/experiments/bench_tauri_runtime_surface.py" \
|
||||
--root "$ROOT"
|
||||
+10
-388
@@ -1,393 +1,15 @@
|
||||
# ForgeLM API Reference
|
||||
# ForgeLM API docs
|
||||
|
||||
ForgeLM exposes an **OpenAI-compatible REST API** for local LLM inference. Any client that works with the OpenAI API (VSCode extensions, OpenCode, Cline, Roo Code, curl, Python `openai` SDK, etc.) can point at this server with no code changes.
|
||||
Este arquivo antigo foi substituido pela nova documentacao modular em `docs/api/`.
|
||||
|
||||
---
|
||||
Use estes documentos como referencia oficial atual:
|
||||
|
||||
## Base URL
|
||||
- `docs/api/overview.md` - visao geral da arquitetura e da superficie HTTP
|
||||
- `docs/api/endpoints.md` - referencia dos endpoints, payloads, respostas e erros
|
||||
- `docs/api/configuration.md` - variaveis de ambiente, auth, CORS e perfis de runtime
|
||||
|
||||
```
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
Motivo da mudanca:
|
||||
|
||||
Configure with the `LOCALLLM_ADDR` environment variable (default `127.0.0.1:8080`).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Authentication is **optional** and disabled by default.
|
||||
|
||||
| Env var | Description |
|
||||
|---------|-------------|
|
||||
| `LOCALLLM_API_KEY` | If set, all requests must include this key |
|
||||
| `LOCALLLM_AUTH_HEADER` | Header name to check (default: `authorization`) |
|
||||
| `LOCALLLM_AUTH_SCHEME` | Expected scheme prefix (default: `Bearer`) |
|
||||
|
||||
**Sending a key:**
|
||||
|
||||
```http
|
||||
Authorization: Bearer <your-key>
|
||||
```
|
||||
|
||||
or via `x-api-key`:
|
||||
|
||||
```http
|
||||
x-api-key: <your-key>
|
||||
```
|
||||
|
||||
If no key is configured, all requests are accepted without authentication.
|
||||
|
||||
---
|
||||
|
||||
## CORS
|
||||
|
||||
CORS is enabled for all origins by default (`*`). Restrict with:
|
||||
|
||||
```
|
||||
LOCALLLM_CORS_ORIGINS=https://myapp.com,http://localhost:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns server and model status.
|
||||
|
||||
**Response `200 OK`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"model_loaded": true,
|
||||
"model_id": "Qwen3.5-4B-Abliterated.i1-Q6_K",
|
||||
"uptime_secs": 142
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `status` | `string` | Always `"ok"` when server is running |
|
||||
| `model_loaded` | `boolean` | Whether a model is currently loaded |
|
||||
| `model_id` | `string \| null` | Canonical model identifier |
|
||||
| `uptime_secs` | `integer` | Seconds since server start |
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/models`
|
||||
|
||||
Lists available models. Compatible with the OpenAI models endpoint.
|
||||
|
||||
**Response `200 OK`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "Qwen3.5-4B-Abliterated.i1-Q6_K",
|
||||
"object": "model",
|
||||
"created": 1711497600,
|
||||
"owned_by": "local",
|
||||
"root": "Qwen3.5-4B-Abliterated.i1-Q6_K"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Model aliases (configured via `LOCALLLM_MODEL_ALIASES`) also appear in the list and resolve to the same underlying model.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/chat/completions`
|
||||
|
||||
The main inference endpoint. Fully compatible with the OpenAI Chat Completions API.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "Qwen3.5-4B-Abliterated.i1-Q6_K",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "You are a helpful assistant." },
|
||||
{ "role": "user", "content": "Explain async/await in Rust." }
|
||||
],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.15,
|
||||
"top_p": 0.95,
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
#### Request fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `model` | `string` | **required** | Model ID or alias |
|
||||
| `messages` | `array` | **required** | Conversation history |
|
||||
| `max_tokens` | `integer` | `1024` | Max tokens to generate (alias: `max_completion_tokens`) |
|
||||
| `temperature` | `float` | `0.15` | Sampling temperature (0 = greedy) |
|
||||
| `top_p` | `float` | `0.95` | Nucleus sampling threshold |
|
||||
| `stream` | `boolean` | `false` | Enable SSE streaming |
|
||||
| `stop` | `string \| array` | `[]` | Stop sequences |
|
||||
| `seed` | `integer` | `null` | Reproducibility seed |
|
||||
| `n` | `integer` | `1` | Number of completions (only `1` supported) |
|
||||
| `response_format` | `object` | `null` | `{"type": "text"}` or `{"type": "json_object"}` |
|
||||
| `tools` | `array` | `[]` | Tool definitions for function calling |
|
||||
| `tool_choice` | `string \| object` | `null` | Tool selection policy |
|
||||
| `stream_options` | `object` | `null` | `{"include_usage": true}` to get usage in stream |
|
||||
|
||||
#### Message roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| `system` | System-level instructions |
|
||||
| `developer` | Developer-level instructions (treated as system) |
|
||||
| `user` | User turn |
|
||||
| `assistant` | Previous assistant turn |
|
||||
| `tool` | Tool call result |
|
||||
|
||||
#### Non-streaming response `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-a1b2c3d4",
|
||||
"object": "chat.completion",
|
||||
"created": 1711497600,
|
||||
"model": "Qwen3.5-4B-Abliterated.i1-Q6_K",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "In Rust, `async/await` allows you to write asynchronous code..."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 24,
|
||||
"completion_tokens": 87,
|
||||
"total_tokens": 111,
|
||||
"tokens_per_second": 52.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Streaming response (SSE)
|
||||
|
||||
Set `"stream": true` to receive Server-Sent Events:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-a1b2c3d4","object":"chat.completion.chunk","created":1711497600,"model":"Qwen3.5-4B-Abliterated.i1-Q6_K","choices":[{"index":0,"delta":{"role":"assistant","content":"In"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-a1b2c3d4","object":"chat.completion.chunk","created":1711497600,"model":"Qwen3.5-4B-Abliterated.i1-Q6_K","choices":[{"index":0,"delta":{"content":" Rust"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-a1b2c3d4","object":"chat.completion.chunk","created":1711497600,"model":"Qwen3.5-4B-Abliterated.i1-Q6_K","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
To include usage in the final streaming chunk:
|
||||
|
||||
```json
|
||||
{
|
||||
"stream": true,
|
||||
"stream_options": { "include_usage": true }
|
||||
}
|
||||
```
|
||||
|
||||
#### Tool calling
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "Qwen3.5-4B-Abliterated.i1-Q6_K",
|
||||
"messages": [{ "role": "user", "content": "What is the weather in Paris?" }],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
When the model decides to call a tool, `finish_reason` is `"tool_calls"` and `message.tool_calls` contains the call:
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\": \"Paris\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error responses
|
||||
|
||||
All errors follow the OpenAI error format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "The model 'unknown-model' does not exist",
|
||||
"type": "invalid_request_error",
|
||||
"param": "model",
|
||||
"code": "model_not_found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP status | `type` | When |
|
||||
|-------------|--------|------|
|
||||
| `400` | `invalid_request_error` | Malformed request, empty messages, unsupported `n` |
|
||||
| `401` | `authentication_error` | Missing or invalid API key |
|
||||
| `404` | `invalid_request_error` | Unknown model |
|
||||
| `422` | `invalid_request_error` | Unsupported `response_format` |
|
||||
| `500` | `server_error` | Internal inference failure |
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOCALLLM_MODEL` | *(required)* | Path to the GGUF model file |
|
||||
| `LOCALLLM_ADDR` | `127.0.0.1:8080` | Bind address |
|
||||
| `LOCALLLM_PROFILE` | `balanced` | Runtime profile (`fast`, `balanced`, `quality`) |
|
||||
| `LOCALLLM_GPU_LAYERS` | `0` | Number of layers to offload to GPU |
|
||||
| `LOCALLLM_CTX` | `4096` | Context window size |
|
||||
| `LOCALLLM_BATCH` | `512` | Batch size |
|
||||
| `LOCALLLM_UBATCH` | `512` | Micro-batch size |
|
||||
| `LOCALLLM_THREADS` | *(auto)* | CPU threads for generation |
|
||||
| `LOCALLLM_THREADS_BATCH` | *(auto)* | CPU threads for batch processing |
|
||||
| `LOCALLLM_API_KEY` | *(none)* | API key for authentication |
|
||||
| `LOCALLLM_AUTH_HEADER` | `authorization` | Auth header name |
|
||||
| `LOCALLLM_AUTH_SCHEME` | `Bearer` | Auth scheme prefix |
|
||||
| `LOCALLLM_CORS_ORIGINS` | `*` | Comma-separated allowed origins |
|
||||
| `LOCALLLM_SYSTEM_PROMPT` | *(coding assistant)* | Default system prompt |
|
||||
| `LOCALLLM_ENABLE_TOOL_CALLS` | `false` | Enable tool calling support |
|
||||
| `LOCALLLM_MODEL_ALIASES` | *(none)* | Comma-separated `alias=model` pairs |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### Start the server
|
||||
|
||||
```bash
|
||||
LOCALLLM_MODEL=./models/Qwen3.5-4B.gguf cargo run --release -p inference-server
|
||||
```
|
||||
|
||||
### Test with curl
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# List models
|
||||
curl http://localhost:8080/v1/models
|
||||
|
||||
# Chat completion
|
||||
curl http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "Qwen3.5-4B",
|
||||
"messages": [{"role": "user", "content": "Hello!"}],
|
||||
"max_tokens": 100
|
||||
}'
|
||||
|
||||
# Streaming
|
||||
curl http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "Qwen3.5-4B",
|
||||
"messages": [{"role": "user", "content": "Count to 5"}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Use with Python openai SDK
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://localhost:8080/v1",
|
||||
api_key="not-needed" # or your LOCALLLM_API_KEY
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="Qwen3.5-4B",
|
||||
messages=[{"role": "user", "content": "Write a Python hello world"}],
|
||||
max_tokens=200
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### Use with VSCode / OpenCode / Cline / Roo Code
|
||||
|
||||
Set the following in your extension settings:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Base URL | `http://localhost:8080/v1` |
|
||||
| API Key | *(leave empty or set `LOCALLLM_API_KEY`)* |
|
||||
| Model | *(your model ID from `/v1/models`)* |
|
||||
|
||||
---
|
||||
|
||||
## Chat template behavior
|
||||
|
||||
ForgeLM automatically selects the correct chat template based on the model family:
|
||||
|
||||
| Model family | Template |
|
||||
|-------------|----------|
|
||||
| `qwen` | `<\|im_start\|>` / `<\|im_end\|>` tokens |
|
||||
| `gemma` | `<start_of_turn>` / `<end_of_turn>` tokens |
|
||||
| `llama` | `[INST]` / `[/INST]` blocks with `<<SYS>>` |
|
||||
| `kimi` / `moonshot` | `<\|im_system\|>` / `<\|im_user\|>` / `<\|im_assistant\|>` |
|
||||
| *(other)* | Generic `System:` / `User:` / `Assistant:` format |
|
||||
|
||||
Template selection uses the requested model name and any configured aliases, so renaming a model to include a family hint (e.g. `qwen-coder`) is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Coding defaults
|
||||
|
||||
When no system prompt is provided, ForgeLM injects a coding-focused default:
|
||||
|
||||
> *You are a precise coding assistant running locally. Prioritize correct code, minimal speculation, explicit assumptions, and actionable fixes. When editing or generating code, prefer complete snippets, preserve requested APIs, and explain risks briefly.*
|
||||
|
||||
Sampling defaults are also tuned for code generation:
|
||||
- `temperature: 0.15` (low randomness)
|
||||
- `top_p: 0.95`
|
||||
- `max_tokens: 1024`
|
||||
|
||||
Override any of these per-request or globally via `LOCALLLM_SYSTEM_PROMPT`.
|
||||
- a estrutura antiga era monolitica
|
||||
- ela corria o risco de sugerir uma compatibilidade OpenAI mais ampla do que o codigo atual realmente garante
|
||||
- a nova estrutura separa melhor arquitetura, referencia HTTP e configuracao operacional
|
||||
@@ -0,0 +1,30 @@
|
||||
# Documentacao do ForgeLM
|
||||
|
||||
Esta pasta concentra a documentacao detalhada do projeto. O `README.md` da raiz funciona como ponto de entrada rapido; aqui ficam as referencias completas.
|
||||
|
||||
## API e backend
|
||||
|
||||
- `api/overview.md` - arquitetura do backend, crates envolvidos e comportamento geral da API
|
||||
- `api/endpoints.md` - referencia dos endpoints HTTP, payloads, respostas e erros
|
||||
- `api/configuration.md` - variaveis de ambiente, auth, CORS, aliases de modelo e perfis de runtime
|
||||
|
||||
## CLI
|
||||
|
||||
- `cli/overview.md` - como a CLI se encaixa no projeto, flags globais e formatos de saida
|
||||
- `cli/commands.md` - referencia de comandos, subcomandos, exemplos e limitacoes atuais
|
||||
|
||||
## App desktop
|
||||
|
||||
- `app/overview.md` - arquitetura geral do app Tauri e seus subsistemas
|
||||
- `app/user-guide.md` - guia de uso do runtime manager, selecao de modelos e chat
|
||||
- `app/backend-commands.md` - comandos Tauri expostos pelo backend do app
|
||||
|
||||
## Documentacao para desenvolvedores
|
||||
|
||||
- `developer/architecture.md` - mapa do workspace, relacao entre crates e fluxo entre API, CLI e app
|
||||
|
||||
## Como navegar
|
||||
|
||||
- Usuario final do app: comece em `app/user-guide.md`
|
||||
- Operacao da API/CLI: comece em `api/overview.md` e `cli/overview.md`
|
||||
- Contribuicao tecnica: comece em `developer/architecture.md`
|
||||
@@ -0,0 +1,101 @@
|
||||
# Configuracao do servidor
|
||||
|
||||
Esta pagina resume a configuracao atual do backend local do ForgeLM.
|
||||
|
||||
## Variaveis de ambiente principais
|
||||
|
||||
### Modelo e bind
|
||||
|
||||
- `LOCALLLM_MODEL` - caminho do arquivo `.gguf` a ser carregado
|
||||
- `LOCALLLM_ADDR` - endereco de bind do servidor, default `0.0.0.0:8080`
|
||||
- `LOCALLLM_MODEL_ALIASES` - aliases separados por virgula para o modelo canonico
|
||||
|
||||
### Perfil de runtime
|
||||
|
||||
- `LOCALLLM_PROFILE` - `balanced_9b`, `cpu_fallback` ou default `Turbo4B`
|
||||
- `LOCALLLM_GPU_LAYERS`
|
||||
- `LOCALLLM_CTX`
|
||||
- `LOCALLLM_BATCH`
|
||||
- `LOCALLLM_UBATCH`
|
||||
- `LOCALLLM_THREADS`
|
||||
- `LOCALLLM_THREADS_BATCH`
|
||||
|
||||
Esses campos sobrepoem os defaults do perfil selecionado.
|
||||
|
||||
## Perfis conhecidos
|
||||
|
||||
### Turbo4B
|
||||
|
||||
- `n_gpu_layers = 999`
|
||||
- `n_ctx = 4096`
|
||||
- `n_batch = 512`
|
||||
- `n_ubatch = 128`
|
||||
- `flash_attn = true`
|
||||
- `n_threads = 8`
|
||||
|
||||
### Balanced9B
|
||||
|
||||
- `n_gpu_layers = 999`
|
||||
- `n_ctx = 2048`
|
||||
- `n_batch = 256`
|
||||
- `n_ubatch = 64`
|
||||
- `flash_attn = true`
|
||||
- `n_threads = 8`
|
||||
|
||||
### CpuFallback
|
||||
|
||||
- `n_gpu_layers = 0`
|
||||
- `n_ctx = 2048`
|
||||
- `n_batch = 128`
|
||||
- `n_ubatch = 32`
|
||||
- `flash_attn = false`
|
||||
- `n_threads = available_parallelism()`
|
||||
|
||||
## Auth
|
||||
|
||||
- `LOCALLLM_API_KEY` - habilita auth por chave quando definido
|
||||
- `LOCALLLM_AUTH_HEADER` - nome do cabecalho preferencial; default `authorization`
|
||||
- `LOCALLLM_AUTH_SCHEME` - prefixo opcional para remover antes da comparacao
|
||||
|
||||
Se `LOCALLLM_API_KEY` estiver vazio ou ausente, a API funciona sem auth para `GET /v1/models` e `POST /v1/chat/completions`.
|
||||
|
||||
## CORS
|
||||
|
||||
- `LOCALLLM_CORS_ORIGINS` - lista separada por virgula
|
||||
|
||||
Comportamento atual:
|
||||
|
||||
- sem configuracao explicita, o servidor libera `*`
|
||||
- quando a variavel existe, o backend constroi uma allowlist literal
|
||||
|
||||
## Prompt de sistema e tool calling
|
||||
|
||||
- `LOCALLLM_SYSTEM_PROMPT` - prompt de sistema default injetado quando a request nao traz mensagem `system` ou `developer`
|
||||
- `LOCALLLM_ENABLE_TOOL_CALLS` - habilita/desabilita tool calling no servidor
|
||||
|
||||
## Exemplo de ambiente minimo
|
||||
|
||||
```bash
|
||||
set LOCALLLM_MODEL=D:\modelos\meu-modelo.gguf
|
||||
set LOCALLLM_ADDR=0.0.0.0:8080
|
||||
cargo run -p inference-server
|
||||
```
|
||||
|
||||
## Exemplo com auth
|
||||
|
||||
```bash
|
||||
set LOCALLLM_MODEL=D:\modelos\meu-modelo.gguf
|
||||
set LOCALLLM_API_KEY=dev-secret
|
||||
set LOCALLLM_AUTH_SCHEME=Bearer
|
||||
cargo run -p inference-server
|
||||
```
|
||||
|
||||
## Alias de modelos
|
||||
|
||||
Quando `LOCALLLM_MODEL_ALIASES` esta definido, o backend expoe aliases adicionais em `GET /v1/models`. Esses aliases apontam para o mesmo `root` do modelo canonico e sao resolvidos pelo backend durante a validacao do campo `model`.
|
||||
|
||||
## Observacoes operacionais
|
||||
|
||||
- o banner do servidor imprime `127.0.0.1:<porta>` como endereco recomendado para consumo local quando o bind usa `0.0.0.0`
|
||||
- o app Tauri usa readiness por `GET /health` ao iniciar o runtime local
|
||||
- o backend nao persiste configuracao propria em arquivo; a persistencia de runtime local do app acontece no backend Tauri, nao dentro do `inference-server`
|
||||
@@ -0,0 +1,225 @@
|
||||
# Endpoints HTTP
|
||||
|
||||
Esta pagina documenta a superficie HTTP publica atual do ForgeLM.
|
||||
|
||||
## Base URL
|
||||
|
||||
- default de bind: `0.0.0.0:8080`
|
||||
- endereco recomendado para consumo local: `http://127.0.0.1:8080`
|
||||
|
||||
Quando esta documentacao usa `127.0.0.1`, ela esta descrevendo o endereco tipico de consumo local, nao necessariamente o bind literal do processo.
|
||||
|
||||
## GET /health
|
||||
|
||||
Retorna um snapshot simples do estado do processo.
|
||||
|
||||
### Auth
|
||||
|
||||
- nao exige auth
|
||||
|
||||
### Exemplo
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
### Resposta
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"model_loaded": true,
|
||||
"model_id": "meu-modelo",
|
||||
"uptime_secs": 42
|
||||
}
|
||||
```
|
||||
|
||||
### Observacoes
|
||||
|
||||
- hoje `model_loaded` funciona como um sinal operacional simples do processo em pe
|
||||
- o app desktop usa `GET /health` como parte do readiness check do runtime local
|
||||
|
||||
## GET /v1/models
|
||||
|
||||
Lista o modelo canonico e aliases conhecidos.
|
||||
|
||||
### Auth
|
||||
|
||||
- exige auth apenas se `LOCALLLM_API_KEY` estiver configurada
|
||||
|
||||
### Cabecalhos aceitos
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `x-api-key: <token>`
|
||||
- ou o cabecalho custom configurado em `LOCALLLM_AUTH_HEADER`
|
||||
|
||||
### Exemplo
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
```
|
||||
|
||||
### Resposta
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "default",
|
||||
"object": "model",
|
||||
"created": 1710000000,
|
||||
"owned_by": "local",
|
||||
"root": "meu-modelo",
|
||||
"parent": "meu-modelo",
|
||||
"permission": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Observacoes
|
||||
|
||||
- `root` aponta para o modelo canonico
|
||||
- `parent` e usado quando um ID e alias do modelo canonico
|
||||
- o app desktop usa esse endpoint para preencher o seletor de modelo
|
||||
|
||||
## POST /v1/chat/completions
|
||||
|
||||
Endpoint principal de inferencia.
|
||||
|
||||
### Auth
|
||||
|
||||
- exige auth apenas se `LOCALLLM_API_KEY` estiver configurada
|
||||
|
||||
### Campos principais da request
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "default",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "Explique este projeto" }
|
||||
],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.15,
|
||||
"top_p": 0.95,
|
||||
"stream": false,
|
||||
"stop": [],
|
||||
"n": 1,
|
||||
"seed": null,
|
||||
"user": null,
|
||||
"response_format": null,
|
||||
"tools": [],
|
||||
"tool_choice": null,
|
||||
"parallel_tool_calls": null,
|
||||
"stream_options": null
|
||||
}
|
||||
```
|
||||
|
||||
### Defaults importantes
|
||||
|
||||
- `max_tokens`: `1024`
|
||||
- `temperature`: `0.15`
|
||||
- `top_p`: `0.95`
|
||||
- `n`: `1`
|
||||
|
||||
### Regras de validacao
|
||||
|
||||
- `model` deve existir no catalogo de modelos
|
||||
- `messages` nao pode ser vazio
|
||||
- `n` precisa ser `1`
|
||||
- `tools` podem ser rejeitadas se tool calling estiver desabilitado
|
||||
|
||||
### Exemplo sem streaming
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "default",
|
||||
"messages": [{"role": "user", "content": "Diga ola"}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
### Resposta sem streaming
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-...",
|
||||
"object": "chat.completion",
|
||||
"created": 1710000000,
|
||||
"model": "meu-modelo",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Ola!"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 20,
|
||||
"tokens_per_second": 31.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exemplo com streaming
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-N \
|
||||
-d '{
|
||||
"model": "default",
|
||||
"messages": [{"role": "user", "content": "Escreva um resumo"}],
|
||||
"stream": true,
|
||||
"stream_options": {"include_usage": true}
|
||||
}'
|
||||
```
|
||||
|
||||
### Streaming SSE
|
||||
|
||||
O backend envia eventos `data: ...` no formato `chat.completion.chunk` e termina com:
|
||||
|
||||
```text
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
O ultimo chunk pode incluir `usage` se `include_usage=true`.
|
||||
|
||||
### Erros
|
||||
|
||||
Exemplo de payload de erro:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Only n=1 is currently supported",
|
||||
"type": "invalid_request_error",
|
||||
"param": "n",
|
||||
"code": "unsupported_value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Limitacoes atuais
|
||||
|
||||
- o servidor nao implementa toda a plataforma OpenAI
|
||||
- `parallel_tool_calls` nao deve ser tratado como recurso plenamente suportado
|
||||
- o campo `content` de mensagens pode aceitar arrays de partes textuais, mas o backend reduz isso a texto consolidado
|
||||
- reasoning nativo nao aparece como campo separado na API HTTP; o app desktop separa `<think>...</think>` no frontend
|
||||
|
||||
## Codigos de status mais comuns
|
||||
|
||||
- `200 OK` - sucesso
|
||||
- `400 Bad Request` - payload invalido ou modo nao suportado
|
||||
- `401 Unauthorized` - chave ausente/invalida quando auth esta habilitada
|
||||
- `404 Not Found` - modelo nao encontrado
|
||||
|
||||
Para configuracao do servidor e variaveis de ambiente, veja `configuration.md`.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Visao geral da API e backend
|
||||
|
||||
O backend do ForgeLM e um servidor HTTP local escrito em Rust, montado sobre Axum e conectado a uma engine de inferencia em `inference-core`.
|
||||
|
||||
## Componentes principais
|
||||
|
||||
- `crates/inference-server` - inicializacao do servidor, leitura de configuracao por variaveis de ambiente e roteamento HTTP
|
||||
- `crates/api-contracts` - tipos de request/response para chat, health, models e erros
|
||||
- `crates/inference-core` - engine, sessoes, perfis de runtime e sampler
|
||||
- `crates/metrics` - contadores de throughput e metricas de geracao
|
||||
|
||||
## Endpoints publicos
|
||||
|
||||
Hoje o servidor expoe:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
|
||||
Essa superficie e pequena de proposito: o foco atual do projeto esta em chat local, descoberta de modelos e integracao com o runtime manager do app.
|
||||
|
||||
## Inicializacao do servidor
|
||||
|
||||
O binario principal do backend esta em `crates/inference-server/src/main.rs`.
|
||||
|
||||
Na inicializacao, o servidor:
|
||||
|
||||
1. configura tracing/logs
|
||||
2. le variaveis de ambiente como `LOCALLLM_MODEL`, `LOCALLLM_PROFILE`, `LOCALLLM_ADDR`, `LOCALLLM_GPU_LAYERS` e `LOCALLLM_CTX`
|
||||
3. cria a configuracao da engine a partir do perfil selecionado
|
||||
4. carrega e aquece o modelo (`warmup()`)
|
||||
5. monta o router com CORS e estado compartilhado
|
||||
6. faz bind no endereco configurado e serve as rotas HTTP
|
||||
|
||||
## Perfil de runtime
|
||||
|
||||
Os perfis atuais ficam em `crates/inference-core/src/profile.rs`:
|
||||
|
||||
- `Turbo4B` - foco em throughput, contexto moderado e uso de GPU agressivo
|
||||
- `Balanced9B` - equilibrio entre qualidade e custo
|
||||
- `CpuFallback` - fallback para maquinas sem GPU
|
||||
|
||||
Esses perfis definem defaults como `n_gpu_layers`, `n_ctx`, `n_batch`, `n_ubatch`, `flash_attn` e `n_threads`.
|
||||
|
||||
## Auth e CORS
|
||||
|
||||
A configuracao de compatibilidade HTTP mora em `crates/inference-server/src/compat.rs`.
|
||||
|
||||
- auth e opcional; se `LOCALLLM_API_KEY` nao estiver configurada, a API aceita requests sem chave
|
||||
- `GET /health` nao passa por verificacao de auth
|
||||
- `GET /v1/models` e `POST /v1/chat/completions` passam por `authorize_request()`
|
||||
- CORS aceita `*` por default ou uma lista configuravel por `LOCALLLM_CORS_ORIGINS`
|
||||
|
||||
Cabecalhos aceitos para auth:
|
||||
|
||||
- cabecalho configurado em `LOCALLLM_AUTH_HEADER` (default: `authorization`)
|
||||
- `Authorization`
|
||||
- `x-api-key`
|
||||
|
||||
Se `LOCALLLM_AUTH_SCHEME` estiver configurado, o backend tenta remover esse prefixo antes de comparar a chave.
|
||||
|
||||
## Catalogo de modelos
|
||||
|
||||
O backend monta um catalogo de modelos em memoria a partir do nome canonico do modelo carregado e de aliases opcionais configurados por `LOCALLLM_MODEL_ALIASES`.
|
||||
|
||||
Regras importantes:
|
||||
|
||||
- o modelo carregado vira o ID canonico principal
|
||||
- aliases adicionais podem apontar para o mesmo `root`
|
||||
- `local-model` e `default` entram como aliases comuns quando possivel
|
||||
- `GET /v1/models` retorna tanto o canonico quanto os aliases conhecidos
|
||||
|
||||
## Chat completions
|
||||
|
||||
O endpoint de chat aceita mensagens em formato semelhante ao da OpenAI, com suporte para:
|
||||
|
||||
- roles `system`, `developer`, `user`, `assistant` e `tool`
|
||||
- `stream: true|false`
|
||||
- `stop` como string unica, array de strings ou `null`
|
||||
- `response_format`
|
||||
- `tools` e `tool_choice`
|
||||
- `stream_options.include_usage`
|
||||
|
||||
O servidor, porem, continua com escopo limitado. Alguns campos sao aceitos por compatibilidade de contrato, mas isso nao significa paridade total com a plataforma OpenAI.
|
||||
|
||||
## Streaming
|
||||
|
||||
No modo `stream`, o backend usa SSE e envia chunks no formato `chat.completion.chunk`.
|
||||
|
||||
Dois modos de streaming existem internamente:
|
||||
|
||||
- `LiveTokens` - emite tokens incrementais quando o fluxo suporta token streaming
|
||||
- `Buffered` - produz a resposta inteira e a entrega em blocos compativeis
|
||||
|
||||
O chunk final pode incluir `usage` quando `stream_options.include_usage=true`.
|
||||
|
||||
## Erros e validacao
|
||||
|
||||
Os erros seguem `ErrorResponse { error: ApiError }` com:
|
||||
|
||||
- `message`
|
||||
- `type`
|
||||
- `param` opcional
|
||||
- `code` opcional
|
||||
|
||||
Validacoes conhecidas:
|
||||
|
||||
- `model` inexistente retorna `model_not_found`
|
||||
- `messages` vazia retorna `empty_messages`
|
||||
- `n != 1` retorna `unsupported_value`
|
||||
- `tools` com tool calls desabilitadas retorna `tool_calls_disabled`
|
||||
|
||||
## Limitacoes atuais
|
||||
|
||||
- nao ha endpoints publicos para embeddings, uploads, files ou responses API
|
||||
- `n` diferente de `1` nao e suportado
|
||||
- `response_format` e tool calling passam por compatibilidade de prompt/pos-processamento, nao por enforcement forte de decoder
|
||||
- `parallel_tool_calls` existe no contrato, mas nao deve ser tratado como capacidade plena documentada
|
||||
- `GET /health` e um health check operacional; nao e uma prova profunda de qualidade do modelo
|
||||
|
||||
Para referencia detalhada de requests e respostas, siga para `endpoints.md` e `configuration.md`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Comandos backend do app desktop
|
||||
|
||||
O backend Tauri exposto ao frontend vive principalmente em `tauri-app/src-tauri/src/lib.rs`.
|
||||
|
||||
Esta pagina documenta os comandos que o frontend usa para runtime, modelos e diagnostico.
|
||||
|
||||
## Runtime manager
|
||||
|
||||
### `bootstrap_runtime_command`
|
||||
|
||||
Carrega a configuracao persistida, reconstrui o snapshot do runtime e pode iniciar o runtime local automaticamente quando a politica e `on_app_launch` e o modo e `local`.
|
||||
|
||||
### `get_runtime_snapshot_command`
|
||||
|
||||
Retorna o `RuntimeSnapshot` atual com:
|
||||
|
||||
- `status`
|
||||
- `ready`
|
||||
- `process_running`
|
||||
- `resolved_url`
|
||||
- `binary_path`
|
||||
- `last_error`
|
||||
- `config`
|
||||
|
||||
### `save_runtime_config_command_handler`
|
||||
|
||||
Persiste `RuntimeConfig` e atualiza o estado backend usado pelo runtime manager.
|
||||
|
||||
Importante:
|
||||
|
||||
- se o processo local estiver rodando e a mudanca exigir restart, o backend pode recusar a alteracao com erro `restart_required`
|
||||
|
||||
### `start_runtime_command`
|
||||
|
||||
Inicia o runtime local usando a configuracao atualmente salva no backend Tauri.
|
||||
|
||||
### `stop_runtime_command`
|
||||
|
||||
Para o runtime local e atualiza o snapshot.
|
||||
|
||||
## GPU e modelos
|
||||
|
||||
### `query_gpu`
|
||||
|
||||
Usa `nvidia-smi` para detectar GPU NVIDIA e sugerir `recommended_layers`.
|
||||
|
||||
### `scan_models(dir)`
|
||||
|
||||
Escaneia um diretorio em busca de arquivos `.gguf`.
|
||||
|
||||
### `pick_models_dir`
|
||||
|
||||
Abre seletor nativo de pasta e devolve o diretorio escolhido junto com os modelos encontrados.
|
||||
|
||||
### `pick_model_files`
|
||||
|
||||
Abre seletor nativo de arquivos para escolher um ou mais `.gguf`.
|
||||
|
||||
### `import_model(src_path, dest_dir)`
|
||||
|
||||
Copia um arquivo `.gguf` para um diretorio de modelos.
|
||||
|
||||
## Configuracao e paths
|
||||
|
||||
### `save_config`
|
||||
|
||||
Compatibilidade legada para salvar `RuntimeConfig` em arquivo.
|
||||
|
||||
### `load_config`
|
||||
|
||||
Leitura da configuracao persistida.
|
||||
|
||||
### `find_server_binary`
|
||||
|
||||
Resolve o caminho do binario `localllm` para diagnostico e start local.
|
||||
|
||||
### `get_app_data_dir`
|
||||
|
||||
Retorna o diretorio de dados do app.
|
||||
|
||||
## Comandos legados de compatibilidade
|
||||
|
||||
O backend ainda expoe:
|
||||
|
||||
- `start_server`
|
||||
- `stop_server`
|
||||
- `server_status`
|
||||
|
||||
Esses comandos existem como shims de compatibilidade. O fluxo principal do frontend atual deve preferir o runtime manager unificado (`bootstrap_runtime_command`, `get_runtime_snapshot_command`, `save_runtime_config_command_handler`, `start_runtime_command`, `stop_runtime_command`).
|
||||
|
||||
## Tipos principais
|
||||
|
||||
### `RuntimeConfig`
|
||||
|
||||
Campos relevantes:
|
||||
|
||||
- `connection_mode`
|
||||
- `start_policy`
|
||||
- `server_url`
|
||||
- `api_key`
|
||||
- `model_path`
|
||||
- `models_dir`
|
||||
- `addr`
|
||||
- `gpu_layers`
|
||||
- `ctx_size`
|
||||
- `extra_model_paths`
|
||||
|
||||
### `RuntimeSnapshot`
|
||||
|
||||
Representa o estado serializado usado pelo frontend para desenhar o runtime card, drawer e logica de start/stop.
|
||||
|
||||
## Observacoes importantes
|
||||
|
||||
- o backend Tauri e a source of truth da configuracao persistida do runtime do app
|
||||
- o frontend usa `useRuntimeManager()` para consumir essa superficie de comandos
|
||||
- readiness do runtime local depende de `GET /health`, nao apenas de socket aberto
|
||||
@@ -0,0 +1,114 @@
|
||||
# Visao geral do app desktop
|
||||
|
||||
O app desktop do ForgeLM vive em `tauri-app/` e combina:
|
||||
|
||||
- frontend React/Vite
|
||||
- backend Tauri em Rust
|
||||
- runtime manager para API local/remota
|
||||
- chat persistente com banco local em memoria exportado para `localStorage`
|
||||
|
||||
## Funcao do app
|
||||
|
||||
O app foi desenhado para ser a interface principal de uso do runtime local. Ele permite:
|
||||
|
||||
- iniciar ou parar a API local quando o modo e `local`
|
||||
- apontar para uma API existente quando o modo e `remote`
|
||||
- escolher/importar modelos `.gguf`
|
||||
- conversar com o backend via `POST /v1/chat/completions`
|
||||
- persistir historico de chats e mensagens
|
||||
- separar trechos de reasoning (`<think>...</think>`) da resposta final no frontend
|
||||
|
||||
## Arquitetura em alto nivel
|
||||
|
||||
### Frontend
|
||||
|
||||
Arquivos principais:
|
||||
|
||||
- `src/App.tsx` - shell principal do app
|
||||
- `src/components/Sidebar.tsx` - sidebar com runtime card, seletor de modelo e lista de chats
|
||||
- `src/components/ChatView.tsx` - fluxo de chat, streaming, memoria e persistencia de mensagens
|
||||
- `src/features/runtime/*` - runtime manager unificado
|
||||
|
||||
### Backend Tauri
|
||||
|
||||
Arquivos principais:
|
||||
|
||||
- `src-tauri/src/lib.rs` - comandos Tauri expostos ao frontend
|
||||
- `src-tauri/src/runtime/*` - estado, config, spawn, health probe e snapshot do runtime local
|
||||
|
||||
## Runtime manager
|
||||
|
||||
O runtime manager e a peca central do app.
|
||||
|
||||
Estados principais:
|
||||
|
||||
- `stopped`
|
||||
- `starting`
|
||||
- `ready`
|
||||
- `degraded`
|
||||
- `stopping`
|
||||
- `error`
|
||||
|
||||
Modos de conexao:
|
||||
|
||||
- `local` - o app controla o processo do backend
|
||||
- `remote` - o app so consome uma API externa ja existente
|
||||
|
||||
O frontend usa `useRuntimeManager()` como source of truth para:
|
||||
|
||||
- snapshot atual do runtime
|
||||
- URL resolvida da API
|
||||
- save de config
|
||||
- start/stop do runtime local
|
||||
|
||||
## Selecao de modelos
|
||||
|
||||
O app carrega modelos via `GET /v1/models` usando `useModels()`.
|
||||
|
||||
Comportamentos importantes:
|
||||
|
||||
- se nao houver endpoint configurado, o estado fica em erro e a lista e limpa
|
||||
- se a API responder sem modelos, o model selector fica vazio
|
||||
- se o modelo antes selecionado nao existir mais na lista, o app seleciona o primeiro disponivel
|
||||
|
||||
## Persistencia local
|
||||
|
||||
O chat usa `sql.js` com schema local gerenciado por `src/lib/db.ts`.
|
||||
|
||||
Tabelas principais:
|
||||
|
||||
- `chats`
|
||||
- `messages`
|
||||
|
||||
Campos persistidos por mensagem incluem:
|
||||
|
||||
- `role`
|
||||
- `content`
|
||||
- `reasoning_content`
|
||||
- `prompt_tokens`
|
||||
- `completion_tokens`
|
||||
- `total_tokens`
|
||||
- `tokens_per_second`
|
||||
|
||||
Importante: isso nao e um banco nativo do sistema operacional. O app injeta `sql.js` por CDN e persiste a exportacao do banco em `localStorage`.
|
||||
|
||||
## Chat e streaming
|
||||
|
||||
`ChatView.tsx` envia requests para `POST /v1/chat/completions`.
|
||||
|
||||
Recursos atuais:
|
||||
|
||||
- streaming opcional
|
||||
- memoria opcional com ate 20 mensagens recentes
|
||||
- separacao visual de reasoning (`<think>...</think>`) no frontend
|
||||
- contadores de tokens por mensagem e por chat
|
||||
- edicao, reenvio e exclusao de mensagens
|
||||
|
||||
## Limitacoes atuais
|
||||
|
||||
- o app depende de um binario `localllm` acessivel quando o modo local e usado
|
||||
- o fallback browser-only nao substitui os recursos desktop do Tauri
|
||||
- o chat depende da pequena API HTTP atual; o app nao expoe magica propria fora dela
|
||||
- o runtime manager faz polling periodico do snapshot; ele nao usa ainda um fluxo completo de eventos push
|
||||
|
||||
Para uso pratico, siga para `user-guide.md`.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Guia de uso do app desktop
|
||||
|
||||
Este guia cobre o fluxo de uso do app Tauri no estado atual do projeto.
|
||||
|
||||
## 1. Abrir o app
|
||||
|
||||
Em desenvolvimento:
|
||||
|
||||
```bash
|
||||
cd tauri-app
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
## 2. Entender os modos de conexao
|
||||
|
||||
O Runtime Manager suporta dois modos.
|
||||
|
||||
### Local API
|
||||
|
||||
Use quando o app deve subir e controlar o backend local.
|
||||
|
||||
Voce configura:
|
||||
|
||||
- pasta de modelos
|
||||
- modelo selecionado
|
||||
- endereco de bind (`addr`)
|
||||
- numero de GPU layers
|
||||
- contexto (`ctx_size`)
|
||||
- politica de auto-start
|
||||
|
||||
### Remote API
|
||||
|
||||
Use quando a API ja esta rodando em outro lugar.
|
||||
|
||||
Voce configura:
|
||||
|
||||
- `server_url`
|
||||
- `api_key` opcional
|
||||
|
||||
Nesse modo o app nao tenta iniciar/parar o backend local.
|
||||
|
||||
## 3. Configurar o runtime local
|
||||
|
||||
Na sidebar, abra `Manage` ou o icone de configuracao.
|
||||
|
||||
Fluxo recomendado:
|
||||
|
||||
1. deixe `Connection mode` em `Local API`
|
||||
2. escolha a pasta de modelos com `Browse`, ou adicione arquivos individuais com `Add files`
|
||||
3. selecione o modelo desejado
|
||||
4. ajuste `Listen address`, `GPU layers` e `Context size` se necessario
|
||||
5. use `Save settings` ou `Save and start local API`
|
||||
|
||||
Observacoes:
|
||||
|
||||
- o app tenta descobrir o binario do backend automaticamente
|
||||
- se uma revarredura da pasta vier vazia, o app preserva o `model_path` salvo em vez de limpar a selecao imediatamente
|
||||
- o start local so fica disponivel quando existe binario e modelo selecionado
|
||||
|
||||
## 4. Configurar API remota
|
||||
|
||||
1. abra o Runtime Manager
|
||||
2. mude para `Remote API`
|
||||
3. preencha `Remote server URL`
|
||||
4. opcionalmente, preencha `API key`
|
||||
5. salve a configuracao
|
||||
|
||||
Depois disso, o app passa a usar essa URL para `GET /v1/models` e `POST /v1/chat/completions`.
|
||||
|
||||
## 5. Selecionar modelos no app
|
||||
|
||||
Quando a API estiver acessivel:
|
||||
|
||||
- o seletor de modelo na sidebar sera preenchido por `GET /v1/models`
|
||||
- o app tenta manter a selecao atual quando possivel
|
||||
- se o modelo sumir da API, o app troca para o primeiro disponivel
|
||||
|
||||
## 6. Conversar no chat
|
||||
|
||||
O fluxo principal fica em `ChatView`.
|
||||
|
||||
Recursos de uso:
|
||||
|
||||
- streaming on/off
|
||||
- memory on/off
|
||||
- historico persistente por conversa
|
||||
- edicao e reenvio de mensagens do usuario
|
||||
- edicao de resposta do assistente
|
||||
- copia de conteudo/markdown/codigo
|
||||
|
||||
## 7. Thinking e tokens
|
||||
|
||||
Quando a resposta inclui blocos `<think>...</think>`:
|
||||
|
||||
- o app separa esse conteudo como `Thinking`
|
||||
- o painel de thinking pode ser expandido/recolhido
|
||||
- a resposta final aparece separadamente do thinking
|
||||
|
||||
Metricas visiveis:
|
||||
|
||||
- tokens enviados (`Sent`)
|
||||
- tokens gerados (`Generated`)
|
||||
- total por mensagem e por chat
|
||||
- `tok/s` quando informado pela API
|
||||
|
||||
## 8. Troubleshooting rapido
|
||||
|
||||
### "Configure a local or remote API before sending messages"
|
||||
|
||||
O app esta sem endpoint valido. Abra o Runtime Manager e configure modo local ou remoto.
|
||||
|
||||
### Nenhum modelo aparece
|
||||
|
||||
- confirme que a API esta acessivel
|
||||
- use o botao de refresh da sidebar
|
||||
- no modo local, confirme que o modelo salvo foi iniciado corretamente
|
||||
|
||||
### O start local esta desabilitado
|
||||
|
||||
Verifique:
|
||||
|
||||
- se o binario do backend foi encontrado
|
||||
- se um modelo esta salvo/selecionado
|
||||
- se voce nao esta em modo remoto
|
||||
|
||||
### O runtime sobe mas nunca fica pronto
|
||||
|
||||
O app usa readiness por `GET /health`. Se a API local nao passar nesse check dentro do timeout, o runtime manager marca erro.
|
||||
|
||||
## 9. Persistencia e cuidado com dados
|
||||
|
||||
- chats e mensagens ficam no armazenamento local do app/frontend
|
||||
- o app nao implementa sincronizacao remota de conversas
|
||||
- apagar dados do navegador/webview pode remover o historico local
|
||||
@@ -0,0 +1,235 @@
|
||||
# Referencia de comandos da CLI
|
||||
|
||||
Esta pagina lista a superficie atual da CLI `localllm`.
|
||||
|
||||
## Sintaxe geral
|
||||
|
||||
```bash
|
||||
localllm [flags-globais] <comando> [subcomando] [args]
|
||||
```
|
||||
|
||||
## Flags globais
|
||||
|
||||
- `--server <url>`
|
||||
- `--dry-run`
|
||||
- `--api-key <token>`
|
||||
- `--models-dir <path>`
|
||||
- `--format text|json`
|
||||
|
||||
## status
|
||||
|
||||
Verifica o estado do backend.
|
||||
|
||||
```bash
|
||||
localllm status
|
||||
```
|
||||
|
||||
Uso tipico:
|
||||
|
||||
- testar se o backend esta de pe
|
||||
- validar auth e reachability
|
||||
- integrar checks simples em scripts
|
||||
|
||||
## models
|
||||
|
||||
### models list
|
||||
|
||||
Lista os modelos expostos pelo backend.
|
||||
|
||||
```bash
|
||||
localllm models list
|
||||
```
|
||||
|
||||
### models scan
|
||||
|
||||
Escaneia um diretorio local de modelos.
|
||||
|
||||
```bash
|
||||
localllm --models-dir ./models models scan
|
||||
```
|
||||
|
||||
### models load
|
||||
|
||||
Aceita um nome de modelo e alias opcional.
|
||||
|
||||
```bash
|
||||
localllm models load meu-modelo.gguf --alias default
|
||||
```
|
||||
|
||||
### models unload
|
||||
|
||||
Aceita um modelo opcional.
|
||||
|
||||
```bash
|
||||
localllm models unload
|
||||
localllm models unload default
|
||||
```
|
||||
|
||||
### models use
|
||||
|
||||
Seleciona um modelo por nome.
|
||||
|
||||
```bash
|
||||
localllm models use default
|
||||
```
|
||||
|
||||
### models watch
|
||||
|
||||
Superficie prevista para acompanhar alteracoes/model lifecycle.
|
||||
|
||||
```bash
|
||||
localllm models watch
|
||||
```
|
||||
|
||||
### models download
|
||||
|
||||
Aceita um repositorio como argumento.
|
||||
|
||||
```bash
|
||||
localllm models download org/modelo
|
||||
```
|
||||
|
||||
### models remove
|
||||
|
||||
Remove um modelo por nome.
|
||||
|
||||
```bash
|
||||
localllm models remove default
|
||||
```
|
||||
|
||||
### Observacao importante sobre `models`
|
||||
|
||||
No estado atual do projeto, a parte mais confiavel e comprovada contra o backend HTTP atual e `models list`. Os demais subcomandos devem ser tratados como superficie de operacao em evolucao, local-only ou ainda nao suportada pelo backend publico atual.
|
||||
|
||||
## auth
|
||||
|
||||
### auth status
|
||||
|
||||
```bash
|
||||
localllm auth status
|
||||
```
|
||||
|
||||
### auth enable
|
||||
|
||||
```bash
|
||||
localllm auth enable --mode protected --api-key dev-secret
|
||||
```
|
||||
|
||||
Modos aceitos:
|
||||
|
||||
- `off`
|
||||
- `localonly`
|
||||
- `protected`
|
||||
|
||||
### auth disable
|
||||
|
||||
```bash
|
||||
localllm auth disable
|
||||
```
|
||||
|
||||
### auth set-key
|
||||
|
||||
```bash
|
||||
localllm auth set-key dev-secret
|
||||
```
|
||||
|
||||
### Observacao importante sobre `auth`
|
||||
|
||||
Os subcomandos existem na superficie da CLI, mas o backend HTTP publico atual nao expoe uma API administrativa completa para auth. Documente esses comandos como superficie de controle/planejamento, nao como garantia de operacao remota ja fechada.
|
||||
|
||||
## config
|
||||
|
||||
### config get
|
||||
|
||||
```bash
|
||||
localllm config get <chave>
|
||||
```
|
||||
|
||||
### config set
|
||||
|
||||
```bash
|
||||
localllm config set <chave> <valor>
|
||||
```
|
||||
|
||||
Mesma observacao de `auth`: a superficie existe, mas a administracao remota completa ainda nao esta refletida na pequena API HTTP publica atual.
|
||||
|
||||
## bench
|
||||
|
||||
### bench throughput
|
||||
|
||||
Benchmark de throughput com parametros configuraveis.
|
||||
|
||||
```bash
|
||||
localllm bench throughput --model default --samples 3 --prompt-tokens 256 --completion-tokens 128
|
||||
```
|
||||
|
||||
Parametros atuais:
|
||||
|
||||
- `--model` (default `default`)
|
||||
- `--samples` (default `3`)
|
||||
- `--prompt-tokens` (default `256`)
|
||||
- `--completion-tokens` (default `128`)
|
||||
|
||||
### bench smoke
|
||||
|
||||
```bash
|
||||
localllm bench smoke
|
||||
```
|
||||
|
||||
### bench suite
|
||||
|
||||
```bash
|
||||
localllm bench suite
|
||||
```
|
||||
|
||||
### bench compare
|
||||
|
||||
```bash
|
||||
localllm bench compare baseline candidate
|
||||
```
|
||||
|
||||
### Observacao importante sobre `bench`
|
||||
|
||||
O fluxo mais claramente alinhado com o backend atual e `bench throughput`.
|
||||
|
||||
## logs
|
||||
|
||||
Leitura de logs com limite de linhas.
|
||||
|
||||
```bash
|
||||
localllm logs --lines 200
|
||||
```
|
||||
|
||||
## doctor
|
||||
|
||||
Superficie pensada para diagnostico do ambiente.
|
||||
|
||||
```bash
|
||||
localllm doctor
|
||||
```
|
||||
|
||||
## Exemplos com flags globais
|
||||
|
||||
### JSON
|
||||
|
||||
```bash
|
||||
localllm --format json status
|
||||
```
|
||||
|
||||
### Dry run
|
||||
|
||||
```bash
|
||||
localllm --dry-run models list
|
||||
```
|
||||
|
||||
### Backend remoto com auth
|
||||
|
||||
```bash
|
||||
localllm --server http://127.0.0.1:8080 --api-key dev-secret status
|
||||
```
|
||||
|
||||
## Resumo honesto do estado atual
|
||||
|
||||
- parser e superficie de comandos sao mais amplos que a API HTTP publica atual
|
||||
- para automacao confiavel hoje, priorize `status`, `models list` e `bench throughput`
|
||||
- use `--dry-run` quando estiver explorando comandos administrativos ainda em evolucao
|
||||
@@ -0,0 +1,104 @@
|
||||
# Visao geral da CLI
|
||||
|
||||
O binario `localllm` e a interface de linha de comando do projeto. Ele foi desenhado para diagnostico, automacao e operacao do backend local, com suporte a saida em texto ou JSON.
|
||||
|
||||
## Onde esta a CLI
|
||||
|
||||
- codigo: `crates/localllm-cli/src/main.rs`
|
||||
- nome do binario: `localllm`
|
||||
|
||||
## Flags globais
|
||||
|
||||
As flags globais atuais sao:
|
||||
|
||||
- `--server` - base URL do backend, default `http://127.0.0.1:8080`
|
||||
- `--dry-run` - mostra a acao planejada sem executar a chamada real
|
||||
- `--api-key` - chave de auth opcional
|
||||
- `--models-dir` - diretorio local de modelos, default `./models`
|
||||
- `--format text|json` - formato de saida
|
||||
|
||||
## Superficie de comandos
|
||||
|
||||
Comandos raiz atuais:
|
||||
|
||||
- `status`
|
||||
- `models`
|
||||
- `auth`
|
||||
- `config`
|
||||
- `bench`
|
||||
- `logs`
|
||||
- `doctor`
|
||||
|
||||
## Filosofia atual
|
||||
|
||||
A CLI hoje mistura tres tipos de fluxo:
|
||||
|
||||
- comandos com execucao HTTP real contra o backend atual
|
||||
- comandos locais/de operacao previstos para uma superficie maior de controle
|
||||
- comandos que ja possuem parsing e planejamento, mas ainda nao estao todos ligados a uma acao remota completa
|
||||
|
||||
Essa distincao importa para documentacao: a CLI tem uma superficie ampla, mas a parte realmente comprovada contra o backend HTTP atual e menor.
|
||||
|
||||
## O que esta mais consolidado hoje
|
||||
|
||||
Os fluxos mais maduros no estado atual do repositorio sao:
|
||||
|
||||
- `status`
|
||||
- `models list`
|
||||
- `bench throughput`
|
||||
|
||||
Esses fluxos fazem mais sentido quando o backend `inference-server` esta rodando.
|
||||
|
||||
## Formatos de saida
|
||||
|
||||
### Texto
|
||||
|
||||
Bom para uso humano, leitura manual e diagnostico rapido.
|
||||
|
||||
### JSON
|
||||
|
||||
Bom para scripts, CI e integracoes. Use `--format json`.
|
||||
|
||||
## Dry run
|
||||
|
||||
Use `--dry-run` quando quiser validar qual URL, qual operacao ou qual tipo de acao a CLI pretende executar sem realmente disparar a operacao.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```bash
|
||||
cargo run -p localllm-cli -- --dry-run status
|
||||
```
|
||||
|
||||
## Exemplos rapidos
|
||||
|
||||
### Verificar saude do backend
|
||||
|
||||
```bash
|
||||
cargo run -p localllm-cli -- status
|
||||
```
|
||||
|
||||
### Listar modelos via API
|
||||
|
||||
```bash
|
||||
cargo run -p localllm-cli -- models list
|
||||
```
|
||||
|
||||
### Rodar benchmark de throughput
|
||||
|
||||
```bash
|
||||
cargo run -p localllm-cli -- bench throughput --samples 3 --prompt-tokens 256 --completion-tokens 128
|
||||
```
|
||||
|
||||
### Usar auth
|
||||
|
||||
```bash
|
||||
cargo run -p localllm-cli -- --api-key dev-secret status
|
||||
```
|
||||
|
||||
## Limitacoes atuais
|
||||
|
||||
- nem todos os subcomandos do parser correspondem a capacidades HTTP publicas do backend atual
|
||||
- a CLI foi expandida por etapas; por isso alguns comandos sao melhor entendidos como superficie de controle em evolucao
|
||||
- documente e use os comandos de operacao real com mais confianca em `status`, `models list` e `bench throughput`
|
||||
|
||||
Para a referencia completa, siga para `commands.md`.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Arquitetura do projeto
|
||||
|
||||
Esta pagina resume a arquitetura atual do workspace ForgeLM para quem vai manter ou evoluir o sistema.
|
||||
|
||||
## Mapa do workspace Rust
|
||||
|
||||
O `Cargo.toml` raiz declara os membros:
|
||||
|
||||
- `crates/llama-sys`
|
||||
- `crates/inference-core`
|
||||
- `crates/inference-server`
|
||||
- `crates/api-contracts`
|
||||
- `crates/metrics`
|
||||
- `crates/localllm-cli`
|
||||
|
||||
O app desktop fica fora do workspace Rust principal, em `tauri-app/`, mas conversa diretamente com esse ecossistema por binario, comandos Tauri e HTTP local.
|
||||
|
||||
## Responsabilidade de cada modulo
|
||||
|
||||
### `api-contracts`
|
||||
|
||||
Tipos compartilhados para:
|
||||
|
||||
- chat completions
|
||||
- model listing
|
||||
- health
|
||||
- payloads de erro
|
||||
|
||||
Serve como contrato comum entre `inference-server` e consumidores da API.
|
||||
|
||||
### `inference-core`
|
||||
|
||||
Responsavel pela engine de inferencia local:
|
||||
|
||||
- carga do modelo
|
||||
- sessao de inferencia
|
||||
- sampler
|
||||
- perfis de runtime
|
||||
|
||||
### `inference-server`
|
||||
|
||||
Camada HTTP baseada em Axum:
|
||||
|
||||
- leitura de env vars
|
||||
- montagem de `ServerConfig`
|
||||
- auth, CORS e aliases de modelos
|
||||
- endpoints `/health`, `/v1/models`, `/v1/chat/completions`
|
||||
|
||||
### `localllm-cli`
|
||||
|
||||
CLI de operacao e automacao. Hoje ela ja tem uma superficie ampla, mas a parte com execucao real comprovada contra o backend publico atual esta mais concentrada em `status`, `models list` e `bench throughput`.
|
||||
|
||||
### `metrics`
|
||||
|
||||
Contadores e registros de throughput/token usage usados no backend.
|
||||
|
||||
### `llama-sys`
|
||||
|
||||
Bindings e integracao de baixo nivel com a stack de inferencia.
|
||||
|
||||
### `tauri-app`
|
||||
|
||||
App desktop composto por:
|
||||
|
||||
- frontend React/Vite
|
||||
- backend Tauri em Rust
|
||||
- runtime manager local/remoto
|
||||
- chat persistente com `sql.js`
|
||||
|
||||
## Fluxo entre API, CLI e app
|
||||
|
||||
## 1. Backend HTTP
|
||||
|
||||
`inference-server` sobe, carrega o modelo e expoe a API HTTP.
|
||||
|
||||
## 2. CLI
|
||||
|
||||
A CLI pode falar com a API HTTP por URL configurada via `--server` e `--api-key`.
|
||||
|
||||
## 3. App desktop
|
||||
|
||||
O app usa duas camadas:
|
||||
|
||||
- backend Tauri para gerenciar processo, configuracao e integracoes locais
|
||||
- frontend React para runtime manager, selecao de modelos e chat
|
||||
|
||||
Quando em modo local, o app inicia/para o backend por meio do runtime manager do Tauri. Quando em modo remoto, o app apenas consome uma URL externa.
|
||||
|
||||
## Runtime manager do app
|
||||
|
||||
Arquivos centrais:
|
||||
|
||||
- `tauri-app/src/features/runtime/useRuntimeManager.ts`
|
||||
- `tauri-app/src/features/runtime/types.ts`
|
||||
- `tauri-app/src/features/runtime/components/RuntimeStatusCard.tsx`
|
||||
- `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx`
|
||||
- `tauri-app/src-tauri/src/runtime/*`
|
||||
|
||||
Conceitos importantes:
|
||||
|
||||
- `RuntimeConfig` e persistido no backend Tauri
|
||||
- `RuntimeSnapshot` e consumido pelo frontend
|
||||
- modo `local` e `remote` compartilham a mesma superficie de configuracao
|
||||
- readiness do modo local depende de `GET /health`
|
||||
|
||||
## Chat e persistencia
|
||||
|
||||
Arquivos centrais:
|
||||
|
||||
- `tauri-app/src/components/ChatView.tsx`
|
||||
- `tauri-app/src/components/MessageBubble.tsx`
|
||||
- `tauri-app/src/lib/db.ts`
|
||||
- `tauri-app/src/lib/chat.ts`
|
||||
|
||||
Caracteristicas importantes:
|
||||
|
||||
- conversas e mensagens ficam em `sql.js` persistido em `localStorage`
|
||||
- o app separa reasoning de resposta final ao analisar `<think>...</think>`
|
||||
- tokens por mensagem e por chat ficam persistidos
|
||||
- memoria do chat envia ate 20 mensagens recentes quando habilitada
|
||||
|
||||
## Limites e fronteiras atuais
|
||||
|
||||
- a API publica ainda e pequena; nao documente ou desenvolva assumindo uma plataforma completa estilo OpenAI
|
||||
- a CLI esta mais avancada no parser/superficie do que nas capacidades HTTP publicas realmente disponiveis
|
||||
- o app desktop e a interface mais rica do projeto hoje, mas ainda depende do backend HTTP local/remoto para inferencia real
|
||||
|
||||
## Termos recomendados
|
||||
|
||||
- `runtime` - processo + configuracao + estado operacional do backend local
|
||||
- `API local` - o `inference-server` rodando na maquina do usuario
|
||||
- `modo local` - o app controla o processo
|
||||
- `modo remoto` - o app consome uma API externa
|
||||
- `snapshot de runtime` - estado serializado do runtime usado pelo frontend
|
||||
|
||||
## Leitura complementar
|
||||
|
||||
- `../api/overview.md`
|
||||
- `../cli/overview.md`
|
||||
- `../app/overview.md`
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_command(command: list[str], cwd: Path) -> tuple[int, str]:
|
||||
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
if output:
|
||||
sys.stdout.write(output)
|
||||
if not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
return completed.returncode, output
|
||||
|
||||
|
||||
def command_name(name: str) -> str:
|
||||
if os.name == "nt" and name == "npm":
|
||||
return "npm.cmd"
|
||||
return name
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
tauri_app = root / "tauri-app"
|
||||
tauri_backend = tauri_app / "src-tauri"
|
||||
|
||||
unit_command = [
|
||||
command_name("npm"),
|
||||
"test",
|
||||
"--",
|
||||
"src/features/runtime/runtime-utils.test.ts",
|
||||
"src/features/runtime/components/RuntimeSurface.test.tsx",
|
||||
]
|
||||
saved_startup_command = [
|
||||
command_name("npm"),
|
||||
"test",
|
||||
"--",
|
||||
"src/features/runtime/components/RuntimeSurface.test.tsx",
|
||||
"-t",
|
||||
"covers the saved local startup lifecycle from stopped to starting to ready",
|
||||
]
|
||||
local_onboarding_command = [
|
||||
command_name("npm"),
|
||||
"test",
|
||||
"--",
|
||||
"src/features/runtime/components/RuntimeSurface.test.tsx",
|
||||
"-t",
|
||||
"supports local onboarding from browsing a models folder through save and start",
|
||||
]
|
||||
local_mode_cta_command = [
|
||||
command_name("npm"),
|
||||
"test",
|
||||
"--",
|
||||
"src/features/runtime/components/RuntimeSurface.test.tsx",
|
||||
"-t",
|
||||
"keeps the local primary CTA visible while switching from remote to local mode",
|
||||
]
|
||||
build_command = [command_name("npm"), "run", "build"]
|
||||
cargo_check_command = [
|
||||
command_name("cargo"),
|
||||
"check",
|
||||
"--manifest-path",
|
||||
str(tauri_backend / "Cargo.toml"),
|
||||
]
|
||||
|
||||
unit_code, _ = run_command(unit_command, tauri_app)
|
||||
saved_startup_code, _ = run_command(saved_startup_command, tauri_app)
|
||||
local_onboarding_code, _ = run_command(local_onboarding_command, tauri_app)
|
||||
local_mode_cta_code, _ = run_command(local_mode_cta_command, tauri_app)
|
||||
build_code, _ = run_command(build_command, tauri_app)
|
||||
cargo_check_code, _ = run_command(cargo_check_command, root)
|
||||
|
||||
unit_passed = 1 if unit_code == 0 else 0
|
||||
saved_startup_flow_passed = 1 if saved_startup_code == 0 else 0
|
||||
local_onboarding_flow_passed = 1 if local_onboarding_code == 0 else 0
|
||||
local_mode_cta_passed = 1 if local_mode_cta_code == 0 else 0
|
||||
build_passed = 1 if build_code == 0 else 0
|
||||
cargo_check_passed = 1 if cargo_check_code == 0 else 0
|
||||
|
||||
total_checks = 6
|
||||
passed_checks = (
|
||||
unit_passed
|
||||
+ saved_startup_flow_passed
|
||||
+ local_onboarding_flow_passed
|
||||
+ local_mode_cta_passed
|
||||
+ build_passed
|
||||
+ cargo_check_passed
|
||||
)
|
||||
score = round((passed_checks / total_checks) * 100.0, 3)
|
||||
|
||||
print(f"METRIC runtime_lifecycle_score={score}")
|
||||
print(f"METRIC unit_passed={unit_passed}")
|
||||
print(f"METRIC local_mode_cta_passed={local_mode_cta_passed}")
|
||||
print(f"METRIC saved_startup_flow_passed={saved_startup_flow_passed}")
|
||||
print(f"METRIC local_onboarding_flow_passed={local_onboarding_flow_passed}")
|
||||
print(f"METRIC build_passed={build_passed}")
|
||||
print(f"METRIC cargo_check_passed={cargo_check_passed}")
|
||||
|
||||
return 0 if passed_checks == total_checks else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -380,3 +380,50 @@
|
||||
- Result: cli_surface_score=100.0, passed_cases=69, failed_cases=0, total_cases=69.
|
||||
- Insight: The CLI now has a real HTTP client (`ureq`) wired end-to-end for live-compatible commands. The injectable transport seam remains available for testing without a running server. The CLI is functionally complete for the commands that the current backend already supports.
|
||||
- Next: CLI is at natural saturation given the current backend. Further progress requires new admin/control-plane endpoints in the backend for scan, load, auth, config, and watch commands.
|
||||
|
||||
## Segment 18: Unified Tauri Runtime Manager
|
||||
|
||||
- Objective: replace the split desktop runtime control flow with one saved runtime-manager surface that owns local startup, remote routing, and local model/runtime settings.
|
||||
- Benchmark: targeted verification bundle using `npm test`, `npm run build`, and `cargo check` plus acceptance checks for a single runtime control plane and updated runtime UI.
|
||||
- Keep/discard rule: a keep requires one working config/start path, no broken sidebar start button, and green verification across frontend and Rust checks.
|
||||
|
||||
### Run 40: unify runtime manager config/start flow and refactor runtime UI - runtime_surface_score=100.0 (KEEP)
|
||||
- Timestamp: 2026-03-26 23:35
|
||||
- What changed: Removed the active dependency on `useServerProcess` and the embedded legacy `ServerPanel` from the sidebar flow, rebuilt `RuntimeSettingsDrawer` into a full local/remote runtime workspace, added Tauri wrappers for GPU/model picking and scanning, made the sidebar runtime card route setup/start from the same saved runtime state, and refreshed runtime/sidebar/global styling toward a cleaner AI desktop identity.
|
||||
- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, unified_control_plane=1, design_refactor_done=1.
|
||||
- Insight: The runtime failures were caused by state ownership drift, not startup logic alone. Once local runtime configuration and startup lived in the same domain, both runtime entry points aligned and the broken start path disappeared.
|
||||
- Next: improve readiness probing beyond raw TCP-open detection, then smoke-test the desktop runtime end to end against a real local model and record a stricter follow-up score for startup/readiness behavior.
|
||||
|
||||
### Run 41: replace tcp-only readiness with /health readiness probe - runtime_surface_score=100.0 (KEEP)
|
||||
- Timestamp: 2026-03-26 23:43
|
||||
- What changed: Replaced the runtime startup readiness check in `tauri-app/src-tauri/src/runtime/health.rs` so it now issues `GET /health`, requires an HTTP 200 response, and validates `{"status":"ok"}` before marking the local API as ready. Added Rust tests covering healthy HTTP success, unhealthy JSON payload rejection, and plain-open-port rejection.
|
||||
- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, health_probe_passed=1.
|
||||
- Insight: TCP-open was too weak for a desktop-managed API because any bound socket could masquerade as readiness. A lightweight health request gives the runtime manager a much more truthful startup signal without introducing frontend complexity.
|
||||
- Next: run an end-to-end desktop smoke test against a real local model so the next segment can score startup behavior from the actual app surface, not just backend verification.
|
||||
|
||||
### Run 42: add runtime UI smoke coverage and preserve saved model on empty rescans - runtime_surface_score=100.0 (KEEP)
|
||||
- Timestamp: 2026-03-26 23:50
|
||||
- What changed: Added jsdom-based runtime smoke tests for `RuntimeStatusCard` and `RuntimeSettingsDrawer`, installed `jsdom` for Vitest browserless component coverage, and fixed `chooseModel()` in `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` so a previously saved `model_path` is preserved when a models-folder rescan returns no results.
|
||||
- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, runtime_smoke_passed=1.
|
||||
- Insight: The first useful app-surface smoke test immediately caught a real regression that backend/runtime checks could not see: a valid saved model could disappear from drawer state after an empty scan, which silently disabled the unified start action.
|
||||
- Next: push the smoke coverage one step further with a focused end-to-end startup path check, ideally validating that a saved local config transitions from setup -> starting -> ready correctly from the desktop surface.
|
||||
|
||||
## Segment 19: Tauri Runtime Lifecycle Acceptance
|
||||
|
||||
- Objective: replace the stale CLI-oriented benchmark with a Tauri desktop lifecycle benchmark and harden the app-surface contract around local/remote mode transitions.
|
||||
- Benchmark: `./autoresearch.sh` now runs `experiments/bench_tauri_runtime_surface.py`, which scores runtime-focused frontend tests plus `npm run build` and `cargo check`.
|
||||
- Keep/discard rule: higher `runtime_lifecycle_score` wins while the active desktop runtime CTA and verification bundle remain green.
|
||||
|
||||
### Run 43: align the Tauri lifecycle harness and keep the local CTA visible during mode switches - runtime_lifecycle_score=100.0 (KEEP)
|
||||
- Timestamp: 2026-03-27 01:26
|
||||
- What changed: Replaced the stale CLI benchmark in `autoresearch.sh` with a new `experiments/bench_tauri_runtime_surface.py` driver, added a runtime smoke test that switches `RuntimeSettingsDrawer` from remote to local mode, and fixed the drawer footer in `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` so the local primary CTA is keyed off the edited local `mode` instead of the previously saved `snapshot.config.connection_mode`.
|
||||
- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1.
|
||||
- Insight: The previous segment was saturated partly because the harness was no longer measuring the live desktop contract. Once the benchmark targeted the Tauri runtime surface directly, it immediately exposed a real lifecycle regression: switching from remote to local mode could hide the apply/start CTA before save, making the saved local flow feel broken even though the backend state was valid.
|
||||
- Next: raise the lifecycle score again with a stricter saved-startup path check, ideally covering the `stopped -> starting -> ready` transition and degraded behavior after a failed health revalidation.
|
||||
|
||||
### Run 44: cover saved local startup transitions and local onboarding from browse to ready - runtime_lifecycle_score=100.0 (KEEP)
|
||||
- Timestamp: 2026-03-27 01:40
|
||||
- What changed: Extended `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` with two hook-backed lifecycle checks that run through `useRuntimeManager`: one now requires a saved local config to expose `Local API offline -> Starting local API -> Ready`, and another exercises local onboarding by browsing a models folder, auto-selecting the discovered model, saving config, and starting the local API from the same drawer flow. Updated `tauri-app/src/features/runtime/useRuntimeManager.ts` to publish optimistic `starting` and `stopping` snapshots while the Tauri commands are in flight. Raised `experiments/bench_tauri_runtime_surface.py` so the benchmark now records `local_mode_cta_passed`, `saved_startup_flow_passed`, and `local_onboarding_flow_passed` as explicit lifecycle checks alongside the broader runtime suite.
|
||||
- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1, saved_startup_flow_passed=1, local_onboarding_flow_passed=1.
|
||||
- Insight: The saved local startup flow was previously trustworthy only after the backend returned; the desktop surface showed `Working...` but skipped the visible `starting` state entirely. Moving the in-flight state into `useRuntimeManager` closes that UX gap and makes the status card/drawer reflect the real lifecycle immediately. The onboarding smoke also proves the unified drawer now covers browse -> auto-select -> save -> start without falling back to separate legacy controls.
|
||||
- Next: keep the lifecycle segment focused on truthfulness after startup, especially periodic `/health` revalidation that can demote a stale `ready` process into `degraded` when the child stops serving correctly.
|
||||
-1
@@ -1 +0,0 @@
|
||||
.sidebar{width:260px;min-width:260px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}.sidebar-header{display:flex;align-items:center;padding:14px 14px 12px;border-bottom:1px solid var(--border);gap:8px}.sidebar-brand{display:flex;align-items:center;gap:8px;flex:1}.brand-icon{font-size:1.1rem}.brand-name{font-size:.95rem;font-weight:700;letter-spacing:-.01em}.icon-btn{background:none;border:none;color:var(--text2);cursor:pointer;font-size:1rem;padding:4px 6px;border-radius:var(--radius-sm);transition:background .15s,color .15s;line-height:1}.icon-btn:hover{background:var(--bg4);color:var(--text)}.settings-panel{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:6px;background:var(--bg3)}.settings-label{font-size:.7rem;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.05em}.settings-input{background:var(--bg4);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.8rem;padding:6px 10px;outline:none;transition:border-color .15s;font-family:inherit}.settings-input:focus{border-color:var(--accent)}.model-section{padding:10px 14px;border-bottom:1px solid var(--border)}.model-row{display:flex;align-items:center;gap:8px}.status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;transition:background .3s}.status-dot.status-ok{background:var(--success);box-shadow:0 0 6px var(--success)}.status-dot.status-error{background:var(--danger)}.status-dot.status-loading{background:var(--warning);animation:pulse 1s infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}.model-select{flex:1;background:var(--bg4);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.78rem;padding:5px 8px;outline:none;cursor:pointer;min-width:0;font-family:inherit}.model-select:focus{border-color:var(--accent)}.sidebar-actions{padding:10px 14px 6px}.new-chat-btn{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius-sm);padding:8px 12px;font-size:.82rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s,transform .1s;font-family:inherit}.new-chat-btn:hover{background:var(--accent2)}.new-chat-btn:active{transform:scale(.98)}.chat-list{flex:1;overflow-y:auto;padding:6px 8px;display:flex;flex-direction:column;gap:1px}.chat-list-empty{text-align:center;color:var(--text3);font-size:.8rem;padding:20px 0}.chat-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:.82rem;color:var(--text2);transition:background .1s,color .1s;position:relative}.chat-item:hover{background:var(--bg3);color:var(--text)}.chat-item.active{background:var(--bg4);color:var(--text)}.chat-item-icon{font-size:.85rem;flex-shrink:0;opacity:.6}.chat-item-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-item-del{opacity:0;background:none;border:none;color:var(--danger);cursor:pointer;font-size:.75rem;padding:2px 5px;border-radius:4px;line-height:1;transition:opacity .15s,background .15s;flex-shrink:0}.chat-item:hover .chat-item-del{opacity:1}.chat-item-del:hover{background:#f0404026}.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border)}.footer-version{font-size:.7rem;color:var(--text3)}.msg-edit-container{width:100%;max-width:780px}.msg-edit-textarea{width:100%;background:var(--bg3);border:1px solid var(--accent);border-radius:var(--radius);color:var(--text);font-size:.9rem;font-family:inherit;padding:10px 14px;resize:none;min-height:80px;outline:none;line-height:1.6}.msg-edit-actions{display:flex;gap:6px;margin-top:8px}.btn-primary{background:var(--accent);color:#fff;border:none;border-radius:var(--radius-sm);padding:6px 14px;font-size:.8rem;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}.btn-primary:hover{background:var(--accent2)}.btn-secondary{background:var(--bg4);color:var(--text2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 14px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:background .15s}.btn-secondary:hover{background:var(--bg5);color:var(--text)}.btn-ghost{background:none;color:var(--text3);border:none;border-radius:var(--radius-sm);padding:6px 10px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:color .15s}.btn-ghost:hover{color:var(--text2)}.msg-actions{display:flex;gap:4px;margin-top:5px;opacity:0;transition:opacity .15s}.msg-wrapper:hover .msg-actions{opacity:1}.action-btn{background:none;border:1px solid var(--border);color:var(--text2);border-radius:var(--radius-sm);padding:3px 9px;font-size:.72rem;cursor:pointer;transition:all .15s;font-family:inherit;white-space:nowrap}.action-btn:hover{background:var(--bg3);color:var(--text);border-color:var(--border2)}.action-btn.copied{color:var(--success);border-color:var(--success)}.action-btn.danger:hover{background:#f040401Line truncated
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
var tp=Object.defineProperty;var Yu=e=>{throw TypeError(e)};var np=(e,t,n)=>t in e?tp(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var G=(e,t,n)=>np(e,typeof t!="symbol"?t+"":t,n),rp=(e,t,n)=>t.has(e)||Yu("Cannot "+n);var Xu=(e,t,n)=>t.has(e)?Yu("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n);var El=(e,t,n)=>(rp(e,t,"access private method"),n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function lp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var kc={exports:{}},Li={},wc={exports:{}},U={};/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var fl=Symbol.for("react.element"),ip=Symbol.for("react.portal"),op=Symbol.for("react.fragment"),sp=Symbol.for("react.strict_mode"),up=Symbol.for("react.profiler"),ap=Symbol.for("react.provider"),cp=Symbol.for("react.context"),fp=Symbol.for("react.forward_ref"),dp=Symbol.for("react.suspense"),pp=Symbol.for("react.memo"),hp=Symbol.for("react.lazy"),Ku=Symbol.iterator;function mp(e){return e===null||typeof e!="object"?null:(e=Ku&&e[Ku]||e["@@iterator"],typeof e=="function"?e:null)}var Sc={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ec=Object.assign,xc={};function or(e,t,n){this.props=e,this.context=t,this.refs=xc,this.updater=n||Sc}or.prototype.isReactComponent={};or.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};or.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function _c(){}_c.prototype=or.prototype;function Ps(e,t,n){this.props=e,this.context=t,this.refs=xc,this.updater=n||Sc}var Os=Ps.prototype=new _c;Os.constructor=Ps;Ec(Os,or.prototype);Os.isPureReactComponent=!0;var Zu=Array.isArray,Tc=Object.prototype.hasOwnProperty,Is={current:null},Cc={key:!0,ref:!0,__self:!0,__source:!0};function Nc(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)Tc.call(t,r)&&!Cc.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1<s){for(var u=Array(s),a=0;a<s;a++)u[a]=arguments[a+2];l.children=u}if(e&&e.defaultProps)for(r in s=e.defaultProps,s)l[r]===void 0&&(l[r]=s[r]);return{$$typeof:fl,type:e,key:i,ref:o,props:l,_owner:Is.current}}function gp(e,t){return{$$typeof:fl,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function zs(e){return typeof e=="object"&&e!==null&&e.$$typeof===fl}function yp(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var qu=/\/+/g;function eo(e,t){return typeof e=="object"&&e!==null&&e.key!=null?yp(""+e.key):t.toString(36)}function Vl(e,t,n,r,l){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var o=!1;if(e===null)o=!0;else switch(i){case"string":case"number":o=!0;break;case"object":switch(e.$$typeof){case fl:case ip:o=!0}}if(o)return o=e,l=l(o),e=r===""?"."+eo(o,0):r,Zu(l)?(n="",e!=null&&(n=e.replace(qu,"$&/")+"/"),Vl(l,t,n,"",function(a){return a})):l!=null&&(zs(l)&&(l=gp(l,n+(!l.key||o&&o.key===l.key?"":(""+l.key).replace(qu,"$&/")+"/")+e)),t.push(l)),1;if(o=0,r=r===""?".":r+":",Zu(e))for(var s=0;s<e.length;s++){i=e[s];var u=r+eo(i,s);o+=Vl(i,t,n,u,l)}else if(u=mp(e),typeof u=="function")for(e=u.call(e),s=0;!(i=e.next()).done;)i=i.value,u=r+eo(i,s++),o+=Vl(i,t,n,u,l);else if(i==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return o}function xl(e,t,n){if(e==null)return e;var r=[],l=0;return Vl(e,r,"","",function(i){return t.call(n,i,l++)}),r}function vp(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(n){(e._status===0||e._status===-1)&&(e._status=1,e._result=n)},function(n){(e._status===0||e._status===-1)&&(e._status=2,e._result=n)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var Ae={current:null},Ql={transition:null},kp={ReactCurrentDispatcher:Ae,ReactCurrentBatchConfig:Ql,ReactCurrentOwner:Is};function Rc(){throw Error("act(...) is not supported in production builds of React.")}U.Children={map:xl,forEach:function(e,t,n){xl(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return xl(e,function(){t++}),t},toArray:function(e){return xl(e,function(t){return t})||[]},only:function(e){if(!zs(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};U.Component=or;U.Fragment=op;U.Profiler=up;U.PureComponent=Ps;U.StrictMode=sp;U.Suspense=dp;U.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=kp;U.act=Rc;U.cloneElement=function(e,t,n){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var r=Ec({},e.props),l=e.key,i=e.ref,o=e._owner;if(t!=null){if(t.ref!==void 0&&(i=t.ref,o=Is.current),t.key!==void 0&&(l=""+t.key),e.type&&e.type.defaultProps)var s=e.type.defaultProps;for(u in t)Tc.call(t,u)&&!Cc.hasOwnProperty(u)&&(r[u]=t[u]===void 0&&s!==void 0?s[u]:t[u])}var u=arguments.length-2;if(u===1)r.children=n;else if(1<u){s=Array(u);for(var a=0;a<u;a++)s[a]=arguments[a+2];r.children=s}return{$$typeof:fl,type:e.type,key:l,ref:i,props:r,_owner:o}};U.createContext=function(e){return e={$$typeof:cp,_currentValue:e,_currentValue2:e,_threadCount:0,ProviderLine truncated
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Sp=M,Ep=Symbol.for("react.element"),xp=Symbol.for("react.fragment"),_p=Object.prototype.hasOwnProperty,Tp=Sp.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Cp={key:!0,ref:!0,__self:!0,__source:!0};function Ac(e,t,n){var r,l={},i=null,o=null;n!==void 0&&(i=""+n),t.key!==void 0&&(i=""+t.key),t.ref!==void 0&&(o=t.ref);for(r in t)_p.call(t,r)&&!Cp.hasOwnProperty(r)&&(l[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps,t)l[r]===void 0&&(l[r]=t[r]);return{$$typeof:Ep,type:e,key:i,ref:o,props:l,_owner:Tp.current}}Li.Fragment=xp;Li.jsx=Ac;Li.jsxs=Ac;kc.exports=Li;var _=kc.exports,zo={},Lc={exports:{}},Ve={},Pc={exports:{}},Oc={};/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/(function(e){function t(C,E){var I=C.length;C.push(E);e:for(;0<I;){var D=I-1>>>1,K=C[D];if(0<l(K,E))C[D]=E,C[I]=K,I=D;else break e}}function n(C){return C.length===0?null:C[0]}function r(C){if(C.length===0)return null;var E=C[0],I=C.pop();if(I!==E){C[0]=I;e:for(var D=0,K=C.length,J=K>>>1;D<J;){var je=2*(D+1)-1,V=C[je],ye=je+1,Xe=C[ye];if(0>l(V,I))ye<K&&0>l(Xe,V)?(C[D]=Xe,C[ye]=I,D=ye):(C[D]=V,C[je]=I,D=je);else if(ye<K&&0>l(Xe,I))C[D]=Xe,C[ye]=I,D=ye;else break e}}return E}function l(C,E){var I=C.sortIndex-E.sortIndex;return I!==0?I:C.id-E.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var u=[],a=[],m=1,g=null,d=3,y=!1,w=!1,k=!1,O=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function h(C){for(var E=n(a);E!==null;){if(E.callback===null)r(a);else if(E.startTime<=C)r(a),E.sortIndex=E.expirationTime,t(u,E);else break;E=n(a)}}function v(C){if(k=!1,h(C),!w)if(n(u)!==null)w=!0,ln(T);else{var E=n(a);E!==null&&on(v,E.startTime-C)}}function T(C,E){w=!1,k&&(k=!1,p(L),L=-1),y=!0;var I=d;try{for(h(E),g=n(u);g!==null&&(!(g.expirationTime>E)||C&&!B());){var D=g.callback;if(typeof D=="function"){g.callback=null,d=g.priorityLevel;var K=D(g.expirationTime<=E);E=e.unstable_now(),typeof K=="function"?g.callback=K:g===n(u)&&r(u),h(E)}else r(u);g=n(u)}if(g!==null)var J=!0;else{var je=n(a);je!==null&&on(v,je.startTime-E),J=!1}return J}finally{g=null,d=I,y=!1}}var N=!1,A=null,L=-1,X=5,j=-1;function B(){return!(e.unstable_now()-j<X)}function Ge(){if(A!==null){var C=e.unstable_now();j=C;var E=!0;try{E=A(!0,C)}finally{E?Ye():(N=!1,A=null)}}else N=!1}var Ye;if(typeof c=="function")Ye=function(){c(Ge)};else if(typeof MessageChannel<"u"){var xt=new MessageChannel,ar=xt.port2;xt.port1.onmessage=Ge,Ye=function(){ar.postMessage(null)}}else Ye=function(){O(Ge,0)};function ln(C){A=C,N||(N=!0,Ye())}function on(C,E){L=O(function(){C(e.unstable_now())},E)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(C){C.callback=null},e.unstable_continueExecution=function(){w||y||(w=!0,ln(T))},e.unstable_forceFrameRate=function(C){0>C||125<C?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):X=0<C?Math.floor(1e3/C):5},e.unstable_getCurrentPriorityLevel=function(){return d},e.unstable_getFirstCallbackNode=function(){return n(u)},e.unstable_next=function(C){switch(d){case 1:case 2:case 3:var E=3;break;default:E=d}var I=d;d=E;try{return C()}finally{d=I}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(C,E){switch(C){case 1:case 2:case 3:case 4:case 5:break;default:C=3}var I=d;d=C;try{return E()}finally{d=I}},e.unstable_scheduleCallback=function(C,E,I){var D=e.unstable_now();switch(typeof I=="object"&&I!==null?(I=I.delay,I=typeof I=="number"&&0<I?D+I:D):I=D,C){case 1:var K=-1;break;case 2:K=250;break;case 5:K=1073741823;break;case 4:K=1e4;break;default:K=5e3}return K=I+K,C={id:m++,callback:E,priorityLevel:C,startTime:I,expirationTime:K,sortIndex:-1},I>D?(C.sortIndex=I,t(a,C),n(u)===null&&C===n(a)&&(k?(p(L),L=-1):k=!0,on(v,I-D))):(C.sortIndex=K,t(u,C),w||y||(w=!0,ln(T))),C},e.unstable_shouldYield=B,e.unstable_wrapCallback=function(C){var E=d;return function(){var I=d;d=E;try{return C.apply(this,arguments)}finally{d=I}}}})(Oc);Pc.exports=Oc;var Np=Pc.exports;/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Rp=M,We=Np;function S(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var Ic=new Set,Yr={};function _n(e,t){bn(e,t),bn(e+"Capture",t)}function bn(e,t){for(Yr[e]=t,e=0;e<t.length;e++)Ic.add(t[e])}var Ot=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Do=Object.prototype.hasOwnProperty,Ap=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ju={},bu={};function Lp(e){return Do.call(bu,e)?!0:Do.call(Ju,e)?!1:Ap.test(e)?bu[e]=!0:(Ju[e]=!0,!1)}function Pp(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Op(e,t,n,r){if(t===null||typeof t>"u"||Pp(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Le(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ge={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ge[e]=new Le(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ge[t]=new Le(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ge[e]=new Le(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ge[e]=new Le(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ge[e]=new Le(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ge[e]=new Le(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ge[e]=new Le(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ge[e]=new Le(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ge[e]=new Le(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ds=/[\-:]([a-z])/g;function Ms(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ds,Ms);ge[t]=new Le(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ds,Ms);ge[t]=new Le(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ds,Ms);ge[t]=new Le(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ge[e]=new Le(e,1,!1,e.toLowerCase(),null,!1,!1)});ge.xlinkHref=new Le("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ge[e]=new Le(e,1,!1,e.toLowerCase(),null,!0,!0)});function js(e,t,n,r){var l=ge.hasOwnProperty(t)?ge[t]:null;(l!==null?l.type!==0:r||!(2<t.length)||t[0]!=="o"&&t[0]!=="
Line truncated
|
||||
`+to+e}var no=!1;function ro(e,t){if(!e||no)return"";no=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(a){var r=a}Reflect.construct(e,[],t)}else{try{t.call()}catch(a){r=a}e.call(t.prototype)}else{try{throw Error()}catch(a){r=a}e()}}catch(a){if(a&&r&&typeof a.stack=="string"){for(var l=a.stack.split(`
|
||||
`),i=r.stack.split(`
|
||||
`),o=l.length-1,s=i.length-1;1<=o&&0<=s&&l[o]!==i[s];)s--;for(;1<=o&&0<=s;o--,s--)if(l[o]!==i[s]){if(o!==1||s!==1)do if(o--,s--,0>s||l[o]!==i[s]){var u=`
|
||||
`+l[o].replace(" at new "," at ");return e.displayName&&u.includes("<anonymous>")&&(u=u.replace("<anonymous>",e.displayName)),u}while(1<=o&&0<=s);break}}}finally{no=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Or(e):""}function Ip(e){switch(e.tag){case 5:return Or(e.type);case 16:return Or("Lazy");case 13:return Or("Suspense");case 19:return Or("SuspenseList");case 0:case 2:case 15:return e=ro(e.type,!1),e;case 11:return e=ro(e.type.render,!1),e;case 1:return e=ro(e.type,!0),e;default:return""}}function Uo(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Mn:return"Fragment";case Dn:return"Portal";case Mo:return"Profiler";case Fs:return"StrictMode";case jo:return"Suspense";case Fo:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Dc:return(e.displayName||"Context")+".Consumer";case zc:return(e._context.displayName||"Context")+".Provider";case Us:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case $s:return t=e.displayName||null,t!==null?t:Uo(e.type)||"Memo";case Ut:t=e._payload,e=e._init;try{return Uo(e(t))}catch{}}return null}function zp(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Uo(t);case 8:return t===Fs?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function bt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function jc(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Dp(e){var t=jc(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Tl(e){e._valueTracker||(e._valueTracker=Dp(e))}function Fc(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=jc(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function li(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function $o(e,t){var n=t.checked;return re({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ta(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=bt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Uc(e,t){t=t.checked,t!=null&&js(e,"checked",t,!1)}function Ho(e,t){Uc(e,t);var n=bt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Bo(e,t.type,n):t.hasOwnProperty("defaultValue")&&Bo(e,t.type,bt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function na(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Bo(e,t,n){(t!=="number"||li(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Ir=Array.isArray;function Yn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l<n.length;l++)t["$"+n[l]]=!0;for(n=0;n<e.length;n++)l=t.hasOwnProperty("$"+e[n].value),e[n].selected!==l&&(e[n].selected=l),l&&r&&(e[n].defaultSelected=!0)}else{for(n=""+bt(n),t=null,l=0;l<e.length;l++){if(e[l].value===n){e[l].selected=!0,r&&(e[l].defaultSelected=!0);return}t!==null||e[l].disabled||(t=e[l])}t!==null&&(t.selected=!0)}}function Wo(e,t){if(t.dan
Line truncated
|
||||
`).replace(Qh,"")}function Il(e,t,n){if(t=Ta(t),Ta(e)!==t&&n)throw Error(S(425))}function di(){}var ns=null,rs=null;function ls(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var is=typeof setTimeout=="function"?setTimeout:void 0,Gh=typeof clearTimeout=="function"?clearTimeout:void 0,Ca=typeof Promise=="function"?Promise:void 0,Yh=typeof queueMicrotask=="function"?queueMicrotask:typeof Ca<"u"?function(e){return Ca.resolve(null).then(e).catch(Xh)}:is;function Xh(e){setTimeout(function(){throw e})}function yo(e,t){var n=t,r=0;do{var l=n.nextSibling;if(e.removeChild(n),l&&l.nodeType===8)if(n=l.data,n==="/$"){if(r===0){e.removeChild(l),Jr(t);return}r--}else n!=="$"&&n!=="$?"&&n!=="$!"||r++;n=l}while(n);Jr(t)}function Xt(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function Na(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n==="$"||n==="$!"||n==="$?"){if(t===0)return e;t--}else n==="/$"&&t++}e=e.previousSibling}return null}var ur=Math.random().toString(36).slice(2),vt="__reactFiber$"+ur,rl="__reactProps$"+ur,It="__reactContainer$"+ur,os="__reactEvents$"+ur,Kh="__reactListeners$"+ur,Zh="__reactHandles$"+ur;function dn(e){var t=e[vt];if(t)return t;for(var n=e.parentNode;n;){if(t=n[It]||n[vt]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Na(e);e!==null;){if(n=e[vt])return n;e=Na(e)}return t}e=n,n=e.parentNode}return null}function hl(e){return e=e[vt]||e[It],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function $n(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(S(33))}function Di(e){return e[rl]||null}var ss=[],Hn=-1;function nn(e){return{current:e}}function q(e){0>Hn||(e.current=ss[Hn],ss[Hn]=null,Hn--)}function Y(e,t){Hn++,ss[Hn]=e.current,e.current=t}var en={},xe=nn(en),ze=nn(!1),yn=en;function er(e,t){var n=e.type.contextTypes;if(!n)return en;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function De(e){return e=e.childContextTypes,e!=null}function pi(){q(ze),q(xe)}function Ra(e,t,n){if(xe.current!==en)throw Error(S(168));Y(xe,t),Y(ze,n)}function Rf(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(S(108,zp(e)||"Unknown",l));return re({},n,r)}function hi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||en,yn=xe.current,Y(xe,e),Y(ze,ze.current),!0}function Aa(e,t,n){var r=e.stateNode;if(!r)throw Error(S(169));n?(e=Rf(e,t,yn),r.__reactInternalMemoizedMergedChildContext=e,q(ze),q(xe),Y(xe,e)):q(ze),Y(ze,n)}var Rt=null,Mi=!1,vo=!1;function Af(e){Rt===null?Rt=[e]:Rt.push(e)}function qh(e){Mi=!0,Af(e)}function rn(){if(!vo&&Rt!==null){vo=!0;var e=0,t=W;try{var n=Rt;for(W=1;e<n.length;e++){var r=n[e];do r=r(!0);while(r!==null)}Rt=null,Mi=!1}catch(l){throw Rt!==null&&(Rt=Rt.slice(e+1)),bc(Bs,rn),l}finally{W=t,vo=!1}}return null}var Bn=[],Wn=0,mi=null,gi=0,Ze=[],qe=0,vn=null,At=1,Lt="";function cn(e,t){Bn[Wn++]=gi,Bn[Wn++]=mi,mi=e,gi=t}function Lf(e,t,n){Ze[qe++]=At,Ze[qe++]=Lt,Ze[qe++]=vn,vn=e;var r=At;e=Lt;var l=32-ct(r)-1;r&=~(1<<l),n+=1;var i=32-ct(t)+l;if(30<i){var o=l-l%5;i=(r&(1<<o)-1).toString(32),r>>=o,l-=o,At=1<<32-ct(t)+l|n<<l|r,Lt=i+e}else At=1<<i|n<<l|r,Lt=e}function qs(e){e.return!==null&&(cn(e,1),Lf(e,1,0))}function Js(e){for(;e===mi;)mi=Bn[--Wn],Bn[Wn]=null,gi=Bn[--Wn],Bn[Wn]=null;for(;e===vn;)vn=Ze[--qe],Ze[qe]=null,Lt=Ze[--qe],Ze[qe]=null,At=Ze[--qe],Ze[qe]=null}var Be=null,He=null,ee=!1,at=null;function Pf(e,t){var n=Je(5,null,null,0);n.elementType="DELETED",n.stateNode=t,n.return=e,t=e.deletions,t===null?(e.deletions=[n],e.flags|=16):t.push(n)}function La(e,t){switch(e.tag){case 5:var n=e.type;return t=t.nodeType!==1||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,Be=e,He=Xt(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,Be=e,He=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(n=vn!==null?{id:At,overflow:Lt}:null,e.memoizedState={dehydrated:t,treeContext:n,retryLane:1073741824},n=Je(18,null,null,0),n.stateNode=t,n.return=e,e.child=n,Be=e,He=null,!0):!1;default:return!1}}function us(e){return(e.mode&1)!==0&&(e.flags&128)===0}function as(e){if(ee){var t=He;if(t){var n=t;if(!La(e,t)){if(us(e))throw Error(S(418));t=Xt(n.nextSibling);var r=Be;t&&La(e,t)?Pf(r,n):(e.flags=e.flags&-4097|2,ee=!1,Be=e)}}else{if(us(e))throw Error(S(418));e.flags=e.flags&-4097|2,ee=!1,Be=e}}}function Pa(e){for(e=e.return;e!==null
Line truncated
|
||||
Error generating stack: `+i.message+`
|
||||
`+i.stack}return{value:e,source:t,stack:l,digest:null}}function xo(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function ps(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var om=typeof WeakMap=="function"?WeakMap:Map;function ld(e,t,n){n=Pt(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){_i||(_i=!0,xs=r),ps(e,t)},n}function id(e,t,n){n=Pt(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){ps(e,t)}}var i=e.stateNode;return i!==null&&typeof i.componentDidCatch=="function"&&(n.callback=function(){ps(e,t),typeof r!="function"&&(Zt===null?Zt=new Set([this]):Zt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function Ua(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new om;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=wm.bind(null,e,t,n),t.then(e,e))}function $a(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Ha(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=Pt(-1,1),t.tag=2,Kt(n,t,1))),n.lanes|=1),e)}var sm=Mt.ReactCurrentOwner,Ie=!1;function Te(e,t,n,r){t.child=e===null?zf(t,null,n,r):nr(t,e.child,n,r)}function Ba(e,t,n,r,l){n=n.render;var i=t.ref;return qn(t,l),r=au(e,t,n,r,i,l),n=cu(),e!==null&&!Ie?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Dt(e,t,l)):(ee&&n&&qs(t),t.flags|=1,Te(e,t,r,l),t.child)}function Wa(e,t,n,r,l){if(e===null){var i=n.type;return typeof i=="function"&&!wu(i)&&i.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=i,od(e,t,i,r,l)):(e=ti(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(i=e.child,!(e.lanes&l)){var o=i.memoizedProps;if(n=n.compare,n=n!==null?n:el,n(o,r)&&e.ref===t.ref)return Dt(e,t,l)}return t.flags|=1,e=Jt(i,r),e.ref=t.ref,e.return=t,t.child=e}function od(e,t,n,r,l){if(e!==null){var i=e.memoizedProps;if(el(i,r)&&e.ref===t.ref)if(Ie=!1,t.pendingProps=r=i,(e.lanes&l)!==0)e.flags&131072&&(Ie=!0);else return t.lanes=e.lanes,Dt(e,t,l)}return hs(e,t,n,r,l)}function sd(e,t,n){var r=t.pendingProps,l=r.children,i=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},Y(Gn,Ue),Ue|=n;else{if(!(n&1073741824))return e=i!==null?i.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,Y(Gn,Ue),Ue|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=i!==null?i.baseLanes:n,Y(Gn,Ue),Ue|=r}else i!==null?(r=i.baseLanes|n,t.memoizedState=null):r=n,Y(Gn,Ue),Ue|=r;return Te(e,t,l,n),t.child}function ud(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function hs(e,t,n,r,l){var i=De(n)?yn:xe.current;return i=er(t,i),qn(t,l),n=au(e,t,n,r,i,l),r=cu(),e!==null&&!Ie?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Dt(e,t,l)):(ee&&r&&qs(t),t.flags|=1,Te(e,t,n,l),t.child)}function Va(e,t,n,r,l){if(De(n)){var i=!0;hi(t)}else i=!1;if(qn(t,l),t.stateNode===null)Jl(e,t),rd(t,n,r),ds(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,s=t.memoizedProps;o.props=s;var u=o.context,a=n.contextType;typeof a=="object"&&a!==null?a=et(a):(a=De(n)?yn:xe.current,a=er(t,a));var m=n.getDerivedStateFromProps,g=typeof m=="function"||typeof o.getSnapshotBeforeUpdate=="function";g||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==r||u!==a)&&Fa(t,o,r,a),$t=!1;var d=t.memoizedState;o.state=d,ki(t,r,o,l),u=t.memoizedState,s!==r||d!==u||ze.current||$t?(typeof m=="function"&&(fs(t,n,m,r),u=t.memoizedState),(s=$t||ja(t,n,s,r,d,u,a))?(g||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=u),o.props=r,o.state=u,o.context=a,r=s):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,Mf(e,t),s=t.memoizedProps,a=t.type===t.elementType?s:st(t.type,s),o.props=a,g=t.pendingProps,d=o.context,u=n.contextType,typeof u=="object"&&u!==null?u=et(u):(u=De(n)?yn:xe.current,u=er(t,u));var y=n.getDerivedStateFromProps;(m=typeof y=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==g||d!==u)&&Fa(t,o,r,u),$t=!1,d=t.memoizedState,o.state=d,ki(t,r,o,l);var w=t.memoizedState
Line truncated
|
||||
`).map(l=>{const i=l.match(/^\s+/);if(i===null)return l;const[o]=i;return o.length>=r.length?l.slice(r.length):l}).join(`
|
||||
`)}class Ri{constructor(t){G(this,"options");G(this,"rules");G(this,"lexer");this.options=t||Cn}space(t){const n=this.rules.block.newline.exec(t);if(n&&n[0].length>0)return{type:"space",raw:n[0]}}code(t){const n=this.rules.block.code.exec(t);if(n){const r=n[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:n[0],codeBlockStyle:"indented",text:this.options.pedantic?r:$l(r,`
|
||||
`)}}}fences(t){const n=this.rules.block.fences.exec(t);if(n){const r=n[0],l=$m(r,n[3]||"");return{type:"code",raw:r,lang:n[2]?n[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):n[2],text:l}}}heading(t){const n=this.rules.block.heading.exec(t);if(n){let r=n[2].trim();if(/#$/.test(r)){const l=$l(r,"#");(this.options.pedantic||!l||/ $/.test(l))&&(r=l.trim())}return{type:"heading",raw:n[0],depth:n[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){const n=this.rules.block.hr.exec(t);if(n)return{type:"hr",raw:n[0]}}blockquote(t){const n=this.rules.block.blockquote.exec(t);if(n){let r=n[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,`
|
||||
$1`);r=$l(r.replace(/^ *>[ \t]?/gm,""),`
|
||||
`);const l=this.lexer.state.top;this.lexer.state.top=!0;const i=this.lexer.blockTokens(r);return this.lexer.state.top=l,{type:"blockquote",raw:n[0],tokens:i,text:r}}}list(t){let n=this.rules.block.list.exec(t);if(n){let r=n[1].trim();const l=r.length>1,i={type:"list",raw:"",ordered:l,start:l?+r.slice(0,-1):"",loose:!1,items:[]};r=l?`\\d{1,9}\\${r.slice(-1)}`:`\\${r}`,this.options.pedantic&&(r=l?r:"[*+-]");const o=new RegExp(`^( {0,3}${r})((?:[ ][^\\n]*)?(?:\\n|$))`);let s="",u="",a=!1;for(;t;){let m=!1;if(!(n=o.exec(t))||this.rules.block.hr.test(t))break;s=n[0],t=t.substring(s.length);let g=n[2].split(`
|
||||
`,1)[0].replace(/^\t+/,p=>" ".repeat(3*p.length)),d=t.split(`
|
||||
`,1)[0],y=0;this.options.pedantic?(y=2,u=g.trimStart()):(y=n[2].search(/[^ ]/),y=y>4?1:y,u=g.slice(y),y+=n[1].length);let w=!1;if(!g&&/^ *$/.test(d)&&(s+=d+`
|
||||
`,t=t.substring(d.length+1),m=!0),!m){const p=new RegExp(`^ {0,${Math.min(3,y-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),c=new RegExp(`^ {0,${Math.min(3,y-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),h=new RegExp(`^ {0,${Math.min(3,y-1)}}(?:\`\`\`|~~~)`),v=new RegExp(`^ {0,${Math.min(3,y-1)}}#`);for(;t;){const T=t.split(`
|
||||
`,1)[0];if(d=T,this.options.pedantic&&(d=d.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),h.test(d)||v.test(d)||p.test(d)||c.test(t))break;if(d.search(/[^ ]/)>=y||!d.trim())u+=`
|
||||
`+d.slice(y);else{if(w||g.search(/[^ ]/)>=4||h.test(g)||v.test(g)||c.test(g))break;u+=`
|
||||
`+d}!w&&!d.trim()&&(w=!0),s+=T+`
|
||||
`,t=t.substring(T.length+1),g=d.slice(y)}}i.loose||(a?i.loose=!0:/\n *\n *$/.test(s)&&(a=!0));let k=null,O;this.options.gfm&&(k=/^\[[ xX]\] /.exec(u),k&&(O=k[0]!=="[ ] ",u=u.replace(/^\[[ xX]\] +/,""))),i.items.push({type:"list_item",raw:s,task:!!k,checked:O,loose:!1,text:u,tokens:[]}),i.raw+=s}i.items[i.items.length-1].raw=s.trimEnd(),i.items[i.items.length-1].text=u.trimEnd(),i.raw=i.raw.trimEnd();for(let m=0;m<i.items.length;m++)if(this.lexer.state.top=!1,i.items[m].tokens=this.lexer.blockTokens(i.items[m].text,[]),!i.loose){const g=i.items[m].tokens.filter(y=>y.type==="space"),d=g.length>0&&g.some(y=>/\n.*\n/.test(y.raw));i.loose=d}if(i.loose)for(let m=0;m<i.items.length;m++)i.items[m].loose=!0;return i}}html(t){const n=this.rules.block.html.exec(t);if(n)return{type:"html",block:!0,raw:n[0],pre:n[1]==="pre"||n[1]==="script"||n[1]==="style",text:n[0]}}def(t){const n=this.rules.block.def.exec(t);if(n){const r=n[1].toLowerCase().replace(/\s+/g," "),l=n[2]?n[2].replace(/^<(.*)>$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=n[3]?n[3].substring(1,n[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):n[3];return{type:"def",tag:r,raw:n[0],href:l,title:i}}}table(t){const n=this.rules.block.table.exec(t);if(!n||!/[:|]/.test(n[2]))return;const r=oc(n[1]),l=n[2].replace(/^\||\| *$/g,"").split("|"),i=n[3]&&n[3].trim()?n[3].replace(/\n[ \t]*$/,"").split(`
|
||||
`):[],o={type:"table",raw:n[0],header:[],align:[],rows:[]};if(r.length===l.length){for(const s of l)/^ *-+: *$/.test(s)?o.align.push("right"):/^ *:-+: *$/.test(s)?o.align.push("center"):/^ *:-+ *$/.test(s)?o.align.push("left"):o.align.push(null);for(const s of r)o.header.push({text:s,tokens:this.lexer.inline(s)});for(const s of i)o.rows.push(oc(s,o.header.length).map(u=>({text:u,tokens:this.lexer.inline(u)})));return o}}lheading(t){const n=this.rules.block.lheading.exec(t);if(n)return{type:"heading",raw:n[0],depth:n[2].charAt(0)==="="?1:2,text:n[1],tokens:this.lexer.inline(n[1])}}paragraph(t){const n=this.rules.block.paragraph.exec(t);if(n){const r=n[1].charAt(n[1].length-1)===`
|
||||
`?n[1].slice(0,-1):n[1];return{type:"paragraph",raw:n[0],text:r,tokens:this.lexer.inline(r)}}}text(t){const n=this.rules.block.text.exec(t);if(n)return{type:"text",raw:n[0],text:n[0],tokens:this.lexer.inline(n[0])}}escape(t){const n=this.rules.inline.escape.exec(t);if(n)return{type:"escape",raw:n[0],text:$e(n[1])}}tag(t){const n=this.rules.inline.tag.exec(t);if(n)return!this.lexer.state.inLink&&/^<a /i.test(n[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&/^<\/a>/i.test(n[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(n[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(n[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:n[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:n[0]}}link(t){const n=this.rules.inline.link.exec(t);if(n){const r=n[2].trim();if(!this.options.pedantic&&/^</.test(r)){if(!/>$/.test(r))return;const o=$l(r.slice(0,-1),"\\");if((r.length-o.length)%2===0)return}else{const o=Um(n[2],"()");if(o>-1){const u=(n[0].indexOf("!")===0?5:4)+n[1].length+o;n[2]=n[2].substring(0,o),n[0]=n[0].substring(0,u).trim(),n[3]=""}}let l=n[2],i="";if(this.options.pedantic){const o=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(l);o&&(l=o[1],i=o[3])}else i=n[3]?n[3].slice(1,-1):"";return l=l.trim(),/^</.test(l)&&(this.options.pedantic&&!/>$/.test(r)?l=l.slice(1):l=l.slice(1,-1)),sc(n,{href:l&&l.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},n[0],this.lexer)}}reflink(t,n){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){const l=(r[2]||r[1]).replace(/\s+/g," "),i=n[l.toLowerCase()];if(!i){const o=r[0].charAt(0);return{type:"text",raw:o,text:o}}return sc(r,i,r[0],this.lexer)}}emStrong(t,n,r=""){let l=this.rules.inline.emStrongLDelim.exec(t);if(!l||l[3]&&r.match(/[\p{L}\p{N}]/u))return;if(!(l[1]||l[2]||"")||!r||this.rules.inline.punctuation.exec(r)){const o=[...l[0]].length-1;let s,u,a=o,m=0;const g=l[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(g.lastIndex=0,n=n.slice(-1*t.length+o);(l=g.exec(n))!=null;){if(s=l[1]||l[2]||l[3]||l[4]||l[5]||l[6],!s)continue;if(u=[...s].length,l[3]||l[4]){a+=u;continue}else if((l[5]||l[6])&&o%3&&!((o+u)%3)){m+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a+m);const d=[...l[0]][0].length,y=t.slice(0,o+l.index+d+u);if(Math.min(o,u)%2){const k=y.slice(1,-1);return{type:"em",raw:y,text:k,tokens:this.lexer.inlineTokens(k)}}const w=y.slice(2,-2);return{type:"strong",raw:y,text:w,tokens:this.lexer.inlineTokens(w)}}}}codespan(t){const n=this.rules.inline.code.exec(t);if(n){let r=n[2].replace(/\n/g," ");const l=/[^ ]/.test(r),i=/^ /.test(r)&&/ $/.test(r);return l&&i&&(r=r.substring(1,r.length-1)),r=$e(r,!0),{type:"codespan",raw:n[0],text:r}}}br(t){const n=this.rules.inline.br.exec(t);if(n)return{type:"br",raw:n[0]}}del(t){const n=this.rules.inline.del.exec(t);if(n)return{type:"del",raw:n[0],text:n[2],tokens:this.lexer.inlineTokens(n[2])}}autolink(t){const n=this.rules.inline.autolink.exec(t);if(n){let r,l;return n[2]==="@"?(r=$e(n[1]),l="mailto:"+r):(r=$e(n[1]),l=r),{type:"link",raw:n[0],text:r,href:l,tokens:[{type:"text",raw:r,text:r}]}}}url(t){var r;let n;if(n=this.rules.inline.url.exec(t)){let l,i;if(n[2]==="@")l=$e(n[0]),i="mailto:"+l;else{let o;do o=n[0],n[0]=((r=this.rules.inline._backpedal.exec(n[0]))==null?void 0:r[0])??"";while(o!==n[0]);l=$e(n[0]),n[1]==="www."?i="http://"+n[0]:i=n[0]}return{type:"link",raw:n[0],text:l,href:i,tokens:[{type:"text",raw:l,text:l}]}}}inlineText(t){const n=this.rules.inline.text.exec(t);if(n){let r;return this.lexer.state.inRawBlock?r=n[0]:r=$e(n[0]),{type:"text",raw:n[0],text:r}}}}const Hm=/^(?: *(?:\n|$))+/,Bm=/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,Wm=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,gl=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Vm=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Dd=/(?:[*+-]|\d{1,9}[.)])/,Md=Q(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,Dd).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),Cu=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Qm=/^[^\n]+/,Nu=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Gm=Q(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",Nu).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ym=Q(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Dd).getRegex(),Gi="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|diLine truncated
|
||||
]`).replace("lheading",Md).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Fd=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Jm=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Ud=/^( {2,}|\\)\n(?!\s*$)/,bm=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,yl="\\p{P}\\p{S}",eg=Q(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,yl).getRegex(),tg=/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,ng=Q(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,yl).getRegex(),rg=Q("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,yl).getRegex(),lg=Q("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,yl).getRegex(),ig=Q(/\\([punct])/,"gu").replace(/punct/g,yl).getRegex(),og=Q(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),sg=Q(Ru).replace("(?:-->|$)","-->").getRegex(),ug=Q("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",sg).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Ai=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,ag=Q(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",Ai).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),$d=Q(/^!?\[(label)\]\[(ref)\]/).replace("label",Ai).replace("ref",Nu).getRegex(),Hd=Q(/^!?\[(ref)\](?:\[\])?/).replace("ref",Nu).getRegex(),cg=Q("reflink|nolink(?!\\()","g").replace("reflink",$d).replace("nolink",Hd).getRegex(),Lu={_backpedal:Qr,anyPunctuation:ig,autolink:og,blockSkip:tg,br:Ud,code:Jm,del:Qr,emStrongLDelim:ng,emStrongRDelimAst:rg,emStrongRDelimUnd:lg,escape:Fd,link:ag,nolink:Hd,punctuation:eg,reflink:$d,reflinkSearch:cg,tag:ug,text:bm,url:Qr},fg={...Lu,link:Q(/^!?\[(label)\]\((.*?)\)/).replace("label",Ai).getRegex(),reflink:Q(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Ai).getRegex()},Ns={...Lu,escape:Q(Fd).replace("])","~|])").getRegex(),url:Q(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},dg={...Ns,br:Q(Ud).replace("{2,}","*").getRegex(),text:Q(Ns.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},Hl={normal:Au,gfm:Zm,pedantic:qm},Cr={normal:Lu,gfm:Ns,breaks:dg,pedantic:fg};class kt{constructor(t){G(this,"tokens");G(this,"options");G(this,"state");G(this,"tokenizer");G(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||Cn,this.options.tokenizer=this.options.tokenizer||new Ri,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={block:Hl.normal,inline:Cr.normal};this.options.pedantic?(n.block=Hl.pedantic,n.inline=Cr.pedantic):this.options.gfm&&(n.block=Hl.gfm,this.options.breaks?n.inline=Cr.breaks:n.inline=Cr.gfm),this.tokenizer.rules=n}static get rules(){return{block:Hl,inline:Cr}}static lex(t,n){return new kt(n).lex(t)}static lexInline(t,n){return new kt(n).inlineTokens(t)}lex(t){t=t.replace(/\r\n|\r/g,`
|
||||
`),this.blockTokens(t,this.tokens);for(let n=0;n<this.inlineQueue.length;n++){const r=this.inlineQueue[n];this.inlineTokens(r.src,r.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(t,n=[]){this.options.pedantic?t=t.replace(/\t/g," ").replace(/^ +$/gm,""):t=t.replace(/^( *)(\t+)/gm,(s,u,a)=>u+" ".repeat(a.length));let r,l,i,o;for(;t;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some(s=>(r=s.call({lexer:this},t,n))?(t=t.substring(r.raw.length),n.push(r),!0):!1))){if(r=this.tokenizer.space(t)){t=t.substring(r.raw.length),r.raw.length===1&&n.length>0?n[n.length-1].raw+=`
|
||||
`:n.push(r);continue}if(r=this.tokenizer.code(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&(l.type==="paragraph"||l.type==="text")?(l.raw+=`
|
||||
`+r.raw,l.text+=`
|
||||
`+r.text,this.inlineQueue[this.inlineQueue.length-1].src=l.text):n.push(r);continue}if(r=this.tokenizer.fences(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.heading(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.hr(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.blockquote(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.list(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.html(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.def(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&(l.type==="paragraph"||l.type==="text")?(l.raw+=`
|
||||
`+r.raw,l.text+=`
|
||||
`+r.raw,this.inlineQueue[this.inlineQueue.length-1].src=l.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title});continue}if(r=this.tokenizer.table(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.lheading(t)){t=t.substring(r.raw.length),n.push(r);continue}if(i=t,this.options.extensions&&this.options.extensions.startBlock){let s=1/0;const u=t.slice(1);let a;this.options.extensions.startBlock.forEach(m=>{a=m.call({lexer:this},u),typeof a=="number"&&a>=0&&(s=Math.min(s,a))}),s<1/0&&s>=0&&(i=t.substring(0,s+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){l=n[n.length-1],o&&l.type==="paragraph"?(l.raw+=`
|
||||
`+r.raw,l.text+=`
|
||||
`+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=l.text):n.push(r),o=i.length!==t.length,t=t.substring(r.raw.length);continue}if(r=this.tokenizer.text(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&l.type==="text"?(l.raw+=`
|
||||
`+r.raw,l.text+=`
|
||||
`+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=l.text):n.push(r);continue}if(t){const s="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,n}inline(t,n=[]){return this.inlineQueue.push({src:t,tokens:n}),n}inlineTokens(t,n=[]){let r,l,i,o=t,s,u,a;if(this.tokens.links){const m=Object.keys(this.tokens.links);if(m.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(o))!=null;)m.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(o=o.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+o.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.blockSkip.exec(o))!=null;)o=o.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+o.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(o))!=null;)o=o.slice(0,s.index)+"++"+o.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;t;)if(u||(a=""),u=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some(m=>(r=m.call({lexer:this},t,n))?(t=t.substring(r.raw.length),n.push(r),!0):!1))){if(r=this.tokenizer.escape(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.tag(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&r.type==="text"&&l.type==="text"?(l.raw+=r.raw,l.text+=r.text):n.push(r);continue}if(r=this.tokenizer.link(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(r.raw.length),l=n[n.length-1],l&&r.type==="text"&&l.type==="text"?(l.raw+=r.raw,l.text+=r.text):n.push(r);continue}if(r=this.tokenizer.emStrong(t,o,a)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.codespan(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.br(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.del(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.autolink(t)){t=t.substring(r.raw.length),n.push(r);continue}if(!this.state.inLink&&(r=this.tokenizer.url(t))){t=t.substring(r.raw.length),n.push(r);continue}if(i=t,this.options.extensions&&this.options.extensions.startInline){let m=1/0;const g=t.slice(1);let d;this.options.extensions.startInline.forEach(y=>{d=y.call({lexer:this},g),typeof d=="number"&&d>=0&&(m=Math.min(m,d))}),m<1/0&&m>=0&&(i=t.substring(0,m+1))}if(r=this.tokenizer.inlineText(i)){t=t.substring(r.raw.length),r.raw.slice(-1)!=="_"&&(a=r.raw.slice(-1)),u=!0,l=n[n.length-1],l&&l.type==="text"?(l.raw+=r.raw,l.text+=r.text):n.push(r);continue}if(t){const m="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(m);break}else throw new Error(m)}}return n}}class cl{constructor(t){G(this,"options");this.options=t||Cn}code(t,n,r){var i;const l=(i=(n||"").match(/^\S*/))==null?void 0:i[0];return t=t.replace(/\n$/,"")+`
|
||||
`,l?'<pre><code class="language-'+$e(l)+'">'+(r?t:$e(t,!0))+`</code></pre>
|
||||
`:"<pre><code>"+(r?t:$e(t,!0))+`</code></pre>
|
||||
`}blockquote(t){return`<blockquote>
|
||||
${t}</blockquote>
|
||||
`}html(t,n){return t}heading(t,n,r){return`<h${n}>${t}</h${n}>
|
||||
`}hr(){return`<hr>
|
||||
`}list(t,n,r){const l=n?"ol":"ul",i=n&&r!==1?' start="'+r+'"':"";return"<"+l+i+`>
|
||||
`+t+"</"+l+`>
|
||||
`}listitem(t,n,r){return`<li>${t}</li>
|
||||
`}checkbox(t){return"<input "+(t?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph(t){return`<p>${t}</p>
|
||||
`}table(t,n){return n&&(n=`<tbody>${n}</tbody>`),`<table>
|
||||
<thead>
|
||||
`+t+`</thead>
|
||||
`+n+`</table>
|
||||
`}tablerow(t){return`<tr>
|
||||
${t}</tr>
|
||||
`}tablecell(t,n){const r=n.header?"th":"td";return(n.align?`<${r} align="${n.align}">`:`<${r}>`)+t+`</${r}>
|
||||
`}strong(t){return`<strong>${t}</strong>`}em(t){return`<em>${t}</em>`}codespan(t){return`<code>${t}</code>`}br(){return"<br>"}del(t){return`<del>${t}</del>`}link(t,n,r){const l=ic(t);if(l===null)return r;t=l;let i='<a href="'+t+'"';return n&&(i+=' title="'+n+'"'),i+=">"+r+"</a>",i}image(t,n,r){const l=ic(t);if(l===null)return r;t=l;let i=`<img src="${t}" alt="${r}"`;return n&&(i+=` title="${n}"`),i+=">",i}text(t){return t}}class Pu{strong(t){return t}em(t){return t}codespan(t){return t}del(t){return t}html(t){return t}text(t){return t}link(t,n,r){return""+r}image(t,n,r){return""+r}br(){return""}}class wt{constructor(t){G(this,"options");G(this,"renderer");G(this,"textRenderer");this.options=t||Cn,this.options.renderer=this.options.renderer||new cl,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new Pu}static parse(t,n){return new wt(n).parse(t)}static parseInline(t,n){return new wt(n).parseInline(t)}parse(t,n=!0){let r="";for(let l=0;l<t.length;l++){const i=t[l];if(this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[i.type]){const o=i,s=this.options.extensions.renderers[o.type].call({parser:this},o);if(s!==!1||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(o.type)){r+=s||"";continue}}switch(i.type){case"space":continue;case"hr":{r+=this.renderer.hr();continue}case"heading":{const o=i;r+=this.renderer.heading(this.parseInline(o.tokens),o.depth,jm(this.parseInline(o.tokens,this.textRenderer)));continue}case"code":{const o=i;r+=this.renderer.code(o.text,o.lang,!!o.escaped);continue}case"table":{const o=i;let s="",u="";for(let m=0;m<o.header.length;m++)u+=this.renderer.tablecell(this.parseInline(o.header[m].tokens),{header:!0,align:o.align[m]});s+=this.renderer.tablerow(u);let a="";for(let m=0;m<o.rows.length;m++){const g=o.rows[m];u="";for(let d=0;d<g.length;d++)u+=this.renderer.tablecell(this.parseInline(g[d].tokens),{header:!1,align:o.align[d]});a+=this.renderer.tablerow(u)}r+=this.renderer.table(s,a);continue}case"blockquote":{const o=i,s=this.parse(o.tokens);r+=this.renderer.blockquote(s);continue}case"list":{const o=i,s=o.ordered,u=o.start,a=o.loose;let m="";for(let g=0;g<o.items.length;g++){const d=o.items[g],y=d.checked,w=d.task;let k="";if(d.task){const O=this.renderer.checkbox(!!y);a?d.tokens.length>0&&d.tokens[0].type==="paragraph"?(d.tokens[0].text=O+" "+d.tokens[0].text,d.tokens[0].tokens&&d.tokens[0].tokens.length>0&&d.tokens[0].tokens[0].type==="text"&&(d.tokens[0].tokens[0].text=O+" "+d.tokens[0].tokens[0].text)):d.tokens.unshift({type:"text",text:O+" "}):k+=O+" "}k+=this.parse(d.tokens,a),m+=this.renderer.listitem(k,w,!!y)}r+=this.renderer.list(m,s,u);continue}case"html":{const o=i;r+=this.renderer.html(o.text,o.block);continue}case"paragraph":{const o=i;r+=this.renderer.paragraph(this.parseInline(o.tokens));continue}case"text":{let o=i,s=o.tokens?this.parseInline(o.tokens):o.text;for(;l+1<t.length&&t[l+1].type==="text";)o=t[++l],s+=`
|
||||
`+(o.tokens?this.parseInline(o.tokens):o.text);r+=n?this.renderer.paragraph(s):s;continue}default:{const o='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return r}parseInline(t,n){n=n||this.renderer;let r="";for(let l=0;l<t.length;l++){const i=t[l];if(this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[i.type]){const o=this.options.extensions.renderers[i.type].call({parser:this},i);if(o!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){r+=o||"";continue}}switch(i.type){case"escape":{const o=i;r+=n.text(o.text);break}case"html":{const o=i;r+=n.html(o.text);break}case"link":{const o=i;r+=n.link(o.href,o.title,this.parseInline(o.tokens,n));break}case"image":{const o=i;r+=n.image(o.href,o.title,o.text);break}case"strong":{const o=i;r+=n.strong(this.parseInline(o.tokens,n));break}case"em":{const o=i;r+=n.em(this.parseInline(o.tokens,n));break}case"codespan":{const o=i;r+=n.codespan(o.text);break}case"br":{r+=n.br();break}case"del":{const o=i;r+=n.del(this.parseInline(o.tokens,n));break}case"text":{const o=i;r+=n.text(o.text);break}default:{const o='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return r}}class Gr{constructor(t){G(this,"options");this.options=t||Cn}preprocess(t){return t}postprocess(t){return t}processAllTokens(t){return t}}G(Gr,"passThroughHooks",new Set(["preprocess","postprocess","processAllTokens"]));var xn,Rs,Bd;class pg{constructor(...t){Xu(this,xn);G(this,"defaults",Tu());G(this,"options",this.setOptions);G(this,"parse",El(this,xn,Rs).call(this,kt.lex,wt.parse));G(this,"parseInline",El(this,xn,Rs).call(this,kt.lexInline,wt.parseInline));G(this,"Parser",wt);G(this,"Renderer",cl);G(this,"TextRenderer",Pu);G(this,"Lexer",kt);G(this,"Tokenizer",Ri);G(this,"Hooks",Gr);this.use(...t)}walkTokens(t,n){var l,i;let r=[];for(const o of t)switch(r=r.concat(n.call(this,o)),o.type){case"table":{const s=o;for(const u of s.header)r=r.concat(this.walkTokens(u.tokens,n));for(const u of s.rows)for(const a of u)r=r.concat(this.walkTokens(a.tokens,n));break}case"list":{const s=o;r=r.concat(this.walkTokens(s.items,n));break}default:{const s=o;(i=(l=this.defaults.extensions)==null?void 0:l.childTokens)!=null&&i[s.type]?this.defaults.extensions.childTokens[s.type].forEach(u=>{const a=s[u].flat(1/0);r=r.concat(this.walkTokens(a,n))}):s.tokens&&(r=r.concat(this.walkTokens(s.tokens,n)))}}return r}use(...t){const n=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{const l={...r};if(l.async=this.defaults.async||l.async||!1,r.extensions&&(r.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){const o=n.renderers[i.name];o?n.renderers[i.name]=function(...s){let u=i.renderer.apply(this,s);return u===!1&&(u=o.apply(this,s)),u}:n.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const o=n[i.level];o?o.unshift(i.tokenizer):n[i.level]=[i.tokenizer],i.start&&(i.level==="block"?n.startBlock?n.startBlock.push(i.start):n.startBlock=[i.start]:i.level==="inline"&&(n.startInline?n.startInline.push(i.start):n.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(n.childTokens[i.name]=i.childTokens)}),l.extensions=n),r.renderer){const i=this.defaults.renderer||new cl(this.defaults);for(const o in r.renderer){if(!(o in i))throw new Error(`renderer '${o}' does not exist`);if(o==="options")continue;const s=o,u=r.renderer[s],a=i[s];i[s]=(...m)=>{let g=u.apply(i,m);return g===!1&&(g=a.apply(i,m)),g||""}}l.renderer=i}if(r.tokenizer){const i=this.defaults.tokenizer||new Ri(this.defaults);for(const o in r.tokenizer){if(!(o in i))throw new Error(`tokenizer '${o}' does not exist`);if(["options","rules","lexer"].includes(o))continue;const s=o,u=r.tokenizer[s],a=i[s];i[s]=(...m)=>{let g=u.apply(i,m);return g===!1&&(g=a.apply(i,m)),g}}l.tokenizer=i}if(r.hooks){const i=this.defaults.hooks||new Gr;for(const o in r.hooks){if(!(o in i))throw new Error(`hook '${o}' does not exist`);if(o==="options")continue;const s=o,u=r.hooks[s],a=i[s];Gr.passThroughHooks.has(o)?i[s]=m=>{if(this.defaults.async)return Promise.resolve(u.call(i,m)).then(d=>a.call(i,d));const g=u.call(i,m);return a.call(i,g)}:i[s]=(...m)=>{let g=u.apply(i,m);return g===!1&&(g=a.apply(i,m)),g}}l.hooks=i}if(r.walkTokens){const i=this.defaults.walkTokens,o=r.walkTokens;l.walkTokens=function(s){let u=[];return u.push(o.call(this,s)),i&&(u=u.concat(i.call(this,s))),u}}this.defaults={...this.defaults,...l}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,n){return kt.lex(t,n??this.defaults)}parser(t,n){return wt.parse(t,n??this.defaults)}}xn=new WeakSet,Rs=function(t,n){return(r,l)=>{const i={...l},o={...this.defaults,...i};this.defaults.async===!0&&i
Line truncated
|
||||
Please report this to https://github.com/markedjs/marked.`,t){const l="<p>An error occurred:</p><pre>"+$e(r.message+"",!0)+"</pre>";return n?Promise.resolve(l):l}if(n)return Promise.reject(r);throw r}};const En=new pg;function H(e,t){return En.parse(e,t)}H.options=H.setOptions=function(e){return En.setOptions(e),H.defaults=En.defaults,Od(H.defaults),H};H.getDefaults=Tu;H.defaults=Cn;H.use=function(...e){return En.use(...e),H.defaults=En.defaults,Od(H.defaults),H};H.walkTokens=function(e,t){return En.walkTokens(e,t)};H.parseInline=En.parseInline;H.Parser=wt;H.parser=wt.parse;H.Renderer=cl;H.TextRenderer=Pu;H.Lexer=kt;H.lexer=kt.lex;H.Tokenizer=Ri;H.Hooks=Gr;H.parse=H;H.options;H.setOptions;H.use;H.walkTokens;H.parseInline;wt.parse;kt.lex;/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */const{entries:Wd,setPrototypeOf:ac,isFrozen:hg,getPrototypeOf:mg,getOwnPropertyDescriptor:gg}=Object;let{freeze:Ne,seal:nt,create:ni}=Object,{apply:As,construct:Ls}=typeof Reflect<"u"&&Reflect;Ne||(Ne=function(t){return t});nt||(nt=function(t){return t});As||(As=function(t,n){for(var r=arguments.length,l=new Array(r>2?r-2:0),i=2;i<r;i++)l[i-2]=arguments[i];return t.apply(n,l)});Ls||(Ls=function(t){for(var n=arguments.length,r=new Array(n>1?n-1:0),l=1;l<n;l++)r[l-1]=arguments[l];return new t(...r)});const Bl=Re(Array.prototype.forEach),yg=Re(Array.prototype.lastIndexOf),cc=Re(Array.prototype.pop),Nr=Re(Array.prototype.push),vg=Re(Array.prototype.splice),ri=Re(String.prototype.toLowerCase),No=Re(String.prototype.toString),Ro=Re(String.prototype.match),Rr=Re(String.prototype.replace),kg=Re(String.prototype.indexOf),wg=Re(String.prototype.trim),Fe=Re(Object.prototype.hasOwnProperty),_e=Re(RegExp.prototype.test),Ar=Sg(TypeError);function Re(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var n=arguments.length,r=new Array(n>1?n-1:0),l=1;l<n;l++)r[l-1]=arguments[l];return As(e,t,r)}}function Sg(e){return function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return Ls(e,n)}}function F(e,t){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:ri;ac&&ac(e,null);let r=t.length;for(;r--;){let l=t[r];if(typeof l=="string"){const i=n(l);i!==l&&(hg(t)||(t[r]=i),l=i)}e[l]=!0}return e}function Eg(e){for(let t=0;t<e.length;t++)Fe(e,t)||(e[t]=null);return e}function gt(e){const t=ni(null);for(const[n,r]of Wd(e))Fe(e,n)&&(Array.isArray(r)?t[n]=Eg(r):r&&typeof r=="object"&&r.constructor===Object?t[n]=gt(r):t[n]=r);return t}function Lr(e,t){for(;e!==null;){const r=gg(e,t);if(r){if(r.get)return Re(r.get);if(typeof r.value=="function")return Re(r.value)}e=mg(e)}function n(){return null}return n}const fc=Ne(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),Ao=Ne(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),Lo=Ne(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),xg=Ne(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),Po=Ne(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mta
Line truncated
|
||||
`+Pe),lt&&Bl([Ge,Ye,xt],Ct=>{Pe=Rr(Pe,Ct," ")}),v&&un?v.createHTML(Pe):Pe},t.setConfig=function(){let z=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};qi(z),dr=!0},t.clearConfig=function(){On=null,dr=!1},t.isValidAttribute=function(z,f,x){On||qi({});const P=ue(z),b=ue(f);return Bu(P,b,x)},t.addHook=function(z,f){typeof f=="function"&&Nr(B[z],f)},t.removeHook=function(z,f){if(f!==void 0){const x=yg(B[z],f);return x===-1?void 0:vg(B[z],x,1)[0]}return cc(B[z])},t.removeHooks=function(z){B[z]=[]},t.removeAllHooks=function(){B=gc()},t}var Dg=Gd();let Io=!1;async function Mg(){if(Io||window.hljs){Io=!0;return}const e=["javascript","typescript","python","rust","bash","json","sql","css","xml","go","cpp","c"],t="https://cdn.jsdelivr.net/npm/highlight.js@11/lib";await yc(`${t}/core.min.js`),await Promise.all(e.map(n=>yc(`${t}/languages/${n}.min.js`))),Io=!0}function yc(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.onload=()=>t(),r.onerror=n,document.head.appendChild(r)})}const Yd=new cl;Yd.code=function(e,t){var i;const n=t&&((i=window.hljs)!=null&&i.getLanguage(t))?t:"plaintext";let r=e;try{window.hljs&&(r=n!=="plaintext"?window.hljs.highlight(e,{language:n}).value:window.hljs.highlightAuto(e).value)}catch{}const l=e.replace(/&/g,"&").replace(/'/g,"'");return`<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span class="code-lang">${n}</span>
|
||||
<button class="copy-code-btn" data-code='${l}'>Copy</button>
|
||||
</div>
|
||||
<pre><code class="hljs language-${n}">${r}</code></pre>
|
||||
</div>`};H.setOptions({breaks:!0,gfm:!0});H.use({renderer:Yd});function vc(e){const t=H.parse(e);return Dg.sanitize(t,{ADD_TAGS:["div","span","button","pre","code"],ADD_ATTR:["class","data-code"],FORCE_BODY:!1})}function jg({message:e,onDelete:t,onEditAndResend:n,onEditAssistant:r,renderMarkdown:l}){const[i,o]=M.useState(!1),[s,u]=M.useState(e.content),[a,m]=M.useState(!1),g=M.useRef(null),d=M.useRef(null);M.useEffect(()=>{if(!d.current)return;d.current.querySelectorAll(".copy-code-btn").forEach(h=>{h.onclick=()=>{const v=h.getAttribute("data-code")||"";navigator.clipboard.writeText(v).then(()=>{h.textContent="Copied!",h.style.color="var(--success)",setTimeout(()=>{h.textContent="Copy",h.style.color=""},2e3)})}})});const y=()=>{const c=g.current;c&&(c.style.height="auto",c.style.height=Math.min(c.scrollHeight,300)+"px")},w=()=>{u(e.content),o(!0),setTimeout(()=>{var c;(c=g.current)==null||c.focus(),y()},0)},k=()=>o(!1),O=c=>{const h=s.trim();h&&(o(!1),c&&n?n(e,h):r&&r(e.id,h))},p=()=>{navigator.clipboard.writeText(e.content).then(()=>{m(!0),setTimeout(()=>m(!1),2e3)})};return _.jsx("div",{className:`msg-wrapper ${e.role}`,children:i?_.jsxs("div",{className:"msg-edit-container",children:[_.jsx("textarea",{ref:g,className:"msg-edit-textarea",value:s,onChange:c=>{u(c.target.value),y()},onKeyDown:c=>{c.key==="Escape"&&k(),c.key==="Enter"&&c.ctrlKey&&O(!1)}}),_.jsxs("div",{className:"msg-edit-actions",children:[n&&_.jsx("button",{className:"btn-primary",onClick:()=>O(!0),children:"Save & Resend"}),_.jsx("button",{className:"btn-secondary",onClick:()=>O(!1),children:"Save only"}),_.jsx("button",{className:"btn-ghost",onClick:k,children:"Cancel"})]})]}):_.jsxs(_.Fragment,{children:[e.role==="assistant"?_.jsx("div",{ref:d,className:"msg-bubble",dangerouslySetInnerHTML:{__html:l(e.content)}}):_.jsx("div",{className:"msg-bubble",children:e.content}),_.jsxs("div",{className:"msg-actions",children:[_.jsx("button",{className:`action-btn ${a?"copied":""}`,onClick:p,children:a?"✓ Copied":"⎘ Copy"}),_.jsxs("button",{className:"action-btn",onClick:w,children:["✎ Edit",n?" & Resend":""]}),_.jsx("button",{className:"action-btn danger",onClick:()=>t(e.id),children:"✕ Delete"})]})]})})}const Fg=20;function Ug({db:e,chatId:t,server:n,apiKey:r,model:l,onChatCreated:i,onChatUpdated:o}){const[s,u]=M.useState([]),[a,m]=M.useState(""),[g,d]=M.useState(!1),[y,w]=M.useState(null),[k,O]=M.useState(null),[p,c]=M.useState(!0),[h,v]=M.useState(!0),T=M.useRef(null),N=M.useRef(null),A=M.useRef(null);M.useEffect(()=>{Mg()},[]);const L=M.useCallback(()=>{if(!t){u([]);return}const E=e.query("SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",[t]);u(E)},[e,t]);M.useEffect(()=>{L()},[L]),M.useEffect(()=>{var E;(E=T.current)==null||E.scrollIntoView({behavior:"smooth"})},[s,y]);const X=()=>{const E=N.current;E&&(E.style.height="auto",E.style.height=Math.min(E.scrollHeight,200)+"px")},j=M.useCallback(()=>{if(t)return t;e.run("INSERT INTO chats (title) VALUES ('New Chat')");const I=e.get("SELECT last_insert_rowid() as id").id;return i(I),I},[t,e,i]),B=M.useCallback((E,I)=>{const D=I.slice(0,50)+(I.length>50?"…":"");e.run("UPDATE chats SET title = ?, updated_at = strftime('%s','now') WHERE id = ?",[D,E]),o()},[e,o]),Ge=M.useCallback(E=>{const I=h?Fg:1;return e.query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at DESC LIMIT ?",[E,I]).reverse()},[e,h]),Ye=M.useCallback(async(E,I)=>{var V,ye,Xe,rt,cr,Nn,fr;if(!E.trim()||g)return;if(!l){alert("No model selected");return}d(!0),w(null);const D=I??j();e.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)",[D,E]);const K=e.get("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?",[D]);(K==null?void 0:K.c)===1&&B(D,E),L();const J=Ge(D),je={"Content-Type":"application/json"};r&&(je.Authorization=`Bearer ${r}`),A.current=new AbortController;try{const Ke=await fetch(`${n}/v1/chat/completions`,{method:"POST",headers:je,signal:A.current.signal,body:JSON.stringify({model:l,messages:J,stream:p,stream_options:p?{include_usage:!0}:void 0,max_tokens:2048})});if(!Ke.ok){const ve=await Ke.json().catch(()=>({}));throw new Error(((V=ve==null?void 0:ve.error)==null?void 0:V.message)||`HTTP ${Ke.status}`)}let lt="",it=null;if(p){const ve=Ke.body.getReader(),dr=new TextDecoder;let sn="";for(;;){const{done:jt,value:Rn}=await ve.read();if(jt)break;sn+=dr.decode(Rn,{stream:!0});const un=sn.split(`
|
||||
`);sn=un.pop();for(const pr of un){if(!pr.startsWith("data: "))continue;const hr=pr.slice(6).trim();if(hr!=="[DONE]")try{const mr=JSON.parse(hr),An=(rt=(Xe=(ye=mr.choices)==null?void 0:ye[0])==null?void 0:Xe.delta)==null?void 0:rt.content;An&&(lt+=An,w(lt)),mr.usage&&(it=mr.usage)}catch{}}}}else{const ve=await Ke.json();lt=((fr=(Nn=(cr=ve.choices)==null?void 0:cr[0])==null?void 0:Nn.message)==null?void 0:fr.content)||"",it=ve.usage}e.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)",[D,lt]),e.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?",[D]),it!=null&&it.tokens_per_second&&O(it.tokens_per_second),o()}catch(Ke){Ke instanceof Error&&Ke.name!=="AbortError"&&e.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)",[D,`⚠️ Error: ${Ke.message}`])}finally{d(!1),w(null),L(),A.current=null}},[g,l,n,r,p,e,j,B,Ge,L,o]),xt=()=>{const E=a.trim();E&&(m(""),N.current&&(N.current.style.height="auto"),Ye(E))},ar=()=>{var E;(E=A.current)==null||E.abort()},ln=E=>{e.run("DELETE FROM messages WHERE id = ?",[E]),L()},on=(E,I)=>{e.run("UPDATE messages SET content = ? WHERE id = ?",[I,E.id]),e.run("DELETE FROM messages WHERE chat_id = ? AND id > ?",[E.chat_id,E.id]),L(),Ye(I,E.chat_id)},C=(E,I)=>{e.run("UPDATE messages SET content = ? WHERE id = ?",[I,E]),L()};return _.jsxs("div",{className:"chat-view",children:[_.jsxs("div",{className:"chat-topbar",children:[_.jsx("div",{className:"chat-topbar-left",children:k!==null&&_.jsxs("span",{className:"tps-badge",children:[k.toFixed(1)," tok/s"]})}),_.jsxs("div",{className:"chat-topbar-right",children:[_.jsxs("label",{className:"toggle-label",children:[_.jsx("input",{type:"checkbox",checked:p,onChange:E=>c(E.target.checked)}),"Streaming"]}),_.jsxs("label",{className:"toggle-label",children:[_.jsx("input",{type:"checkbox",checked:h,onChange:E=>v(E.target.checked)}),"Memory"]})]})]}),_.jsxs("div",{className:"messages-area",children:[!t&&s.length===0&&_.jsxs("div",{className:"empty-state",children:[_.jsx("div",{className:"empty-icon",children:"⚡"}),_.jsx("h2",{children:"ForgeLM Chat"}),_.jsx("p",{children:"Start a conversation. Supports full Markdown, code highlighting, and persistent history."})]}),s.map(E=>_.jsx(jg,{message:E,onDelete:ln,onEditAndResend:E.role==="user"?on:void 0,onEditAssistant:E.role==="assistant"?C:void 0,renderMarkdown:vc},E.id)),g&&y!==null&&_.jsx("div",{className:"msg-wrapper assistant",children:_.jsx("div",{className:"msg-bubble",dangerouslySetInnerHTML:{__html:vc(y)}})}),g&&y===null&&_.jsx("div",{className:"msg-wrapper assistant",children:_.jsxs("div",{className:"thinking",children:[_.jsx("span",{}),_.jsx("span",{}),_.jsx("span",{})]})}),_.jsx("div",{ref:T})]}),_.jsxs("div",{className:"input-area",children:[_.jsxs("div",{className:"input-row",children:[_.jsx("textarea",{ref:N,className:"input-textarea",value:a,onChange:E=>{m(E.target.value),X()},onKeyDown:E=>{E.key==="Enter"&&!E.shiftKey&&(E.preventDefault(),xt())},placeholder:"Message… (Enter to send, Shift+Enter for new line)",rows:1,disabled:g}),g?_.jsx("button",{className:"stop-btn",onClick:ar,children:"■ Stop"}):_.jsx("button",{className:"send-btn",onClick:xt,disabled:!a.trim(),children:"Send ↑"})]}),_.jsx("div",{className:"input-meta",children:_.jsxs("span",{className:"char-count",children:[a.length," chars"]})})]})]})}class $g{constructor(t){G(this,"inner");this.inner=t}run(t,n=[]){this.inner.run(t,n),this.persist()}query(t,n=[]){const r=this.inner.prepare(t);r.bind(n);const l=[];for(;r.step();)l.push(r.getAsObject());return r.free(),l}get(t,n=[]){return this.query(t,n)[0]??null}persist(){try{const t=this.inner.export(),n=btoa(String.fromCharCode(...t));localStorage.setItem("forgelm_db_v2",n)}catch{}}}async function Hg(){window.initSqlJs||await new Promise((l,i)=>{const o=document.createElement("script");o.src="https://cdn.jsdelivr.net/npm/[email protected]/dist/sql-wasm.js",o.onload=()=>l(),o.onerror=i,document.head.appendChild(o)});const e=await window.initSqlJs({locateFile:l=>`https://cdn.jsdelivr.net/npm/[email protected]/dist/${l}`}),t=localStorage.getItem("forgelm_db_v2");let n;if(t){const l=Uint8Array.from(atob(t),i=>i.charCodeAt(0));n=new e.Database(l)}else n=new e.Database;const r=new $g(n);return r.run(`
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT 'New Chat',
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)
|
||||
`),r.run(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)
|
||||
`),r.run("CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)"),r}function Bg(){const[e,t]=M.useState(null),[n,r]=M.useState(!1);return M.useEffect(()=>{Hg().then(l=>{t(l),r(!0)})},[]),{db:e,ready:n}}function Wg(e,t){const[n,r]=M.useState([]),[l,i]=M.useState(""),[o,s]=M.useState("loading"),u=M.useCallback(async()=>{s("loading");try{const a={};t&&(a.Authorization=`Bearer ${t}`);const d=(await(await fetch(`${e}/v1/models`,{headers:a})).json()).data||[];r(d),d.length>0&&!l&&i(d[0].id),s("ok")}catch{s("error")}},[e,t,l]);return M.useEffect(()=>{u();const a=setInterval(u,3e4);return()=>clearInterval(a)},[u]),{models:n,selectedModel:l,setSelectedModel:i,status:o,refresh:u}}function Vg(){const{db:e,ready:t}=Bg(),[n,r]=M.useState(null),[l,i]=M.useState([]),[o,s]=M.useState(()=>localStorage.getItem("forgelm_server")||"http://127.0.0.1:8080"),[u,a]=M.useState(()=>localStorage.getItem("forgelm_apikey")||""),{models:m,selectedModel:g,setSelectedModel:d,status:y,refresh:w}=Wg(o,u);M.useEffect(()=>{localStorage.setItem("forgelm_server",o)},[o]),M.useEffect(()=>{localStorage.setItem("forgelm_apikey",u)},[u]);const k=M.useCallback(()=>{if(!e)return;const c=e.query("SELECT * FROM chats ORDER BY updated_at DESC");i(c)},[e]);M.useEffect(()=>{k()},[k]);const O=M.useCallback(()=>{if(!e)return;e.run("INSERT INTO chats (title) VALUES ('New Chat')");const c=e.get("SELECT last_insert_rowid() as id");c&&(r(c.id),k())},[e,k]),p=M.useCallback(c=>{e&&(e.run("DELETE FROM chats WHERE id = ?",[c]),n===c&&r(null),k())},[e,n,k]);return t?_.jsxs("div",{className:"app-layout",children:[_.jsx(Om,{chats:l,currentChatId:n,onSelectChat:r,onNewChat:O,onDeleteChat:p,server:o,onServerChange:s,apiKey:u,onApiKeyChange:a,models:m,selectedModel:g,onModelChange:d,status:y,onRefreshModels:w}),_.jsx(Ug,{db:e,chatId:n,server:o,apiKey:u,model:g,onChatCreated:c=>{r(c),k()},onChatUpdated:k})]}):_.jsxs("div",{className:"app-loading",children:[_.jsx("div",{className:"loading-spinner"}),_.jsx("span",{children:"Initializing…"})]})}zo.createRoot(document.getElementById("root")).render(_.jsx(wp.StrictMode,{children:_.jsx(Vg,{})}));
|
||||
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ForgeLM Chat</title>
|
||||
<script type="module" crossorigin src="/assets/index-DyFHx2Ao.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ci29W9gt.css">
|
||||
<script type="module" crossorigin src="/assets/index-FraKRbHA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DGWirp_d.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/baseline-browser-mapping/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/baseline-browser-mapping/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\baseline-browser-mapping\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\baseline-browser-mapping\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\baseline-browser-mapping\dist\cli.cjs" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\baseline-browser-mapping\dist\cli.cjs" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\baseline-browser-mapping\dist\cli.cjs" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\baseline-browser-mapping\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/baseline-browser-mapping/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/browserslist/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/browserslist/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../browserslist/cli.js" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\browserslist\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\browserslist\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\browserslist\cli.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\browserslist\cli.js" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\browserslist\cli.js" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\browserslist\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/browserslist/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/esbuild/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/esbuild/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../esbuild/bin/esbuild" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\esbuild\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\esbuild\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\esbuild\bin\esbuild" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\esbuild\bin\esbuild" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\esbuild\bin\esbuild" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\esbuild\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/esbuild/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/jsesc/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/jsesc/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../jsesc/bin/jsesc" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../jsesc/bin/jsesc" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\jsesc\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\jsesc\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\jsesc\bin\jsesc" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\jsesc\bin\jsesc" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\jsesc\bin\jsesc" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\jsesc\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/jsesc/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/json5/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/json5/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../json5/lib/cli.js" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\json5\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\json5\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\json5\lib\cli.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\json5\lib\cli.js" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\json5\lib\cli.js" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\json5\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/json5/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/loose-envify/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/loose-envify/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../loose-envify/cli.js" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../loose-envify/cli.js" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\loose-envify\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\loose-envify\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\loose-envify\cli.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\loose-envify\cli.js" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\loose-envify\cli.js" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\loose-envify\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/loose-envify/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/nanoid/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/nanoid/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\nanoid\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\nanoid\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\nanoid\bin\nanoid.cjs" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\nanoid\bin\nanoid.cjs" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.cjs" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\nanoid\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/nanoid/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/@babel/parser/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/@babel/parser/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\@babel\parser\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\@babel\parser\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\@babel\parser\bin\babel-parser.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\@babel\parser\bin\babel-parser.js" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@babel\parser\bin\babel-parser.js" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\@babel\parser\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/@babel/parser/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/rollup/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/rollup/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../rollup/dist/bin/rollup" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../rollup/dist/bin/rollup" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\rollup\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\rollup\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\rollup\dist\bin\rollup" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\rollup\dist\bin\rollup" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\rollup\dist\bin\rollup" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\rollup\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/rollup/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/semver/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/semver/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../semver/bin/semver.js" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../semver/bin/semver.js" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\semver\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\semver\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\semver\bin\semver.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\semver\bin\semver.js" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\semver\bin\semver.js" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\semver\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/semver/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+6
-1
@@ -9,8 +9,13 @@ case `uname` in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/update-browserslist-db/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/update-browserslist-db/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../update-browserslist-db/cli.js" "$@"
|
||||
else
|
||||
else
|
||||
exec node "$basedir/../update-browserslist-db/cli.js" "$@"
|
||||
fi
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
@SETLOCAL
|
||||
@IF NOT DEFINED NODE_PATH (
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\update-browserslist-db\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\update-browserslist-db\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||
)
|
||||
@IF EXIST "%~dp0\node.exe" (
|
||||
"%~dp0\node.exe" "%~dp0\..\update-browserslist-db\cli.js" %*
|
||||
) ELSE (
|
||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
node "%~dp0\..\update-browserslist-db\cli.js" %*
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\update-browserslist-db\cli.js" %*
|
||||
+13
@@ -2,11 +2,23 @@
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
$pathsep=":"
|
||||
$env_node_path=$env:NODE_PATH
|
||||
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\update-browserslist-db\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
$pathsep=";"
|
||||
} else {
|
||||
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/update-browserslist-db/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||
$env:NODE_PATH=$new_node_path
|
||||
} else {
|
||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||
}
|
||||
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
$env:NODE_PATH=$env_node_path
|
||||
exit $ret
|
||||
+1157
File diff suppressed because it is too large.
Load diff
Generated
+1678
-1
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test": "vitest run",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
@@ -12,9 +13,9 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"marked": "^12.0.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.0.0",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -23,8 +24,10 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8",
|
||||
"@vitejs/plugin-react": "^4.3.1"
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
Generated
+1272
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
Generated
+5166
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ForgeLM Chat","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-open"]}}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+66
-122
@@ -1,10 +1,14 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::Mutex;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
pub struct ServerState(pub Mutex<Option<Child>>);
|
||||
mod runtime;
|
||||
|
||||
use runtime::config::{load_runtime_config, save_runtime_config};
|
||||
use runtime::process::find_localllm_binary;
|
||||
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
|
||||
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
|
||||
|
||||
// ─── GPU ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,107 +51,73 @@ fn query_gpu() -> Result<GpuInfo, String> {
|
||||
|
||||
// ─── Server lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Locate the `localllm` binary by searching several well-known locations:
|
||||
/// 1. Workspace `target/release/` relative to the Tauri app's source tree
|
||||
/// 2. Next to the running executable (for installed/bundled builds)
|
||||
/// 3. System PATH
|
||||
fn find_localllm_binary() -> Option<PathBuf> {
|
||||
let exe_name = if cfg!(target_os = "windows") { "localllm.exe" } else { "localllm" };
|
||||
#[tauri::command]
|
||||
fn bootstrap_runtime_command(
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
bootstrap_runtime(app, state)
|
||||
}
|
||||
|
||||
// 1. Workspace target/release — works when running `npm run tauri dev` from tauri-app/
|
||||
// The Tauri dev binary lives at tauri-app/src-tauri/target/debug/forgelm-chat.exe
|
||||
// so we walk up to find the workspace root and look for target/release/localllm.exe
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Walk up from the current exe looking for target/release/localllm.exe
|
||||
let mut dir = exe.parent();
|
||||
while let Some(d) = dir {
|
||||
let candidate = d.join("target").join("release").join(exe_name);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
// Also check sibling: if we're inside target/debug, go up two levels
|
||||
let candidate2 = d.join(exe_name);
|
||||
if candidate2.exists() {
|
||||
return Some(candidate2);
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
}
|
||||
#[tauri::command]
|
||||
fn get_runtime_snapshot_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
get_runtime_snapshot(state)
|
||||
}
|
||||
|
||||
// 2. Relative to CWD (useful when launched from repo root)
|
||||
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
|
||||
if cwd_candidate.exists() {
|
||||
return Some(cwd_candidate);
|
||||
}
|
||||
#[tauri::command]
|
||||
fn save_runtime_config_command_handler(
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
config: RuntimeConfig,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
save_runtime_config_command(app, state, config)
|
||||
}
|
||||
|
||||
// 3. System PATH
|
||||
if let Ok(output) = Command::new(if cfg!(target_os = "windows") { "where" } else { "which" })
|
||||
.arg("localllm")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path_str = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = path_str.lines().next().unwrap_or("").trim();
|
||||
if !first_line.is_empty() {
|
||||
return Some(PathBuf::from(first_line));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tauri::command]
|
||||
fn start_runtime_command(
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
start_runtime(app, state)
|
||||
}
|
||||
|
||||
None
|
||||
#[tauri::command]
|
||||
fn stop_runtime_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
stop_runtime(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_server(
|
||||
state: State<ServerState>,
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
model_path: String,
|
||||
addr: String,
|
||||
gpu_layers: i32,
|
||||
ctx_size: u32,
|
||||
) -> Result<String, String> {
|
||||
let mut guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
if guard.is_some() {
|
||||
return Err("Server is already running".into());
|
||||
}
|
||||
|
||||
let binary_path = find_localllm_binary()
|
||||
.ok_or_else(|| {
|
||||
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
|
||||
Then ensure the binary is in one of: target/release/, PATH, or next to this app."
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let child = Command::new(&binary_path)
|
||||
.env("LOCALLLM_MODEL", &model_path)
|
||||
.env("LOCALLLM_ADDR", &addr)
|
||||
.env("LOCALLLM_GPU_LAYERS", gpu_layers.to_string())
|
||||
.env("LOCALLLM_CTX", ctx_size.to_string())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))?;
|
||||
|
||||
let pid = child.id();
|
||||
*guard = Some(child);
|
||||
Ok(format!("Server started (pid={pid}) on {addr}"))
|
||||
let mut config = load_runtime_config(&app).unwrap_or_default();
|
||||
config.connection_mode = runtime::types::ConnectionMode::Local;
|
||||
config.model_path = Some(model_path);
|
||||
config.addr = addr;
|
||||
config.gpu_layers = gpu_layers;
|
||||
config.ctx_size = ctx_size;
|
||||
save_runtime_config(&app, &config)?;
|
||||
let snapshot = start_runtime(app, state)?;
|
||||
Ok(format!("Local API {}", snapshot.resolved_url))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_server(state: State<ServerState>) -> Result<String, String> {
|
||||
let mut guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
match guard.as_mut() {
|
||||
None => Err("Server is not running".into()),
|
||||
Some(child) => {
|
||||
child.kill().map_err(|e| format!("Failed to kill server: {e}"))?;
|
||||
let _ = child.wait();
|
||||
*guard = None;
|
||||
Ok("Server stopped".into())
|
||||
}
|
||||
}
|
||||
fn stop_server(state: State<RuntimeState>) -> Result<String, String> {
|
||||
let snapshot = stop_runtime(state)?;
|
||||
Ok(format!("Local API stopped ({})", snapshot.resolved_url))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn server_status(state: State<ServerState>) -> String {
|
||||
let guard = state.0.lock().unwrap();
|
||||
if guard.is_some() { "running".into() } else { "stopped".into() }
|
||||
fn server_status(state: State<RuntimeState>) -> String {
|
||||
match get_runtime_snapshot(state) {
|
||||
Ok(snapshot) if snapshot.process_running => "running".into(),
|
||||
_ => "stopped".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Model files ──────────────────────────────────────────────────────────────
|
||||
@@ -288,46 +258,15 @@ fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, String>
|
||||
// ─── Persistent config ────────────────────────────────────────────────────────
|
||||
|
||||
/// All user-configurable settings, persisted to app data dir as config.json.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
|
||||
pub struct AppConfig {
|
||||
pub models_dir: Option<String>,
|
||||
pub model_path: Option<String>,
|
||||
pub addr: Option<String>,
|
||||
pub gpu_layers: Option<i32>,
|
||||
pub ctx_size: Option<u32>,
|
||||
pub api_key: Option<String>,
|
||||
pub server_url: Option<String>,
|
||||
/// Extra model paths added via file picker (not necessarily in models_dir)
|
||||
pub extra_model_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn config_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Cannot create app data dir: {e}"))?;
|
||||
Ok(data_dir.join("config.json"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
|
||||
let path = config_path(&app)?;
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Serialization error: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
|
||||
fn save_config(app: AppHandle, config: RuntimeConfig) -> Result<(), String> {
|
||||
save_runtime_config(&app, &config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_config(app: AppHandle) -> Result<AppConfig, String> {
|
||||
let path = config_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(AppConfig::default());
|
||||
}
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
|
||||
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
|
||||
fn load_config(app: AppHandle) -> Result<RuntimeConfig, String> {
|
||||
load_runtime_config(&app)
|
||||
}
|
||||
|
||||
/// Returns the resolved path to the localllm binary (useful for debugging).
|
||||
@@ -357,13 +296,18 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.manage(RuntimeState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
query_gpu,
|
||||
scan_models,
|
||||
pick_models_dir,
|
||||
pick_model_files,
|
||||
import_model,
|
||||
bootstrap_runtime_command,
|
||||
get_runtime_snapshot_command,
|
||||
save_runtime_config_command_handler,
|
||||
start_runtime_command,
|
||||
stop_runtime_command,
|
||||
start_server,
|
||||
stop_server,
|
||||
server_status,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::types::RuntimeConfig;
|
||||
|
||||
pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("Cannot create app data dir: {e}"))?;
|
||||
Ok(data_dir.join("config.json"))
|
||||
}
|
||||
|
||||
pub fn load_runtime_config(app: &AppHandle) -> Result<RuntimeConfig, String> {
|
||||
let path = config_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(RuntimeConfig::default());
|
||||
}
|
||||
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
|
||||
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
|
||||
}
|
||||
|
||||
pub fn save_runtime_config(app: &AppHandle, config: &RuntimeConfig) -> Result<(), String> {
|
||||
let path = config_path(app)?;
|
||||
let json =
|
||||
serde_json::to_string_pretty(config).map_err(|e| format!("Serialization error: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn resolve_socket_addr(addr: &str) -> Option<std::net::SocketAddr> {
|
||||
match addr.to_socket_addrs() {
|
||||
Ok(mut addrs) => addrs.next(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn health_response_is_ready(response: &str) -> bool {
|
||||
let mut sections = response.splitn(2, "\r\n\r\n");
|
||||
let headers = sections.next().unwrap_or_default();
|
||||
let body = sections.next().unwrap_or_default();
|
||||
|
||||
if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
json.get("status")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|status| status == "ok")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn wait_for_health(addr: &str, timeout: Duration) -> bool {
|
||||
let Some(socket_addr) = resolve_socket_addr(addr) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
match TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)) {
|
||||
Ok(mut stream) => {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(250)));
|
||||
|
||||
let request =
|
||||
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||
|
||||
if stream.write_all(request.as_bytes()).is_ok() {
|
||||
let mut response = String::new();
|
||||
if stream.read_to_string(&mut response).is_ok()
|
||||
&& health_response_is_ready(&response)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::wait_for_health;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn spawn_http_server(response: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
addr.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_health_accepts_healthy_http_response() {
|
||||
let addr = spawn_http_server(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
|
||||
);
|
||||
|
||||
assert!(wait_for_health(&addr, Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_health_rejects_non_healthy_payloads() {
|
||||
let addr = spawn_http_server(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 21\r\n\r\n{\"status\":\"booting\"}",
|
||||
);
|
||||
|
||||
assert!(!wait_for_health(&addr, Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_health_rejects_plain_open_ports() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind bare port");
|
||||
let addr = listener.local_addr().expect("local addr").to_string();
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Ok((_stream, _)) = listener.accept() {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
});
|
||||
|
||||
assert!(!wait_for_health(&addr, Duration::from_millis(500)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
pub mod config;
|
||||
pub mod health;
|
||||
pub mod process;
|
||||
pub mod types;
|
||||
|
||||
use std::process::Child;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use self::config::{load_runtime_config, save_runtime_config};
|
||||
use self::health::wait_for_health;
|
||||
use self::process::{find_localllm_binary, spawn_localllm};
|
||||
use self::types::{
|
||||
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, StartPolicy,
|
||||
};
|
||||
|
||||
pub struct RuntimeInner {
|
||||
pub child: Option<Child>,
|
||||
pub status: RuntimeStatus,
|
||||
pub last_error: Option<RuntimeErrorInfo>,
|
||||
pub config: RuntimeConfig,
|
||||
}
|
||||
|
||||
pub struct RuntimeState(pub Mutex<RuntimeInner>);
|
||||
|
||||
impl RuntimeState {
|
||||
pub fn new() -> Self {
|
||||
Self(Mutex::new(RuntimeInner {
|
||||
child: None,
|
||||
status: RuntimeStatus::Stopped,
|
||||
last_error: None,
|
||||
config: RuntimeConfig::default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
|
||||
RuntimeErrorInfo {
|
||||
code: code.to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
|
||||
match config.connection_mode {
|
||||
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
|
||||
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
|
||||
current.connection_mode != next.connection_mode
|
||||
|| current.model_path != next.model_path
|
||||
|| current.addr != next.addr
|
||||
|| current.gpu_layers != next.gpu_layers
|
||||
|| current.ctx_size != next.ctx_size
|
||||
}
|
||||
|
||||
fn refresh_process_state(inner: &mut RuntimeInner) {
|
||||
let Some(child) = inner.child.as_mut() else {
|
||||
if matches!(
|
||||
inner.status,
|
||||
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
|
||||
) {
|
||||
inner.status = RuntimeStatus::Stopped;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error(
|
||||
"unexpected_exit",
|
||||
format!("Local API exited unexpectedly with status {status}"),
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
if inner.status == RuntimeStatus::Stopped {
|
||||
inner.status = RuntimeStatus::Ready;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
|
||||
RuntimeSnapshot {
|
||||
status: inner.status,
|
||||
ready: inner.status == RuntimeStatus::Ready,
|
||||
process_running: inner.child.is_some(),
|
||||
resolved_url: resolve_runtime_url(&inner.config),
|
||||
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
|
||||
last_error: inner.last_error.clone(),
|
||||
config: inner.config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
|
||||
pub fn save_runtime_config_command(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
config: RuntimeConfig,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
if inner.child.is_some() && requires_runtime_restart(&inner.config, &config) {
|
||||
inner.last_error = Some(runtime_error(
|
||||
"restart_required",
|
||||
"Stop the local API before changing connection mode, model, address, or runtime sizing.",
|
||||
));
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
save_runtime_config(&app, &config)?;
|
||||
inner.config = config;
|
||||
inner.last_error = None;
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
|
||||
fn start_runtime_inner(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, RuntimeState>,
|
||||
override_config: Option<RuntimeConfig>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
let config = {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
if inner.child.is_some() {
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
if let Some(config) = override_config {
|
||||
save_runtime_config(app, &config)?;
|
||||
inner.config = config;
|
||||
}
|
||||
|
||||
if inner.config.connection_mode != ConnectionMode::Local {
|
||||
inner.last_error = Some(runtime_error(
|
||||
"remote_mode",
|
||||
"Switch to Local mode before starting the built-in API",
|
||||
));
|
||||
inner.status = RuntimeStatus::Error;
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
inner.status = RuntimeStatus::Starting;
|
||||
inner.last_error = None;
|
||||
inner.config.clone()
|
||||
};
|
||||
|
||||
let child = match spawn_localllm(&config) {
|
||||
Ok(child) => child,
|
||||
Err(message) => {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error("spawn_failed", message));
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.child = Some(child);
|
||||
inner.status = RuntimeStatus::Starting;
|
||||
}
|
||||
|
||||
if wait_for_health(&config.addr, Duration::from_secs(15)) {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.status = RuntimeStatus::Ready;
|
||||
inner.last_error = None;
|
||||
Ok(build_snapshot(&inner))
|
||||
} else {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(child) = inner.child.as_mut() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error(
|
||||
"health_timeout",
|
||||
format!(
|
||||
"Local API did not pass /health readiness on {} in time",
|
||||
config.addr
|
||||
),
|
||||
));
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bootstrap_runtime(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
let config = load_runtime_config(&app).unwrap_or_default();
|
||||
let should_autostart = config.connection_mode == ConnectionMode::Local
|
||||
&& config.start_policy == StartPolicy::OnAppLaunch
|
||||
&& config
|
||||
.model_path
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
{
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.config = config.clone();
|
||||
refresh_process_state(&mut inner);
|
||||
}
|
||||
|
||||
if should_autostart {
|
||||
start_runtime_inner(&app, &state, Some(config))
|
||||
} else {
|
||||
get_runtime_snapshot(state)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_runtime(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
start_runtime_inner(&app, &state, None)
|
||||
}
|
||||
|
||||
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.status = RuntimeStatus::Stopping;
|
||||
|
||||
if let Some(child) = inner.child.as_mut() {
|
||||
child
|
||||
.kill()
|
||||
.map_err(|e| format!("Failed to kill server: {e}"))?;
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Stopped;
|
||||
inner.last_error = None;
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command};
|
||||
|
||||
use super::types::RuntimeConfig;
|
||||
|
||||
pub fn find_localllm_binary() -> Option<PathBuf> {
|
||||
let exe_name = if cfg!(target_os = "windows") {
|
||||
"localllm.exe"
|
||||
} else {
|
||||
"localllm"
|
||||
};
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut dir = exe.parent();
|
||||
while let Some(d) = dir {
|
||||
let candidate = d.join("target").join("release").join(exe_name);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
let sibling_candidate = d.join(exe_name);
|
||||
if sibling_candidate.exists() {
|
||||
return Some(sibling_candidate);
|
||||
}
|
||||
|
||||
dir = d.parent();
|
||||
}
|
||||
}
|
||||
|
||||
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
|
||||
if cwd_candidate.exists() {
|
||||
return Some(cwd_candidate);
|
||||
}
|
||||
|
||||
if let Ok(output) = Command::new(if cfg!(target_os = "windows") {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
})
|
||||
.arg("localllm")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path_str = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = path_str.lines().next().unwrap_or("").trim();
|
||||
if !first_line.is_empty() {
|
||||
return Some(PathBuf::from(first_line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
|
||||
let model_path = config
|
||||
.model_path
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "Select a local model before starting the API".to_string())?;
|
||||
|
||||
let binary_path = find_localllm_binary().ok_or_else(|| {
|
||||
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
|
||||
Then ensure the binary is in target/release/, PATH, or next to this app."
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Command::new(&binary_path)
|
||||
.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())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionMode {
|
||||
#[default]
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StartPolicy {
|
||||
#[default]
|
||||
Manual,
|
||||
OnAppLaunch,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeStatus {
|
||||
#[default]
|
||||
Stopped,
|
||||
Starting,
|
||||
Ready,
|
||||
Degraded,
|
||||
Stopping,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct RuntimeConfig {
|
||||
pub connection_mode: ConnectionMode,
|
||||
pub start_policy: StartPolicy,
|
||||
pub server_url: String,
|
||||
pub api_key: String,
|
||||
pub model_path: Option<String>,
|
||||
pub models_dir: Option<String>,
|
||||
pub addr: String,
|
||||
pub gpu_layers: i32,
|
||||
pub ctx_size: u32,
|
||||
pub extra_model_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl RuntimeConfig {
|
||||
pub fn default_addr() -> String {
|
||||
"127.0.0.1:8080".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connection_mode: ConnectionMode::Local,
|
||||
start_policy: StartPolicy::Manual,
|
||||
server_url: String::new(),
|
||||
api_key: String::new(),
|
||||
model_path: None,
|
||||
models_dir: None,
|
||||
addr: Self::default_addr(),
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct RuntimeErrorInfo {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct RuntimeSnapshot {
|
||||
pub status: RuntimeStatus,
|
||||
pub ready: bool,
|
||||
pub process_running: bool,
|
||||
pub resolved_url: String,
|
||||
pub binary_path: Option<String>,
|
||||
pub last_error: Option<RuntimeErrorInfo>,
|
||||
pub config: RuntimeConfig,
|
||||
}
|
||||
+12
-18
@@ -3,7 +3,7 @@ import Sidebar from "./components/Sidebar";
|
||||
import ChatView from "./components/ChatView";
|
||||
import { useDB } from "./hooks/useDB";
|
||||
import { useModels } from "./hooks/useModels";
|
||||
import { useServerProcess } from "./hooks/useServerProcess";
|
||||
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
|
||||
import type { Chat } from "./lib/types";
|
||||
import "./App.css";
|
||||
|
||||
@@ -11,22 +11,20 @@ export default function App() {
|
||||
const { db, ready } = useDB();
|
||||
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [server, setServer] = useState(() => localStorage.getItem("forgelm_server") || "http://127.0.0.1:8080");
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || "");
|
||||
|
||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey);
|
||||
const serverProcess = useServerProcess();
|
||||
|
||||
// Persist server/key
|
||||
useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]);
|
||||
useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]);
|
||||
const runtime = useRuntimeManager();
|
||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
|
||||
runtime.connection.serverUrl,
|
||||
runtime.connection.apiKey,
|
||||
Boolean(runtime.connection.serverUrl),
|
||||
);
|
||||
|
||||
// Auto-refresh models when server starts
|
||||
useEffect(() => {
|
||||
if (serverProcess.status === "running") {
|
||||
if (runtime.snapshot.status === "ready") {
|
||||
setTimeout(refreshModels, 2000);
|
||||
}
|
||||
}, [serverProcess.status, refreshModels]);
|
||||
}, [runtime.snapshot.status, refreshModels]);
|
||||
|
||||
const loadChats = useCallback(() => {
|
||||
if (!db) return;
|
||||
@@ -70,22 +68,18 @@ export default function App() {
|
||||
onSelectChat={setCurrentChatId}
|
||||
onNewChat={newChat}
|
||||
onDeleteChat={deleteChat}
|
||||
server={server}
|
||||
onServerChange={setServer}
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={setApiKey}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
status={status}
|
||||
onRefreshModels={refreshModels}
|
||||
serverProcess={serverProcess}
|
||||
runtime={runtime}
|
||||
/>
|
||||
<ChatView
|
||||
db={db!}
|
||||
chatId={currentChatId}
|
||||
server={server}
|
||||
apiKey={apiKey}
|
||||
server={runtime.connection.serverUrl}
|
||||
apiKey={runtime.connection.apiKey}
|
||||
model={selectedModel}
|
||||
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); }}
|
||||
onChatUpdated={loadChats}
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
.chat-topbar-left { display: flex; align-items: center; gap: 10px; }
|
||||
.chat-topbar-right { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.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);
|
||||
@@ -205,6 +221,10 @@
|
||||
40% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
|
||||
.live-reasoning-panel {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
.input-area {
|
||||
padding: 12px 20px 14px;
|
||||
@@ -279,3 +299,17 @@
|
||||
}
|
||||
|
||||
.char-count { font-size: 0.7rem; color: var(--text3); margin-left: auto; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.chat-topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-topbar-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { DB } from "../lib/db";
|
||||
import type { Message, Usage } from "../lib/types";
|
||||
import type { ChatTokenTotals, Message, Usage } from "../lib/types";
|
||||
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
|
||||
import { renderMarkdown, ensureHljs } from "../lib/markdown";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import "./ChatView.css";
|
||||
@@ -21,7 +22,10 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [streamContent, setStreamContent] = useState<string | null>(null);
|
||||
const [streamContent, setStreamContent] = useState("");
|
||||
const [streamReasoning, setStreamReasoning] = useState("");
|
||||
const [streamThinkingOpen, setStreamThinkingOpen] = useState(false);
|
||||
const [showLiveReasoning, setShowLiveReasoning] = useState(false);
|
||||
const [tps, setTps] = useState<number | null>(null);
|
||||
const [useStream, setUseStream] = useState(true);
|
||||
const [useMemory, setUseMemory] = useState(true);
|
||||
@@ -29,13 +33,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => {
|
||||
totals.prompt_tokens += message.prompt_tokens ?? 0;
|
||||
totals.completion_tokens += message.completion_tokens ?? 0;
|
||||
totals.total_tokens += message.total_tokens ?? 0;
|
||||
return totals;
|
||||
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
|
||||
useEffect(() => { ensureHljs(); }, []);
|
||||
|
||||
const loadMessages = useCallback(() => {
|
||||
if (!chatId) { setMessages([]); return; }
|
||||
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
|
||||
if (!targetChatId) { setMessages([]); return; }
|
||||
const rows = db.query<Message>(
|
||||
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
|
||||
[chatId]
|
||||
[targetChatId]
|
||||
);
|
||||
setMessages(rows);
|
||||
}, [db, chatId]);
|
||||
@@ -53,18 +64,21 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
||||
};
|
||||
|
||||
const getOrCreateChat = useCallback((): number => {
|
||||
const getOrCreateChat = useCallback((firstMessage?: string): number => {
|
||||
if (chatId) return chatId;
|
||||
db.run("INSERT INTO chats (title) VALUES ('New Chat')");
|
||||
db.run("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
|
||||
const row = db.get<{ id: number }>("SELECT last_insert_rowid() as id");
|
||||
const newId = row!.id;
|
||||
onChatCreated(newId);
|
||||
return newId;
|
||||
}, [chatId, db, onChatCreated]);
|
||||
|
||||
const updateChatTitle = useCallback((cid: number, firstMsg: string) => {
|
||||
const title = firstMsg.slice(0, 50) + (firstMsg.length > 50 ? "…" : "");
|
||||
db.run("UPDATE chats SET title = ?, updated_at = strftime('%s','now') WHERE id = ?", [title, cid]);
|
||||
const ensureChatTitle = useCallback((cid: number, firstMsg: string) => {
|
||||
const title = buildChatTitle(firstMsg);
|
||||
db.run(
|
||||
"UPDATE chats SET title = CASE WHEN title = 'New Chat' OR title = '' THEN ? ELSE title END, updated_at = strftime('%s','now') WHERE id = ?",
|
||||
[title, cid],
|
||||
);
|
||||
onChatUpdated();
|
||||
}, [db, onChatUpdated]);
|
||||
|
||||
@@ -79,18 +93,23 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const send = useCallback(async (text: string, fromChatId?: number) => {
|
||||
if (!text.trim() || generating) return;
|
||||
if (!model) { alert("No model selected"); return; }
|
||||
if (!server.trim()) { alert("Configure a local or remote API before sending messages"); return; }
|
||||
|
||||
setGenerating(true);
|
||||
setStreamContent(null);
|
||||
setStreamContent("");
|
||||
setStreamReasoning("");
|
||||
setStreamThinkingOpen(false);
|
||||
setShowLiveReasoning(false);
|
||||
|
||||
const cid = fromChatId ?? getOrCreateChat();
|
||||
const cid = fromChatId ?? getOrCreateChat(text);
|
||||
|
||||
// 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]);
|
||||
if (count?.c === 1) updateChatTitle(cid, text);
|
||||
if (count?.c === 1) ensureChatTitle(cid, text);
|
||||
|
||||
loadMessages();
|
||||
loadMessages(cid);
|
||||
onChatUpdated();
|
||||
|
||||
const apiMessages = buildApiMessages(cid);
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
@@ -139,7 +158,14 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
try {
|
||||
const chunk = JSON.parse(data);
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) { fullContent += delta; setStreamContent(fullContent); }
|
||||
if (delta) {
|
||||
fullContent += delta;
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
setStreamContent(parsed.content);
|
||||
setStreamReasoning(parsed.reasoning);
|
||||
setStreamThinkingOpen(parsed.thinkingOpen);
|
||||
if (parsed.reasoning) setShowLiveReasoning(true);
|
||||
}
|
||||
if (chunk.usage) usage = chunk.usage;
|
||||
} catch {}
|
||||
}
|
||||
@@ -148,10 +174,30 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const data = await res.json();
|
||||
fullContent = data.choices?.[0]?.message?.content || "";
|
||||
usage = data.usage;
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
setStreamContent(parsed.content);
|
||||
setStreamReasoning(parsed.reasoning);
|
||||
setStreamThinkingOpen(parsed.thinkingOpen);
|
||||
if (parsed.reasoning) setShowLiveReasoning(true);
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [cid, fullContent]);
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
const finalContent = parsed.content.trim() || fullContent.trim();
|
||||
db.run(
|
||||
`INSERT INTO messages (
|
||||
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
|
||||
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
cid,
|
||||
finalContent,
|
||||
parsed.content.trim() ? parsed.reasoning : null,
|
||||
usage?.prompt_tokens ?? 0,
|
||||
usage?.completion_tokens ?? 0,
|
||||
usage?.total_tokens ?? 0,
|
||||
usage?.tokens_per_second ?? null,
|
||||
],
|
||||
);
|
||||
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
|
||||
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
|
||||
onChatUpdated();
|
||||
@@ -162,14 +208,18 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
cid,
|
||||
`⚠️ Error: ${err.message}`,
|
||||
]);
|
||||
onChatUpdated();
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
setStreamContent(null);
|
||||
loadMessages();
|
||||
setStreamContent("");
|
||||
setStreamReasoning("");
|
||||
setStreamThinkingOpen(false);
|
||||
setShowLiveReasoning(false);
|
||||
loadMessages(cid);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, updateChatTitle, buildApiMessages, loadMessages, onChatUpdated]);
|
||||
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, loadMessages, onChatUpdated]);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
@@ -203,6 +253,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
{/* Top bar */}
|
||||
<div className="chat-topbar">
|
||||
<div className="chat-topbar-left">
|
||||
{chatId !== null && chatTotals.total_tokens > 0 && (
|
||||
<div className="chat-token-summary">
|
||||
<span>Sent {chatTotals.prompt_tokens}</span>
|
||||
<span>Generated {chatTotals.completion_tokens}</span>
|
||||
<span>Total {chatTotals.total_tokens}</span>
|
||||
</div>
|
||||
)}
|
||||
{tps !== null && (
|
||||
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
|
||||
)}
|
||||
@@ -241,17 +298,43 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
))}
|
||||
|
||||
{/* Live streaming bubble */}
|
||||
{generating && streamContent !== null && (
|
||||
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div
|
||||
className="msg-bubble"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
|
||||
/>
|
||||
{(streamReasoning || streamThinkingOpen) && (
|
||||
<div className="reasoning-panel live-reasoning-panel">
|
||||
<button
|
||||
className="reasoning-toggle"
|
||||
onClick={() => setShowLiveReasoning((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{showLiveReasoning ? "▼" : "▶"}</span>
|
||||
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span>
|
||||
</button>
|
||||
{showLiveReasoning && streamReasoning && (
|
||||
<div
|
||||
className="reasoning-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamContent && (
|
||||
<div
|
||||
className="msg-bubble"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!streamContent && streamThinkingOpen && (
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thinking indicator */}
|
||||
{generating && streamContent === null && (
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
|
||||
@@ -66,6 +66,58 @@
|
||||
.btn-ghost:hover { color: var(--text2); }
|
||||
|
||||
/* Message actions */
|
||||
.reasoning-panel {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reasoning-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(124, 158, 248, 0.08);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.reasoning-toggle:hover {
|
||||
background: rgba(124, 158, 248, 0.12);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.reasoning-content {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(124, 158, 248, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text2);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--text3);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.message-metrics span {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -14,8 +14,10 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const reasoningRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Wire up copy-code buttons inside rendered markdown
|
||||
useEffect(() => {
|
||||
@@ -33,6 +35,21 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!reasoningRef.current) return;
|
||||
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
|
||||
buttons.forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const code = btn.getAttribute("data-code") || "";
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
btn.textContent = "Copied!";
|
||||
btn.style.color = "var(--success)";
|
||||
setTimeout(() => { btn.textContent = "Copy"; btn.style.color = ""; }, 2000);
|
||||
});
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const autoResize = () => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
@@ -66,6 +83,13 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
});
|
||||
};
|
||||
|
||||
const hasReasoning = Boolean(message.reasoning_content?.trim());
|
||||
const hasUsage = message.role === "assistant" && (
|
||||
(message.prompt_tokens ?? 0) > 0 ||
|
||||
(message.completion_tokens ?? 0) > 0 ||
|
||||
(message.total_tokens ?? 0) > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`msg-wrapper ${message.role}`}>
|
||||
{editing ? (
|
||||
@@ -94,6 +118,26 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasReasoning && (
|
||||
<div className="reasoning-panel">
|
||||
<button
|
||||
className="reasoning-toggle"
|
||||
onClick={() => setShowReasoning((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{showReasoning ? "▼" : "▶"}</span>
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
{showReasoning && (
|
||||
<div
|
||||
ref={reasoningRef}
|
||||
className="reasoning-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
@@ -101,7 +145,16 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="msg-bubble">{message.content}</div>
|
||||
<div className="msg-bubble">{message.content}</div>
|
||||
)}
|
||||
|
||||
{hasUsage && (
|
||||
<div className="message-metrics">
|
||||
<span>Sent {message.prompt_tokens ?? 0}</span>
|
||||
<span>Generated {message.completion_tokens ?? 0}</span>
|
||||
<span>Total {message.total_tokens ?? 0}</span>
|
||||
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="msg-actions">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
background: var(--bg2);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
|
||||
var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -29,7 +31,7 @@
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
||||
background: linear-gradient(135deg, #2b88e6, #49c0ff);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -43,7 +45,7 @@
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, #e8e8ed, #9ca3af);
|
||||
background: linear-gradient(135deg, #f5fbff, #89b5d9);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -196,7 +198,7 @@
|
||||
|
||||
.new-chat-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
||||
background: linear-gradient(135deg, #2b88e6, #49c0ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -211,12 +213,12 @@
|
||||
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 2px 10px rgba(91,106,240,0.25);
|
||||
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(91,106,240,0.4);
|
||||
box-shadow: 0 4px 16px rgba(72,166,255,0.4);
|
||||
}
|
||||
|
||||
.new-chat-btn:active {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
|
||||
import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess";
|
||||
import ServerPanel from "./ServerPanel";
|
||||
import RuntimeSettingsDrawer from "../features/runtime/components/RuntimeSettingsDrawer";
|
||||
import RuntimeStatusCard from "../features/runtime/components/RuntimeStatusCard";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "../features/runtime/types";
|
||||
import "./Sidebar.css";
|
||||
|
||||
interface Props {
|
||||
@@ -10,33 +11,31 @@ interface Props {
|
||||
onSelectChat: (id: number) => void;
|
||||
onNewChat: () => void;
|
||||
onDeleteChat: (id: number) => void;
|
||||
server: string;
|
||||
onServerChange: (v: string) => void;
|
||||
apiKey: string;
|
||||
onApiKeyChange: (v: string) => void;
|
||||
models: ModelInfo[];
|
||||
selectedModel: string;
|
||||
onModelChange: (v: string) => void;
|
||||
status: ServerStatus;
|
||||
onRefreshModels: () => void;
|
||||
serverProcess: {
|
||||
status: ProcessStatus;
|
||||
message: string;
|
||||
start: (cfg: ServerConfig) => void;
|
||||
stop: () => void;
|
||||
runtime: {
|
||||
snapshot: RuntimeSnapshot;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
startLocal: () => Promise<unknown>;
|
||||
stopLocal: () => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
|
||||
server, onServerChange, apiKey, onApiKeyChange,
|
||||
models, selectedModel, onModelChange, status, onRefreshModels,
|
||||
serverProcess,
|
||||
runtime,
|
||||
}: Props) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showRuntimeSettings, setShowRuntimeSettings] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<>
|
||||
<aside className="sidebar">
|
||||
{/* ── Header ── */}
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-brand">
|
||||
@@ -45,42 +44,20 @@ export default function Sidebar({
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
title="Connection settings"
|
||||
onClick={() => setShowRuntimeSettings(true)}
|
||||
title="Runtime settings"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Connection settings ── */}
|
||||
{showSettings && (
|
||||
<div className="settings-panel">
|
||||
<label className="settings-label">Server URL</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={server}
|
||||
onChange={(e) => onServerChange(e.target.value)}
|
||||
onBlur={onRefreshModels}
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
/>
|
||||
<label className="settings-label">API Key (optional)</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Local server control ── */}
|
||||
<ServerPanel
|
||||
status={serverProcess.status}
|
||||
message={serverProcess.message}
|
||||
onStart={serverProcess.start}
|
||||
onStop={serverProcess.stop}
|
||||
<RuntimeStatusCard
|
||||
snapshot={runtime.snapshot}
|
||||
loading={runtime.loading}
|
||||
busy={runtime.busy}
|
||||
onOpenSettings={() => setShowRuntimeSettings(true)}
|
||||
onStart={() => { void runtime.startLocal(); }}
|
||||
onStop={() => { void runtime.stopLocal(); }}
|
||||
/>
|
||||
|
||||
{/* ── Active model selector ── */}
|
||||
@@ -144,6 +121,18 @@ export default function Sidebar({
|
||||
<div className="sidebar-footer">
|
||||
<span className="footer-version">ForgeLM v0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</aside>
|
||||
|
||||
<RuntimeSettingsDrawer
|
||||
open={showRuntimeSettings}
|
||||
snapshot={runtime.snapshot}
|
||||
busy={runtime.busy}
|
||||
onClose={() => setShowRuntimeSettings(false)}
|
||||
onSave={runtime.saveConfig}
|
||||
onStart={() => runtime.startLocal()}
|
||||
onStop={() => runtime.stopLocal()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
|
||||
import type {
|
||||
RuntimeConfig,
|
||||
RuntimeGpuInfo,
|
||||
RuntimeModelFile,
|
||||
RuntimePickDirResult,
|
||||
RuntimeSnapshot,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function readFallbackSnapshot(): RuntimeSnapshot {
|
||||
if (!canUseLocalStorage()) {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot;
|
||||
} catch {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
|
||||
if (canUseLocalStorage()) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function bootstrapRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("bootstrap_runtime_command");
|
||||
} catch {
|
||||
return readFallbackSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeSnapshot(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("get_runtime_snapshot_command");
|
||||
} catch {
|
||||
return readFallbackSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRuntimeConfig(config: RuntimeConfig): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("save_runtime_config_command_handler", { config });
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({ ...snapshot, config });
|
||||
}
|
||||
}
|
||||
|
||||
export async function startRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("start_runtime_command");
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({
|
||||
...snapshot,
|
||||
status: "error",
|
||||
last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("stop_runtime_command");
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({ ...snapshot, status: "stopped", process_running: false, ready: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryRuntimeGpu(): Promise<RuntimeGpuInfo | null> {
|
||||
try {
|
||||
return await invoke<RuntimeGpuInfo>("query_gpu");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanRuntimeModels(dir: string): Promise<RuntimeModelFile[]> {
|
||||
return invoke<RuntimeModelFile[]>("scan_models", { dir });
|
||||
}
|
||||
|
||||
export async function pickRuntimeModelsDir(): Promise<RuntimePickDirResult | null> {
|
||||
try {
|
||||
return await invoke<RuntimePickDirResult>("pick_models_dir");
|
||||
} catch (error) {
|
||||
if (String(error) === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickRuntimeModelFiles(): Promise<RuntimeModelFile[]> {
|
||||
try {
|
||||
return await invoke<RuntimeModelFile[]>("pick_model_files");
|
||||
} catch (error) {
|
||||
if (String(error) === "cancelled") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
.runtime-drawer-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.runtime-drawer-backdrop {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: rgba(5, 6, 10, 0.58);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.runtime-drawer {
|
||||
width: min(560px, 100%);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, #10161f, #0b1017 72%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.runtime-drawer-header,
|
||||
.runtime-drawer-footer {
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.runtime-drawer-footer {
|
||||
border-bottom: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-drawer-kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #78bdf4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.runtime-drawer-header h2 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.runtime-drawer-copy {
|
||||
margin-top: 10px;
|
||||
max-width: 34ch;
|
||||
color: var(--text2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.runtime-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-drawer-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px 28px;
|
||||
}
|
||||
|
||||
.runtime-drawer-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.runtime-section-card,
|
||||
.runtime-overview-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.runtime-section-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.runtime-overview-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.runtime-overview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-overview-value {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.runtime-overview-mono,
|
||||
.runtime-static-value-mono {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-drawer-section-title,
|
||||
.runtime-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-segmented {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn,
|
||||
.runtime-footer-btn {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn {
|
||||
flex: 1;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-field-input,
|
||||
.runtime-static-value {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
padding: 11px 13px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.runtime-static-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runtime-field-select {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.runtime-static-value-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.runtime-grid-two {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-field-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.runtime-field-row-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.runtime-field-support {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-inline-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-inline-btn,
|
||||
.runtime-preset,
|
||||
.runtime-chip {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-inline-btn {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-inline-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-chip span {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.runtime-preset-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-preset {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-preset.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-checkbox-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-error-banner {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(240, 64, 64, 0.18);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb7b7;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-warning-banner {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(243, 211, 139, 0.18);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
color: #f3d38b;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-footer-btn {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.runtime-footer-btn:active:not(:disabled),
|
||||
.runtime-segmented-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.runtime-footer-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-footer-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-footer-btn-primary {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: linear-gradient(135deg, #2c8ce8, #49c0ff);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.runtime-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.runtime-overview-card,
|
||||
.runtime-grid-two,
|
||||
.runtime-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.runtime-drawer-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
pickRuntimeModelFiles,
|
||||
pickRuntimeModelsDir,
|
||||
queryRuntimeGpu,
|
||||
scanRuntimeModels,
|
||||
} from "../api";
|
||||
import type {
|
||||
RuntimeConfig,
|
||||
RuntimeGpuInfo,
|
||||
RuntimeModelFile,
|
||||
RuntimeSnapshot,
|
||||
} from "../types";
|
||||
import "./RuntimeSettingsDrawer.css";
|
||||
|
||||
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
|
||||
|
||||
function pathName(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
function toModelFile(path: string): RuntimeModelFile {
|
||||
return { name: pathName(path) || path, path };
|
||||
}
|
||||
|
||||
function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] {
|
||||
const seen = new Set<string>();
|
||||
return models.filter((model) => {
|
||||
if (seen.has(model.path)) return false;
|
||||
seen.add(model.path);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string {
|
||||
const nextChoices = [...scanned, ...extra].map((model) => model.path);
|
||||
if (current && (nextChoices.length === 0 || nextChoices.includes(current))) {
|
||||
return current;
|
||||
}
|
||||
return scanned[0]?.path || extra[0]?.path || "";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
snapshot: RuntimeSnapshot;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
onStart: () => Promise<unknown>;
|
||||
onStop: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function RuntimeSettingsDrawer({
|
||||
open,
|
||||
snapshot,
|
||||
busy,
|
||||
onClose,
|
||||
onSave,
|
||||
onStart,
|
||||
onStop,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState(snapshot.config.connection_mode);
|
||||
const [startPolicy, setStartPolicy] = useState(snapshot.config.start_policy);
|
||||
const [serverUrl, setServerUrl] = useState(snapshot.config.server_url);
|
||||
const [apiKey, setApiKey] = useState(snapshot.config.api_key);
|
||||
const [modelsDir, setModelsDir] = useState(snapshot.config.models_dir || "");
|
||||
const [selectedModel, setSelectedModel] = useState(snapshot.config.model_path || "");
|
||||
const [addr, setAddr] = useState(snapshot.config.addr);
|
||||
const [gpuLayers, setGpuLayers] = useState(snapshot.config.gpu_layers);
|
||||
const [ctxSize, setCtxSize] = useState(snapshot.config.ctx_size);
|
||||
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
|
||||
const [extraModels, setExtraModels] = useState<RuntimeModelFile[]>([]);
|
||||
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
|
||||
const [gpuError, setGpuError] = useState("");
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const extra = snapshot.config.extra_model_paths.map(toModelFile);
|
||||
|
||||
setMode(snapshot.config.connection_mode);
|
||||
setStartPolicy(snapshot.config.start_policy);
|
||||
setServerUrl(snapshot.config.server_url);
|
||||
setApiKey(snapshot.config.api_key);
|
||||
setModelsDir(snapshot.config.models_dir || "");
|
||||
setSelectedModel(snapshot.config.model_path || "");
|
||||
setAddr(snapshot.config.addr);
|
||||
setGpuLayers(snapshot.config.gpu_layers);
|
||||
setCtxSize(snapshot.config.ctx_size);
|
||||
setExtraModels(extra);
|
||||
setScannedModels([]);
|
||||
setLocalError("");
|
||||
setGpuError("");
|
||||
|
||||
if (snapshot.config.models_dir?.trim()) {
|
||||
setScanning(true);
|
||||
scanRuntimeModels(snapshot.config.models_dir)
|
||||
.then((models) => {
|
||||
setScannedModels(models);
|
||||
setSelectedModel((current) => chooseModel(current || snapshot.config.model_path || "", models, extra));
|
||||
})
|
||||
.catch((error) => setLocalError(String(error)))
|
||||
.finally(() => setScanning(false));
|
||||
}
|
||||
|
||||
queryRuntimeGpu()
|
||||
.then((info) => {
|
||||
setGpuInfo(info);
|
||||
if (info && snapshot.config.gpu_layers === 0) {
|
||||
setGpuLayers(info.recommended_layers);
|
||||
}
|
||||
})
|
||||
.catch((error) => setGpuError(String(error)));
|
||||
}, [open, snapshot]);
|
||||
|
||||
const allModels = useMemo(() => {
|
||||
const merged = dedupeModels([...scannedModels, ...extraModels]);
|
||||
if (selectedModel && !merged.some((model) => model.path === selectedModel)) {
|
||||
return [toModelFile(selectedModel), ...merged];
|
||||
}
|
||||
return merged;
|
||||
}, [extraModels, scannedModels, selectedModel]);
|
||||
|
||||
const savedLocalModel = snapshot.config.model_path?.trim() || "";
|
||||
const hasPendingModeChange = mode !== snapshot.config.connection_mode;
|
||||
const maxLayers = gpuInfo?.recommended_layers ?? 200;
|
||||
const startReason =
|
||||
mode !== "local"
|
||||
? "Save mode before starting the local runtime."
|
||||
: !snapshot.binary_path
|
||||
? "Build or bundle the local API binary before starting."
|
||||
: !selectedModel.trim()
|
||||
? "Choose a model before starting the local runtime."
|
||||
: "";
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const buildPatch = (): Partial<RuntimeConfig> => ({
|
||||
connection_mode: mode,
|
||||
start_policy: startPolicy,
|
||||
server_url: serverUrl.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
models_dir: mode === "local" ? (modelsDir.trim() || null) : snapshot.config.models_dir,
|
||||
model_path: mode === "local" ? (selectedModel.trim() || null) : snapshot.config.model_path,
|
||||
addr: mode === "local" ? addr.trim() || snapshot.config.addr : snapshot.config.addr,
|
||||
gpu_layers: mode === "local" ? Math.max(0, Math.min(gpuLayers, maxLayers)) : snapshot.config.gpu_layers,
|
||||
ctx_size: mode === "local" ? Math.max(512, ctxSize || 4096) : snapshot.config.ctx_size,
|
||||
extra_model_paths: mode === "local" ? extraModels.map((model) => model.path) : snapshot.config.extra_model_paths,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await onSave(buildPatch());
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndStart = async () => {
|
||||
setLocalError("");
|
||||
|
||||
if (snapshot.process_running) {
|
||||
try {
|
||||
await onStop();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await onSave(buildPatch());
|
||||
if (next.config.connection_mode !== "local") return;
|
||||
if (!next.config.model_path?.trim()) return;
|
||||
if (next.last_error?.code === "restart_required") return;
|
||||
await onStart();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = async (dir = modelsDir) => {
|
||||
if (!dir.trim()) return;
|
||||
setScanning(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
const models = await scanRuntimeModels(dir.trim());
|
||||
setScannedModels(models);
|
||||
setSelectedModel((current) => chooseModel(current, models, extraModels));
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
setScannedModels([]);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickDir = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const result = await pickRuntimeModelsDir();
|
||||
if (!result) return;
|
||||
setModelsDir(result.dir);
|
||||
setScannedModels(result.models);
|
||||
setSelectedModel((current) => chooseModel(current, result.models, extraModels));
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickFiles = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const picked = await pickRuntimeModelFiles();
|
||||
if (picked.length === 0) return;
|
||||
setExtraModels((current) => {
|
||||
const next = dedupeModels([...current, ...picked]);
|
||||
if (!selectedModel) {
|
||||
setSelectedModel(next[0]?.path || "");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveExtra = (path: string) => {
|
||||
setExtraModels((current) => {
|
||||
const next = current.filter((model) => model.path !== path);
|
||||
setSelectedModel((selected) => (selected === path ? chooseModel("", scannedModels, next) : selected));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Runtime settings">
|
||||
<button className="runtime-drawer-backdrop" onClick={onClose} aria-label="Close runtime settings" />
|
||||
<aside className="runtime-drawer">
|
||||
<div className="runtime-drawer-header">
|
||||
<div>
|
||||
<div className="runtime-drawer-kicker">Runtime workspace</div>
|
||||
<h2>One control plane for startup, models, and API routing</h2>
|
||||
<p className="runtime-drawer-copy">
|
||||
Save the config once, then start the managed API from the same surface.
|
||||
</p>
|
||||
</div>
|
||||
<button className="runtime-drawer-close" onClick={onClose} type="button" aria-label="Close runtime settings">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="runtime-drawer-body">
|
||||
<section className="runtime-overview-card">
|
||||
<div>
|
||||
<div className="runtime-overview-label">Current status</div>
|
||||
<div className="runtime-overview-value">{snapshot.status === "ready" ? "Local API ready" : snapshot.config.connection_mode === "remote" ? "Remote API mode" : "Local API setup"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-overview-label">Endpoint</div>
|
||||
<div className="runtime-overview-value runtime-overview-mono">{mode === "remote" ? (serverUrl.trim() || "Not configured") : `http://${addr.trim() || snapshot.config.addr}`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-overview-label">Saved model</div>
|
||||
<div className="runtime-overview-value">{savedLocalModel ? pathName(savedLocalModel) : "No model saved"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-drawer-section-title">Connection mode</div>
|
||||
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
|
||||
<button className={`runtime-segmented-btn${mode === "local" ? " active" : ""}`} onClick={() => setMode("local")} type="button">
|
||||
Local API
|
||||
</button>
|
||||
<button className={`runtime-segmented-btn${mode === "remote" ? " active" : ""}`} onClick={() => setMode("remote")} type="button">
|
||||
Remote API
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{mode === "remote" ? (
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
|
||||
<input
|
||||
id="runtime-remote-url"
|
||||
className="runtime-field-input"
|
||||
value={serverUrl}
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
|
||||
<input
|
||||
id="runtime-api-key"
|
||||
className="runtime-field-input"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="sk-..."
|
||||
type="password"
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||||
<div>
|
||||
<div className="runtime-field-label">Managed endpoint</div>
|
||||
<div className="runtime-static-value runtime-static-value-mono">http://{addr.trim() || snapshot.config.addr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-field-label">Binary discovery</div>
|
||||
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row">
|
||||
<div>
|
||||
<div className="runtime-field-label">Models folder</div>
|
||||
<div className="runtime-static-value runtime-static-value-path">{modelsDir || "Choose a folder or add individual files"}</div>
|
||||
</div>
|
||||
<div className="runtime-inline-actions">
|
||||
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
|
||||
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!modelsDir.trim() || scanning}>
|
||||
{scanning ? "Scanning..." : "Rescan"}
|
||||
</button>
|
||||
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
|
||||
<select
|
||||
id="runtime-model-select"
|
||||
className="runtime-field-input runtime-field-select"
|
||||
value={selectedModel}
|
||||
onChange={(event) => setSelectedModel(event.target.value)}
|
||||
>
|
||||
<option value="">Choose a model</option>
|
||||
{allModels.map((model) => (
|
||||
<option key={model.path} value={model.path}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{extraModels.length > 0 && (
|
||||
<div className="runtime-chip-list">
|
||||
{extraModels.map((model) => (
|
||||
<button
|
||||
key={model.path}
|
||||
className="runtime-chip"
|
||||
onClick={() => handleRemoveExtra(model.path)}
|
||||
type="button"
|
||||
title={`Remove ${model.name}`}
|
||||
>
|
||||
{model.name}
|
||||
<span>×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||||
<div>
|
||||
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
|
||||
<input
|
||||
id="runtime-addr"
|
||||
className="runtime-field-input"
|
||||
value={addr}
|
||||
onChange={(event) => setAddr(event.target.value)}
|
||||
placeholder="127.0.0.1:8080"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
|
||||
<input
|
||||
id="runtime-context"
|
||||
className="runtime-field-input"
|
||||
min={512}
|
||||
step={512}
|
||||
value={ctxSize}
|
||||
onChange={(event) => setCtxSize(Math.max(512, parseInt(event.target.value, 10) || 4096))}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row runtime-field-row-top">
|
||||
<div>
|
||||
<div className="runtime-field-label">GPU layers</div>
|
||||
<div className="runtime-field-support">
|
||||
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
|
||||
</div>
|
||||
</div>
|
||||
{gpuInfo && (
|
||||
<button className="runtime-inline-btn" onClick={() => setGpuLayers(gpuInfo.recommended_layers)} type="button">
|
||||
Use recommended
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="runtime-field-input"
|
||||
min={0}
|
||||
max={maxLayers}
|
||||
value={gpuLayers}
|
||||
onChange={(event) => setGpuLayers(Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)))}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<div className="runtime-preset-row">
|
||||
{CTX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
className={`runtime-preset${ctxSize === preset ? " active" : ""}`}
|
||||
onClick={() => setCtxSize(preset)}
|
||||
type="button"
|
||||
>
|
||||
{preset >= 1024 ? `${preset / 1024}k` : preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<label className="runtime-checkbox-row">
|
||||
<input
|
||||
checked={startPolicy === "on_app_launch"}
|
||||
onChange={(event) => setStartPolicy(event.target.checked ? "on_app_launch" : "manual")}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Start the local API automatically when the desktop app opens</span>
|
||||
</label>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(localError || snapshot.last_error) && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-error-banner">{localError || snapshot.last_error?.message}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!snapshot.process_running && mode === "local" && startReason && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-warning-banner">{startReason}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="runtime-drawer-footer">
|
||||
<button className="runtime-footer-btn runtime-footer-btn-secondary" onClick={() => { void handleSave(); }} type="button" disabled={busy}>
|
||||
{busy ? "Saving..." : "Save settings"}
|
||||
</button>
|
||||
|
||||
{mode === "local" && (
|
||||
<button
|
||||
className="runtime-footer-btn runtime-footer-btn-primary"
|
||||
onClick={() => { void handleSaveAndStart(); }}
|
||||
type="button"
|
||||
disabled={busy || hasPendingModeChange || (!snapshot.process_running && Boolean(startReason))}
|
||||
>
|
||||
{hasPendingModeChange
|
||||
? "Save mode before applying"
|
||||
: snapshot.process_running
|
||||
? "Stop local API"
|
||||
: "Save and start local API"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
.runtime-card {
|
||||
margin: 10px 12px 8px;
|
||||
padding: 15px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01));
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.runtime-card-success {
|
||||
border-color: rgba(48, 209, 88, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-warning {
|
||||
border-color: rgba(255, 214, 10, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-danger {
|
||||
border-color: rgba(240, 64, 64, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-card-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.runtime-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.runtime-card-mode {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border2);
|
||||
color: var(--text2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.runtime-card-mode-local {
|
||||
color: #71c5ff;
|
||||
border-color: rgba(113, 197, 255, 0.28);
|
||||
}
|
||||
|
||||
.runtime-card-mode-remote {
|
||||
color: #92e5c7;
|
||||
border-color: rgba(146, 229, 199, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-copy {
|
||||
margin-top: 10px;
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-card-meta span,
|
||||
.runtime-card-note {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.runtime-card-note {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
border-radius: 12px;
|
||||
color: #f3d38b;
|
||||
border-color: rgba(243, 211, 139, 0.16);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
}
|
||||
|
||||
.runtime-card-error {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(240, 64, 64, 0.2);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb4b4;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.runtime-card-btn {
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border2);
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-card-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.runtime-card-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-card-btn-secondary {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.runtime-card-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.6);
|
||||
background: linear-gradient(135deg, #2b88e6, #46c2ff);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3193f5, #55cbff);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
|
||||
import type { RuntimeSnapshot } from "../types";
|
||||
import "./RuntimeStatusCard.css";
|
||||
|
||||
function summarizePath(path: string | null): string {
|
||||
if (!path) return "No model selected";
|
||||
const segments = path.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
snapshot: RuntimeSnapshot;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
onOpenSettings: () => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export default function RuntimeStatusCard({ snapshot, loading, busy, onOpenSettings, onStart, onStop }: Props) {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
const isLocal = snapshot.config.connection_mode === "local";
|
||||
const startGuard = getRuntimeStartGuard(snapshot);
|
||||
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
|
||||
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
|
||||
const primaryLabel = needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop";
|
||||
const primaryDisabled = busy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
|
||||
|
||||
return (
|
||||
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
|
||||
<div className="runtime-card-header">
|
||||
<div>
|
||||
<div className="runtime-card-kicker">Runtime Manager</div>
|
||||
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
|
||||
</div>
|
||||
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
|
||||
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="runtime-card-copy">
|
||||
{snapshot.config.connection_mode === "local"
|
||||
? `Managed endpoint ${snapshot.resolved_url}`
|
||||
: snapshot.config.server_url || "Configure a remote API endpoint"}
|
||||
</p>
|
||||
|
||||
<div className="runtime-card-meta" aria-label="Runtime details">
|
||||
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
|
||||
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
|
||||
</div>
|
||||
|
||||
{needsSetup && <div className="runtime-card-note">{startGuard.reason}</div>}
|
||||
|
||||
{snapshot.last_error && (
|
||||
<div className="runtime-card-error" role="status">
|
||||
{snapshot.last_error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="runtime-card-actions">
|
||||
<button className="runtime-card-btn runtime-card-btn-secondary" onClick={onOpenSettings} type="button">
|
||||
Manage
|
||||
</button>
|
||||
{snapshot.config.connection_mode === "local" && (
|
||||
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
|
||||
{busy ? "Working..." : primaryLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bootstrapRuntime,
|
||||
getRuntimeSnapshot,
|
||||
pickRuntimeModelsDir,
|
||||
saveRuntimeConfig,
|
||||
startRuntime,
|
||||
stopRuntime,
|
||||
} from "../api";
|
||||
import RuntimeSettingsDrawer from "./RuntimeSettingsDrawer";
|
||||
import RuntimeStatusCard from "./RuntimeStatusCard";
|
||||
import { useRuntimeManager } from "../useRuntimeManager";
|
||||
import { createDefaultRuntimeSnapshot } from "../runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "../types";
|
||||
|
||||
type SnapshotOverrides = Partial<Omit<RuntimeSnapshot, "config">> & {
|
||||
config?: Partial<RuntimeSnapshot["config"]>;
|
||||
};
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
bootstrapRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
|
||||
getRuntimeSnapshot: vi.fn(async () => createDefaultRuntimeSnapshot()),
|
||||
pickRuntimeModelFiles: vi.fn(async () => []),
|
||||
pickRuntimeModelsDir: vi.fn(async () => null),
|
||||
queryRuntimeGpu: vi.fn(async () => null),
|
||||
saveRuntimeConfig: vi.fn(async (config) => ({ ...createDefaultRuntimeSnapshot(), config })),
|
||||
scanRuntimeModels: vi.fn(async () => []),
|
||||
startRuntime: vi.fn(async () => ({ ...createDefaultRuntimeSnapshot(), status: "ready", ready: true, process_running: true })),
|
||||
stopRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
|
||||
}));
|
||||
|
||||
function makeSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
|
||||
return {
|
||||
...createDefaultRuntimeSnapshot(),
|
||||
...overrides,
|
||||
config: {
|
||||
...createDefaultRuntimeSnapshot().config,
|
||||
...overrides.config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function RuntimeManagerHarness({ startOpen = false }: { startOpen?: boolean }) {
|
||||
const runtime = useRuntimeManager();
|
||||
const [drawerOpen, setDrawerOpen] = useState(startOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RuntimeStatusCard
|
||||
snapshot={runtime.snapshot}
|
||||
loading={runtime.loading}
|
||||
busy={runtime.busy}
|
||||
onOpenSettings={() => setDrawerOpen(true)}
|
||||
onStart={() => {
|
||||
void runtime.startLocal();
|
||||
}}
|
||||
onStop={() => {
|
||||
void runtime.stopLocal();
|
||||
}}
|
||||
/>
|
||||
<RuntimeSettingsDrawer
|
||||
open={drawerOpen}
|
||||
snapshot={runtime.snapshot}
|
||||
busy={runtime.busy}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSave={runtime.saveConfig}
|
||||
onStart={runtime.startLocal}
|
||||
onStop={runtime.stopLocal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Runtime app surface", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("routes incomplete local runtime setup to the settings drawer instead of trying to start", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
const onStart = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeStatusCard
|
||||
snapshot={makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: { model_path: null, connection_mode: "local" },
|
||||
})}
|
||||
loading={false}
|
||||
busy={false}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onStart={onStart}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Set up"));
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
expect(onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves local runtime settings before starting from the unified drawer", async () => {
|
||||
const onSave = vi.fn(async (patch) => makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
...patch,
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
}));
|
||||
const onStart = vi.fn(async () => makeSnapshot({ status: "starting" }));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeSettingsDrawer
|
||||
open
|
||||
snapshot={makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
},
|
||||
})}
|
||||
busy={false}
|
||||
onClose={vi.fn()}
|
||||
onSave={onSave}
|
||||
onStart={onStart}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
|
||||
expect(action).toBeTruthy();
|
||||
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
action?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave.mock.calls[0]?.[0]).toMatchObject({
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
});
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops the local runtime from the same drawer action when the process is already running", async () => {
|
||||
const onSave = vi.fn();
|
||||
const onStop = vi.fn(async () => makeSnapshot({ status: "stopping", process_running: false }));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeSettingsDrawer
|
||||
open
|
||||
snapshot={makeSnapshot({
|
||||
status: "ready",
|
||||
process_running: true,
|
||||
ready: true,
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
})}
|
||||
busy={false}
|
||||
onClose={vi.fn()}
|
||||
onSave={onSave}
|
||||
onStart={vi.fn()}
|
||||
onStop={onStop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Stop local API"));
|
||||
expect(action).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
action?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the local primary CTA visible while switching from remote to local mode", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeSettingsDrawer
|
||||
open
|
||||
snapshot={makeSnapshot({
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
},
|
||||
})}
|
||||
busy={false}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn(async () => makeSnapshot())}
|
||||
onStart={vi.fn(async () => makeSnapshot({ status: "starting" }))}
|
||||
onStop={vi.fn(async () => makeSnapshot({ status: "stopped" }))}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const localTab = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Local API"));
|
||||
expect(localTab).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
localTab?.click();
|
||||
});
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save mode before applying"));
|
||||
expect(action).toBeTruthy();
|
||||
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("covers the saved local startup lifecycle from stopped to starting to ready", async () => {
|
||||
const initialSnapshot = makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
start_policy: "on_app_launch",
|
||||
},
|
||||
});
|
||||
const startDeferred = createDeferred<RuntimeSnapshot>();
|
||||
|
||||
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(startRuntime).mockReturnValue(startDeferred.promise);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RuntimeManagerHarness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Local API offline");
|
||||
expect(container.textContent).toContain("demo.gguf");
|
||||
|
||||
const startButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Start"));
|
||||
expect(startButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
startButton?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Starting local API");
|
||||
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
startDeferred.resolve(makeSnapshot({
|
||||
...initialSnapshot,
|
||||
status: "ready",
|
||||
process_running: true,
|
||||
ready: true,
|
||||
}));
|
||||
await startDeferred.promise;
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Ready");
|
||||
expect(container.textContent).toContain("Stop");
|
||||
});
|
||||
|
||||
it("supports local onboarding from browsing a models folder through save and start", async () => {
|
||||
const initialSnapshot = makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(pickRuntimeModelsDir).mockResolvedValue({
|
||||
dir: "D:/models",
|
||||
models: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
});
|
||||
|
||||
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(saveRuntimeConfig).mockImplementation(async (config: RuntimeConfig) => makeSnapshot({
|
||||
...initialSnapshot,
|
||||
config,
|
||||
}));
|
||||
vi.mocked(startRuntime).mockResolvedValue(makeSnapshot({
|
||||
...initialSnapshot,
|
||||
status: "ready",
|
||||
process_running: true,
|
||||
ready: true,
|
||||
config: {
|
||||
...initialSnapshot.config,
|
||||
models_dir: "D:/models",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RuntimeManagerHarness startOpen />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const browseButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Browse"));
|
||||
expect(browseButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
browseButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const select = container.querySelector("#runtime-model-select") as HTMLSelectElement | null;
|
||||
expect(select?.value).toBe("D:/models/demo.gguf");
|
||||
expect(container.textContent).toContain("D:/models");
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
|
||||
expect(action).toBeTruthy();
|
||||
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
action?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveRuntimeConfig)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(saveRuntimeConfig).mock.calls[0]?.[0]).toMatchObject({
|
||||
connection_mode: "local",
|
||||
models_dir: "D:/models",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
});
|
||||
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Local API ready");
|
||||
expect(container.textContent).toContain("Stop local API");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
|
||||
import type { RuntimeSnapshot } from "./types";
|
||||
|
||||
function createSnapshot(overrides: Partial<RuntimeSnapshot> = {}): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: "http://127.0.0.1:8080",
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getRuntimeConnection", () => {
|
||||
it("prefers the resolved local runtime url in local mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
resolved_url: "http://127.0.0.1:9000",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "http://example.com:9999",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:9000",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(connection.serverUrl).toBe("http://127.0.0.1:9000");
|
||||
expect(connection.mode).toBe("local");
|
||||
});
|
||||
|
||||
it("uses remote settings in remote mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(connection.serverUrl).toBe("https://api.example.com");
|
||||
expect(connection.apiKey).toBe("sk-demo");
|
||||
expect(connection.mode).toBe("remote");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStateLabel", () => {
|
||||
it("reports ready snapshots as ready", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
|
||||
});
|
||||
|
||||
it("reports local background start progress", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
|
||||
});
|
||||
|
||||
it("reports remote mode without runtime ownership", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({
|
||||
status: "stopped",
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}))).toBe("Remote API");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStartGuard", () => {
|
||||
it("requires a saved model before starting the local runtime", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(false);
|
||||
expect(guard.reason.toLowerCase()).toContain("model");
|
||||
});
|
||||
|
||||
it("allows starting when the saved local runtime config is complete", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 24,
|
||||
ctx_size: 8192,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(true);
|
||||
expect(guard.reason).toBe("");
|
||||
});
|
||||
|
||||
it("never offers managed start controls in remote mode", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(false);
|
||||
expect(guard.reason.toLowerCase()).toContain("remote");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { RuntimeConnection, RuntimeSnapshot } from "./types";
|
||||
|
||||
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
|
||||
|
||||
function normalizeUrl(value: string | null | undefined): string {
|
||||
const trimmed = (value || "").trim();
|
||||
return trimmed ? trimmed.replace(/\/+$/, "") : "";
|
||||
}
|
||||
|
||||
export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return {
|
||||
mode: "remote",
|
||||
serverUrl: normalizeUrl(snapshot.config.server_url),
|
||||
apiKey: snapshot.config.api_key || "",
|
||||
managed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "local",
|
||||
serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL,
|
||||
apiKey: snapshot.config.api_key || "",
|
||||
managed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "Remote API";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Ready";
|
||||
case "starting":
|
||||
return "Starting local API";
|
||||
case "stopping":
|
||||
return "Stopping local API";
|
||||
case "error":
|
||||
return "Local API error";
|
||||
case "degraded":
|
||||
return "Attention needed";
|
||||
default:
|
||||
return "Local API offline";
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
case "starting":
|
||||
case "stopping":
|
||||
case "degraded":
|
||||
return "warning";
|
||||
case "error":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuntimeStartGuard {
|
||||
canStart: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Remote mode uses an external API and does not launch the local runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot.binary_path) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Build or bundle the local API binary before starting the managed runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot.config.model_path?.trim()) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Choose a local model before starting the managed runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
return { canStart: true, reason: "" };
|
||||
}
|
||||
|
||||
export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: DEFAULT_LOCAL_URL,
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type ConnectionMode = "local" | "remote";
|
||||
export type StartPolicy = "manual" | "on_app_launch";
|
||||
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
|
||||
|
||||
export interface RuntimeConfig {
|
||||
connection_mode: ConnectionMode;
|
||||
start_policy: StartPolicy;
|
||||
server_url: string;
|
||||
api_key: string;
|
||||
model_path: string | null;
|
||||
models_dir: string | null;
|
||||
addr: string;
|
||||
gpu_layers: number;
|
||||
ctx_size: number;
|
||||
extra_model_paths: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeErrorInfo {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSnapshot {
|
||||
status: RuntimeStatus;
|
||||
ready: boolean;
|
||||
process_running: boolean;
|
||||
resolved_url: string;
|
||||
binary_path: string | null;
|
||||
last_error: RuntimeErrorInfo | null;
|
||||
config: RuntimeConfig;
|
||||
}
|
||||
|
||||
export interface RuntimeConnection {
|
||||
mode: ConnectionMode;
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeModelFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RuntimePickDirResult {
|
||||
dir: string;
|
||||
models: RuntimeModelFile[];
|
||||
}
|
||||
|
||||
export interface RuntimeGpuInfo {
|
||||
name: string;
|
||||
vram_mib: number;
|
||||
recommended_layers: number;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, startRuntime, stopRuntime } from "./api";
|
||||
import { createDefaultRuntimeSnapshot, getRuntimeConnection } from "./runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
|
||||
|
||||
export function useRuntimeManager() {
|
||||
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(createDefaultRuntimeSnapshot());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const next = await getRuntimeSnapshot();
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
bootstrapRuntime()
|
||||
.then((next) => {
|
||||
if (active) {
|
||||
setSnapshot(next);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
refresh().catch(() => undefined);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await saveRuntimeConfig({ ...snapshot.config, ...patch });
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [snapshot.config]);
|
||||
|
||||
const startLocal = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
status: "starting",
|
||||
process_running: true,
|
||||
ready: false,
|
||||
last_error: null,
|
||||
}));
|
||||
try {
|
||||
const next = await startRuntime();
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopLocal = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
status: "stopping",
|
||||
ready: false,
|
||||
last_error: null,
|
||||
}));
|
||||
try {
|
||||
const next = await stopRuntime();
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connection = useMemo(() => getRuntimeConnection(snapshot), [snapshot]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
connection,
|
||||
loading,
|
||||
busy,
|
||||
refresh,
|
||||
saveConfig,
|
||||
startLocal,
|
||||
stopLocal,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ModelInfo, ServerStatus } from "../lib/types";
|
||||
|
||||
export function useModels(server: string, apiKey: string) {
|
||||
export function useModels(server: string, apiKey: string, enabled = true) {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [status, setStatus] = useState<ServerStatus>("loading");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !server.trim()) {
|
||||
setModels([]);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${server}/v1/models`, { headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const list: ModelInfo[] = data.data || [];
|
||||
setModels(list);
|
||||
if (list.length > 0 && !selectedModel) setSelectedModel(list[0].id);
|
||||
if (list.length === 0) {
|
||||
setSelectedModel("");
|
||||
} else if (!selectedModel || !list.some((item) => item.id === selectedModel)) {
|
||||
setSelectedModel(list[0].id);
|
||||
}
|
||||
setStatus("ok");
|
||||
} catch {
|
||||
setModels([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [server, apiKey, selectedModel]);
|
||||
}, [enabled, server, apiKey, selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
+18
-18
@@ -2,23 +2,23 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0c0c0e;
|
||||
--bg2: #141416;
|
||||
--bg3: #1c1c1f;
|
||||
--bg4: #242428;
|
||||
--bg5: #2c2c31;
|
||||
--border: #2a2a30;
|
||||
--border2: #38383f;
|
||||
--text: #e8e8ed;
|
||||
--text2: #8e8e99;
|
||||
--text3: #5a5a66;
|
||||
--accent: #5b6af0;
|
||||
--accent2: #4a58e0;
|
||||
--accent-glow: rgba(91,106,240,0.15);
|
||||
--user-bg: #1a2040;
|
||||
--user-border: #2a3560;
|
||||
--asst-bg: #141416;
|
||||
--asst-border: #2a2a30;
|
||||
--bg: #0a1016;
|
||||
--bg2: #0f1721;
|
||||
--bg3: #14202d;
|
||||
--bg4: #182636;
|
||||
--bg5: #203246;
|
||||
--border: #243241;
|
||||
--border2: #344759;
|
||||
--text: #edf5ff;
|
||||
--text2: #93a8bc;
|
||||
--text3: #5d7288;
|
||||
--accent: #48a6ff;
|
||||
--accent2: #2f8ae3;
|
||||
--accent-glow: rgba(72,166,255,0.16);
|
||||
--user-bg: #10263f;
|
||||
--user-border: #214468;
|
||||
--asst-bg: #101924;
|
||||
--asst-border: #243241;
|
||||
--danger: #f04040;
|
||||
--success: #30d158;
|
||||
--warning: #ffd60a;
|
||||
@@ -36,7 +36,7 @@ html, body, #root {
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-family: "Segoe UI Variable", "SF Pro Display", "IBM Plex Sans", "Inter", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface ParsedAssistantResponse {
|
||||
rawContent: string;
|
||||
content: string;
|
||||
reasoning: string;
|
||||
thinkingOpen: boolean;
|
||||
}
|
||||
|
||||
const THINK_OPEN = "<think>";
|
||||
const THINK_CLOSE = "</think>";
|
||||
|
||||
export function buildChatTitle(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return "New Chat";
|
||||
return normalized.slice(0, 50) + (normalized.length > 50 ? "..." : "");
|
||||
}
|
||||
|
||||
export function parseAssistantResponse(rawContent: string): ParsedAssistantResponse {
|
||||
let cursor = 0;
|
||||
let thinkingOpen = false;
|
||||
let content = "";
|
||||
let reasoning = "";
|
||||
|
||||
while (cursor < rawContent.length) {
|
||||
if (!thinkingOpen) {
|
||||
const start = rawContent.indexOf(THINK_OPEN, cursor);
|
||||
if (start === -1) {
|
||||
content += rawContent.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
content += rawContent.slice(cursor, start);
|
||||
cursor = start + THINK_OPEN.length;
|
||||
thinkingOpen = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const end = rawContent.indexOf(THINK_CLOSE, cursor);
|
||||
if (end === -1) {
|
||||
reasoning += rawContent.slice(cursor);
|
||||
cursor = rawContent.length;
|
||||
break;
|
||||
}
|
||||
|
||||
reasoning += rawContent.slice(cursor, end);
|
||||
cursor = end + THINK_CLOSE.length;
|
||||
thinkingOpen = false;
|
||||
}
|
||||
|
||||
const normalizedContent = reasoning.trim().length > 0
|
||||
? content.replace(/^\s+/, "")
|
||||
: content;
|
||||
|
||||
return {
|
||||
rawContent,
|
||||
content: normalizedContent.trimEnd(),
|
||||
reasoning: reasoning.trim(),
|
||||
thinkingOpen,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,17 @@ export class DB {
|
||||
}
|
||||
}
|
||||
|
||||
function hasColumn(db: DB, table: string, column: string): boolean {
|
||||
const rows = db.query<{ name?: string }>(`PRAGMA table_info(${table})`);
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function ensureColumn(db: DB, table: string, column: string, definition: string) {
|
||||
if (!hasColumn(db, table, column)) {
|
||||
db.run(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDB(): Promise<DB> {
|
||||
// Inject sql.js script if not already loaded
|
||||
if (!window.initSqlJs) {
|
||||
@@ -105,5 +116,11 @@ export async function initDB(): Promise<DB> {
|
||||
`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)`);
|
||||
|
||||
ensureColumn(db, "messages", "reasoning_content", "reasoning_content TEXT");
|
||||
ensureColumn(db, "messages", "prompt_tokens", "prompt_tokens INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "messages", "completion_tokens", "completion_tokens INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "messages", "total_tokens", "total_tokens INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "messages", "tokens_per_second", "tokens_per_second REAL");
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -10,9 +10,20 @@ export interface Message {
|
||||
chat_id: number;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
reasoning_content?: string | null;
|
||||
prompt_tokens?: number | null;
|
||||
completion_tokens?: number | null;
|
||||
total_tokens?: number | null;
|
||||
tokens_per_second?: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ChatTokenTotals {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
object: string;
|
||||
|
||||
Reference in new issue
Block a user