diff --git a/.DS_Store b/.DS_Store index 46381cf..69bacf4 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.core/reporting.py b/.core/reporting.py index 5581a38..4223cea 100644 --- a/.core/reporting.py +++ b/.core/reporting.py @@ -120,7 +120,9 @@ def initialize_report_file(model_label: str) -> Path: ## Qualitative Analysis + *(Manual grading and analysis of the responses is required to determine the final letter grade.)* + --- @@ -204,3 +206,42 @@ def finalize_report_summary(report_path: Path, results: List[Dict[str, object]]) flags=re.DOTALL, ) report_path.write_text(updated_content, encoding="utf-8") + + +def replace_analysis_section(report_path: Path, markdown: str) -> None: + """Swap the qualitative-analysis placeholder for generated content. + + Used when grader plugins produce scores, so the report carries real grades + instead of the "grade this by hand" note. No-op if the markers are absent + (an older report, or a customized header). + """ + if not report_path.exists(): + return + + content = report_path.read_text(encoding="utf-8") + if "" not in content: + return + + updated = re.sub( + r".*?", + lambda _: f"\n{markdown.strip()}\n", + content, + flags=re.DOTALL, + ) + report_path.write_text(updated, encoding="utf-8") + + +def append_sections(report_path: Path, sections: List[str]) -> None: + """Append extra markdown blocks to the end of a finished report.""" + blocks = [block.strip() for block in sections if block and block.strip()] + if not blocks or not report_path.exists(): + return + + existing = report_path.read_text(encoding="utf-8") + with report_path.open("a", encoding="utf-8") as report_file: + if has_unclosed_code_block(existing): + report_file.write("\n```\n") + if not existing.endswith("\n"): + report_file.write("\n") + for block in blocks: + report_file.write(f"\n---\n\n{block}\n") diff --git a/.core/runner.py b/.core/runner.py index 455091f..f21ad2f 100644 --- a/.core/runner.py +++ b/.core/runner.py @@ -45,8 +45,14 @@ def run_suite( model_id: Optional[str] = None, temperature: Optional[float] = None, progress_callback: Optional[Callable[[int, int, Dict[str, str], Dict[str, object]], None]] = None, + prompts: Optional[List[Dict[str, str]]] = None, ) -> Path: - """Run the diagnostic test suite and return the generated report path.""" + """Run the diagnostic test suite and return the generated report path. + + When ``prompts`` is omitted every prompt in the ``tests`` directory is run. + Callers may pass an explicit subset (same shape as ``load_test_prompts``) + to re-run only part of the suite. + """ ensure_directories() reset_temp_directory() @@ -73,7 +79,8 @@ def run_suite( "Template file missing. Please create '.core/templates/test-block.md' before running tests." ) - prompts = load_test_prompts() + if prompts is None: + prompts = load_test_prompts() if not prompts: raise TestRunError("No test prompts found in the 'tests' directory.") diff --git a/.gitignore b/.gitignore index d06faf3..45c442f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,14 @@ ENV/ # Ignore other build artifacts *.egg-info/ + +# Frontend +web/node_modules/ +web/dist/ +node_modules/ + +# Local model weights +models/ + +# Locally persisted GUI/CLI settings +.core/user_settings.json diff --git a/README.md b/README.md index aa7ecd9..bdb7b10 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,297 @@ +# LM-Gambit v2.0.0 — Automated LLM Diagnostic Suite +LM-Gambit benchmarks local or remote Large Language Models against a suite of prompts you +author yourself. It runs each question one at a time, records throughput and latency for +every answer, and writes a markdown report you can grade. - -# LM-Gambit v1.0.0: Automated LLM Diagnostic Suite - - -**Version 1.0.0** - -LM-Gambit is a modular, extensible framework for benchmarking and analyzing local or remote Large Language Models (LLMs). It supports both a command-line interface (CLI) and a Tkinter-based GUI, enabling rapid, repeatable evaluation of LLMs using customizable prompt suites. +Version 2.0.0 replaces the Tkinter GUI with a React web interface served by a FastAPI +backend. The diagnostic engine under `.core/` is unchanged — the CLI, providers, hardware +runtimes and report format all behave exactly as before. --- -## Features +## Install -- **Automated Testing:** Run a suite of prompt-based diagnostics against any supported LLM provider. -- **Provider System:** Out-of-the-box support for LM Studio (API) and a native Local Engine (hardware-optimized, supports Apple Silicon, CUDA, ROCm, CPU). -- **Extensible:** Add new providers or engine runtimes with minimal code changes. -- **GUI & CLI:** Choose between a full-featured GUI or a fast CLI for scripting and automation. -- **Customizable Reports:** Generates detailed Markdown reports with performance metrics and qualitative analysis sections. -- **Prompt Management:** Prompts are simple `.txt` files, editable in the GUI or any text editor. +**Requirements** ---- - -## Installation - -**Requirements:** -- Python 3.10+ -- macOS, Linux, or Windows (Apple Silicon, NVIDIA, AMD, or CPU-only supported) - -**Install dependencies:** +- Python 3.10+ (3.11 recommended) +- Node.js 20+ and npm (only to build the interface) +- macOS, Linux or Windows — Apple Silicon, NVIDIA, AMD or CPU-only ```bash python -m pip install -r requirements.txt +cd web && npm install && npm run build && cd .. ``` -**(Optional)** For Apple Silicon (MLX), ensure `mlx-lm` is installed. For CUDA/ROCm, ensure your system drivers are set up. +## Run + +```bash +python app.py +``` + +That serves the API and the compiled interface from one origin and opens +`http://localhost:8765` in your browser. + +| Flag | Purpose | +|---|---| +| `--port ` | Serve on a specific port (default 8765; the next free port is used if taken) | +| `--host ` | Bind a different interface (default `127.0.0.1`) | +| `--no-browser` | Do not open a browser window | +| `--reload` | Auto-reload on Python changes | --- -## Quick Start +## The interface -### CLI Usage +| View | What it does | +|---|---| +| **Run** | Pick a provider, model and temperature; choose all or a subset of questions; watch results stream in per question with live throughput, token counts and TTFT. Cancel between questions — the partial report is still finalized. | +| **Suite** | The question editor. One text box per question, with add, duplicate, reorder and delete. | +| **Reports** | Every saved report, with a per-question throughput chart, a metrics table and the full rendered markdown. | +| **Playground** | Send a single prompt without touching the suite or writing a report. | +| **Settings** | Default provider and temperature, model search paths, engine and folder information, and which plugins loaded. | -Run all tests with the default provider/model: +A run keeps streaming while you browse other views, and reattaches if you reload the page +mid-run. -```bash -python auto-test.py +--- + +## Writing questions + +Questions live in `tests/` as one `.txt` file per prompt. The **entire file** is sent to the +model, and its **first non-empty line** doubles as the title in reports — so lead with the +task: + +``` +Write a Swift function that solves the FizzBuzz problem. + +The function must have the exact signature: +`func generateFizzBuzz(upTo max: Int) -> [String]` ``` -**Options:** -- `-h, --help` — Show usage instructions -- `-p ` — Select provider (e.g., "Local Engine", "LM Studio") -- `-m ` — Select model by ID or filename -- `-l, --list` — List available providers or models -- `-t ` — (Reserved) Specify a test suite +Saving from the Suite Builder rewrites the folder as `test1.txt … testN.txt` in the order +shown, so the web interface and `auto-test.py` always run the same suite in the same order. +You can still edit the `.txt` files by hand. -Example: +--- + +## Plugins + +Drop a `.py` file into `plugins/` and it loads at startup. Start from the +skeleton, which documents every hook: + +```bash +cp plugins/_skeleton.py plugins/my_plugin.py +``` + +Files beginning with `_` are ignored, so the skeleton itself never runs. A +plugin directory with an `__init__.py` works too, if you want to split one +across files. + +### Hooks + +Every hook is optional — define only what you need. + +| Hook | When | Purpose | +|---|---|---| +| `grade(test)` | after each answer | Score it. Return `0.0`–`1.0`, `True`/`False`, a `Grade`, or `None` to abstain | +| `on_run_start(run)` | before the first question | Set up, announce, start a timer | +| `on_test_complete(test)` | after each question | Log, stream elsewhere, react to failures | +| `on_run_complete(run)` | when the run ends | Notify, export, archive — fires on cancel and failure too | +| `report_sections(run)` | after the report is written | Return extra markdown to append | +| `register_routes(router)` | at server start | Add endpoints under `/api/plugins/` | +| `register()` | once, at load | One-time setup; raising here disables the plugin | + +Plugins import only from `plugin_api`, which exposes `TestRecord`, `RunRecord`, +`Grade` and `GradeEntry` as plain frozen dataclasses. They never touch engine or +server internals. + +### A minimal grader + +```python +# plugins/actor_check.py +from plugin_api import Grade + +NAME = "Actor check" +DESCRIPTION = "Verifies concurrency questions actually use an actor." + + +def grade(test): + if "thread-safe" not in test.prompt.lower(): + return None # abstain — not my kind of question + used_actor = "actor " in (test.response or "") + return Grade( + score=1.0 if used_actor else 0.0, + label="actor" if used_actor else "no actor", + ) +``` + +### How grading behaves + +- **Abstaining is free.** Returning `None` excludes the question from that + grader entirely; it never counts as a zero. +- **A question's score is the mean** of every grader that scored it. The run's + score is the mean of those per-question scores. +- **Failed questions are never graded** — there is no answer to judge. Use + `on_test_complete` if you want to see failures. +- **Grades replace the report's "grade this by hand" placeholder** with a table + of scores, a letter grade, and each grader's notes. With no graders installed, + the report is unchanged from v1. +- Scores appear live in the run feed and in the Reports table. + +### Failure isolation + +A plugin that raises is logged with its slug and skipped for that call only — +it stays loaded and its other hooks keep firing. A plugin that fails to import +is listed in Settings → Plugins with the error, and everything else still runs. +A plugin can never fail a run or stop the server. + +### Reloading + +**Settings → Plugins → Reload** re-scans the directory, picking up new graders +and lifecycle hooks without a restart. Plugins that add HTTP routes need a full +restart, since routes are bound when the server starts. + +Set `ENABLED = False` in a plugin to keep the file but stop loading it. + +--- + +## CLI + +The CLI is unchanged and does not require the frontend to be built. ```bash python auto-test.py -p "Local Engine" -m Qwen3-Coder-30B.gguf ``` -Reports are saved to `results/automated_report_.md`. +| Flag | Purpose | +|---|---| +| `-h, --help` | Usage instructions | +| `-p ` | Provider to use (e.g. `"Local Engine"`, `"LM Studio"`) | +| `-m ` | Model by id or filename | +| `-l, --list` | List providers, or models when combined with `-p` | -### GUI Usage +Reports are written to `results/automated_report_.md`. -```bash -python index.py +--- + +## Project structure + +``` +.core/ Diagnostic engine (unchanged) + providers/ Provider adapters (LM Studio, Local Engine) + .engine/ Hardware runtimes (MLX, CUDA, ROCm, CPU) + runner.py Orchestrates a run and its report + reporting.py Markdown report generation + prompts.py Loads prompts from tests/ + templates/ test-block.md — the per-question report template +server/ FastAPI backend wrapping the engine + core_bridge.py Import shim for the hidden .core package + api.py REST endpoints + run_manager.py Background runs + server-sent-event streaming + suite.py Reads and writes tests/ + plugins.py Bridge between server internals and the plugin API +plugins/ Drop-in plugins — _skeleton.py is the starter template +plugin_api.py Stable types plugins import +plugin_system.py Plugin discovery and hook dispatch +web/ React + TypeScript interface (Vite) +models/ Drop .gguf or MLX weights here (auto-discovered) +tests/ One prompt per .txt file +results/ Generated markdown reports +app.py Web entrypoint +auto-test.py CLI entrypoint ``` -**GUI Features:** -- Provider/model dropdowns with auto-discovery -- Adjustable sampling temperature -- Run, refresh, open prompts/results, and view latest report -- Live log of test progress -- Settings dialog for managing model search paths +--- + +## How a run works + +1. **Provider selection** — providers are registered in `.core/providers/`, each implementing + `list_models()` and `run_prompt()`. The Local Engine picks the best runtime for your + hardware: MLX on Apple Silicon, CUDA on NVIDIA, ROCm on AMD, or a `llama-cpp-python` CPU + fallback. +2. **Prompt loading** — every `.txt` in `tests/`, in natural filename order. +3. **Execution** — one prompt at a time against the selected model. The backend streams each + result to the browser as it lands, so nothing is buffered until the end. +4. **Reporting** — each result is rendered through `.core/templates/test-block.md`, with a + performance summary written at the top once the run finishes. --- -## Project Structure +## API -- `.core/` — Core logic, providers, engine loader, reporting, templates - - `providers/` — Provider adapters (LM Studio, Local Engine, etc.) - - `.engine/` — Hardware-specific runtime implementations (MLX, CUDA, ROCm, CPU) - - `runner.py` — Orchestrates test runs, progress, and reporting - - `reporting.py` — Markdown report generation, code-fence hygiene - - `prompts.py` — Loads and parses prompt files from `tests/` - - `templates/test-block.md` — Customizable template for each test result -- `models/` — Drop-in directory for `.gguf` or MLX-compatible weights (auto-discovered) -- `tests/` — One prompt per `.txt` file (edit or add your own) -- `results/` — Markdown reports generated after each run -- `auto-test.py` — CLI entrypoint -- `index.py` — Tkinter GUI entrypoint +The backend is a normal REST API — interactive docs at `http://localhost:8765/api/docs`. + +| Endpoint | Purpose | +|---|---| +| `GET /api/providers` · `GET /api/providers/{name}/models` | Discovery | +| `GET /api/tests` · `PUT /api/tests` | Read and replace the suite | +| `POST /api/runs` · `GET /api/runs/{id}/events` · `POST /api/runs/{id}/cancel` | Start, stream, cancel | +| `GET /api/reports` · `GET /api/reports/{name}` | Saved reports | +| `POST /api/playground` | One-off prompt | +| `GET /api/settings` · `PUT /api/settings` | Preferences | +| `GET /api/plugins` · `POST /api/plugins/reload` | Installed plugins | +| `/api/plugins//…` | Whatever a plugin's `register_routes` defines | + +Only one run executes at a time — the local engine loads weights into memory, so overlapping +runs would compete for RAM and produce meaningless throughput numbers. --- -## How the Core Engine Works +## Configuration -1. **Provider Selection:** - - Providers (e.g., LM Studio, Local Engine) are registered in `.core/providers/`. - - Each provider implements `list_models()` and `run_prompt()`. - - The Local Engine auto-selects the best runtime for your hardware (MLX, CUDA, ROCm, or CPU fallback). +| Variable | Purpose | +|---|---| +| `LM_STUDIO_BASE_URL` | LM Studio endpoint (default `http://localhost:1234`) | +| `AUTO_TEST_TEMPERATURE` | Default sampling temperature | +| `AUTO_TEST_PROVIDER` | Default provider for CLI runs | +| `LOCAL_LLM_PATHS` | `os.pathsep`-separated extra directories to scan for `.gguf` weights | -2. **Prompt Loading:** - - Prompts are loaded from the `tests/` directory (one `.txt` file per test). - - Each prompt file's first non-empty line is used as the test title. - -3. **Test Execution:** - - For each prompt, the selected provider/model is used to generate a response. - - Results are streamed to the CLI or GUI with live progress updates. - -4. **Reporting:** - - Each test result is rendered using a Markdown template (`.core/templates/test-block.md`). - - Performance metrics (tokens/sec, time to first token, etc.) are included. - - A summary and qualitative analysis section are generated at the top of the report. +Settings changed in the interface persist to `.core/user_settings.json` and are shared with +the CLI. Common LM Studio locations (`~/.lmstudio`, `~/.lmstudio/models`, +`~/Library/Application Support/lm-studio/models`) are scanned by default. --- -## Configuration & Environment Variables +## Frontend development -You can customize behavior via environment variables: +```bash +python app.py --no-browser # terminal 1 — API on :8765 +cd web && npm run dev # terminal 2 — UI on :5173 with hot reload +``` -| Variable | Purpose | -|-------------------------|--------------------------------------------------------------| -| `LM_STUDIO_BASE_URL` | Override LM Studio API endpoint (default: http://localhost:1234) | -| `AUTO_TEST_TEMPERATURE` | Default sampling temperature for test runs | -| `AUTO_TEST_PROVIDER` | Default provider for CLI runs | -| `LOCAL_LLM_PATHS` | `:`-separated list of extra directories for `.gguf` models | - -Model search paths can also be managed via the GUI settings dialog (persisted in `.core/user_settings.json`). +Vite proxies `/api` (including the event stream) through to the Python server, so both run +from one origin. --- +## Extending -## Extending LM-Gambit +**A new provider** — add a class in `.core/providers/` inheriting from `Provider`, implement +`list_models()` and `run_prompt()`, and register it in `.core/providers/__init__.py`. It +appears in the interface automatically. -**Add a new provider:** -1. Create a new class in `.core/providers/` inheriting from `Provider` (see `base.py`). -2. Implement `list_models()` and `run_prompt()`. -3. Register your provider in `.core/providers/__init__.py`. - -**Add a new engine runtime:** -1. Add a new Python file under `.core/.engine/./.py`. -2. Implement an `EngineRuntime` class inheriting from `BaseRuntime`. -3. The engine loader will auto-detect and use your runtime if the hardware matches. - ---- - - -## Customizing Prompts & Reports in LM-Gambit - -- Add/edit prompt `.txt` files in `tests/` (first non-empty line is the title). -- Edit `.core/templates/test-block.md` to change the report format. +**A new engine runtime** — add `.core/.engine/./.py` defining an +`EngineRuntime` class that inherits from `BaseRuntime`. The loader picks it up when the +hardware matches. --- ## Troubleshooting -- **No models found?** - - Ensure your `.gguf` files are in `models/` or listed in `LOCAL_LLM_PATHS`. - - For LM Studio, ensure the app is running and the API is enabled. -- **Engine errors?** - - Check your hardware drivers and Python dependencies. -- **GUI not launching?** - - Ensure `tkinter` is installed and available in your Python environment. +**"Frontend not built"** — run `cd web && npm install && npm run build`. The API and CLI work +without it. + +**No models found** — put `.gguf` files in `models/`, add a path under Settings, or set +`LOCAL_LLM_PATHS`. For LM Studio, make sure the app is running with its API enabled. + +**Port already in use** — `app.py` automatically tries the next 20 ports, or pass `--port`. + +**Engine errors** — check Settings → Engine for the detected runtime, and confirm your +hardware drivers and Python dependencies are installed. --- - ## License MIT License. See `LICENSE` for details. - -- `LM_STUDIO_BASE_URL` – override the default `http://localhost:1234` endpoint for the LM Studio provider. -- `AUTO_TEST_TEMPERATURE` – default sampling temperature for test runs. -- `AUTO_TEST_PROVIDER` – default provider name for CLI runs. -- `LOCAL_LLM_PATHS` – optional `os.pathsep`-separated list of extra directories to scan for `.gguf` weights. - -Templates can be customized by editing `.core/templates/test-block.md`. - -### Local Engine provider - -- Place `.gguf` (llama.cpp) weights inside `models/` or add extra directories via the GUI settings dialog / `LOCAL_LLM_PATHS` environment variable. -- Paths include common defaults (for example `~/.lmstudio`, `~/.lmstudio/models`, or `~/Library/Application Support/lm-studio/models` on macOS) so LM Studio downloads are discovered automatically. -- The engine loader automatically selects the best runtime for your hardware: MLX on Apple Silicon, CUDA on NVIDIA GPUs, ROCm on AMD GPUs, or a CPU fallback via `llama-cpp-python`. -- Settings are saved in `.core/user_settings.json`, keeping CLI and GUI runs in sync. - - -## Extending Providers in LM-Gambit - -Provider adapters live under `.core/providers/` and are registered in `.core/providers/__init__.py`. Each provider implements `list_models` and `run_prompt` to integrate with the runner and GUI, while hardware-specific runtimes reside under `.core/.engine/`. diff --git a/app.py b/app.py new file mode 100644 index 0000000..71f6488 --- /dev/null +++ b/app.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""LM-Gambit desktop entrypoint. + +Starts the FastAPI backend, serves the compiled React interface from the same +origin and opens a browser at it: + + python app.py + +For frontend development run the Vite dev server alongside it instead: + + python app.py --no-browser # terminal 1 + cd web && npm run dev # terminal 2 -> http://localhost:5173 +""" + +from __future__ import annotations + +import argparse +import socket +import sys +import threading +import webbrowser +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +DEFAULT_PORT = 8765 +WEB_DIST = ROOT_DIR / "web" / "dist" + + +def port_is_free(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind((host, port)) + except OSError: + return False + return True + + +def find_port(host: str, preferred: int) -> int: + for candidate in range(preferred, preferred + 20): + if port_is_free(host, candidate): + return candidate + raise SystemExit( + f"No free port found between {preferred} and {preferred + 19}. " + "Pass --port to choose one explicitly." + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="app.py", + description="Run the LM-Gambit web interface.", + ) + parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1)") + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to serve on (default: {DEFAULT_PORT}; the next free one is used if taken)", + ) + parser.add_argument("--no-browser", action="store_true", help="Do not open a browser window") + parser.add_argument("--reload", action="store_true", help="Auto-reload on Python changes (dev)") + args = parser.parse_args() + + try: + import uvicorn + except ImportError: + print( + "FastAPI/uvicorn are not installed.\n" + " python -m pip install -r requirements.txt", + file=sys.stderr, + ) + return 1 + + port = args.port if port_is_free(args.host, args.port) else find_port(args.host, args.port) + url = f"http://{'localhost' if args.host in {'127.0.0.1', '0.0.0.0'} else args.host}:{port}" + + if not (WEB_DIST / "index.html").is_file(): + print( + "The web interface has not been built yet.\n" + " cd web && npm install && npm run build\n" + f"Starting the API anyway — docs at {url}/api/docs\n", + file=sys.stderr, + ) + elif not args.no_browser: + threading.Timer(1.0, lambda: webbrowser.open(url)).start() + + print(f"\n LM-Gambit -> {url}\n Press Ctrl+C to stop.\n") + + uvicorn.run( + "server.main:app" if args.reload else _build_app(), + host=args.host, + port=port, + reload=args.reload, + log_level="info", + access_log=False, + ) + return 0 + + +def _build_app(): + from server.main import create_app + + return create_app() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("\nStopped.") diff --git a/auto-test.py b/auto-test.py index b00a538..3754973 100644 --- a/auto-test.py +++ b/auto-test.py @@ -7,9 +7,18 @@ if str(CORE_DIR) not in sys.path: sys.path.insert(0, str(CORE_DIR)) import argparse +import time from runner import run_suite, TestRunError, TemplateNotFoundError from providers import list_provider_names, get_provider from config import DEFAULT_PROVIDER_NAME +from reporting import append_sections, replace_analysis_section + +from plugin_api import GradeEntry, RunRecord, TestRecord +from plugin_system import ( + collect_report_sections, + get_plugin_manager, + render_grade_section, +) def list_providers(): print("Available providers:") @@ -61,13 +70,60 @@ def main(): model = args.m # args.t is parsed but not yet implemented in runner + plugins = get_plugin_manager() + for loaded in plugins.loaded: + if loaded.error: + print(f"Warning: plugin '{loaded.slug}' failed to load: {loaded.error}") + + records: list[TestRecord] = [] + grades: dict[int, list[GradeEntry]] = {} + started_at = time.time() + def _print_progress(index: int, total: int, prompt, result): status = "FAILED" if "error" in result else "DONE" filename_label = prompt.get("filename", f"test{index}") title = prompt.get("title", f"Test {index}") - print(f"Running {title} [{filename_label}] ({index}/{total})... {status}") + + record = TestRecord( + index=index, + total=total, + title=title, + filename=filename_label, + prompt=prompt.get("prompt", ""), + ok="error" not in result, + response=result.get("response") if "error" not in result else None, + error=str(result["error"]) if "error" in result else None, + metrics=dict(result.get("metrics") or {}), + ) + records.append(record) + + # Same contract as the web interface: failed questions are never graded. + scored = plugins.grade(record) if record.ok else [] + if scored: + grades[index] = [GradeEntry(grader=n, grade=g) for n, g in scored] + + plugins.emit("on_test_complete", record) + + suffix = "" + if index in grades: + mean = sum(e.grade.score for e in grades[index]) / len(grades[index]) + suffix = f" [score {mean:.0%}]" + print(f"Running {title} [{filename_label}] ({index}/{total})... {status}{suffix}") print(f"Starting automated diagnostic run with provider '{provider}'…") + plugins.emit( + "on_run_start", + RunRecord( + id=f"cli-{int(started_at)}", + provider=provider, + model_id=model or "", + model_label=model or "(provider default)", + temperature=0.0, + total=0, + status="running", + started_at=started_at, + ), + ) try: report_path = run_suite(provider_name=provider, model_id=model, progress_callback=_print_progress) except TestRunError as exc: @@ -77,8 +133,59 @@ def main(): print(f"Error: {exc}") return 1 + _apply_plugins_to_report(report_path, records, grades, provider, model, started_at) + print(f"\n✅ Success! Report saved to '{report_path.name}'.") return 0 + +def _apply_plugins_to_report(report_path, records, grades, provider, model, started_at) -> None: + """Write grades and plugin sections into the report the CLI just produced. + + Mirrors what the web backend does, so a run graded through either entrypoint + yields the same report. + """ + plugins = get_plugin_manager() + label = report_path.stem.replace("automated_report_", "") + + record = RunRecord( + id=f"cli-{int(started_at)}", + provider=provider, + model_id=model or "", + model_label=label, + temperature=0.0, + total=len(records), + status="completed", + started_at=started_at, + finished_at=time.time(), + report_path=report_path, + summary={}, + tests=records, + grades=grades, + ) + + grade_markdown = render_grade_section(record) + if grade_markdown: + try: + replace_analysis_section(report_path, grade_markdown) + except OSError: + pass + overall = record.overall_score + if overall is not None: + print(f"\nGraded {len(grades)}/{len(records)} questions — overall {overall:.0%}") + + try: + sections = collect_report_sections(record, plugins) + except Exception as exc: # noqa: BLE001 - a plugin must not fail the CLI + print(f"Warning: report_sections hook failed: {exc}") + sections = [] + if sections: + try: + append_sections(report_path, sections) + except OSError: + pass + + plugins.emit("on_run_complete", record) + if __name__ == "__main__": raise SystemExit(main()) diff --git a/plugin_api.py b/plugin_api.py new file mode 100644 index 0000000..90eac93 --- /dev/null +++ b/plugin_api.py @@ -0,0 +1,117 @@ +"""Stable types passed to LM-Gambit plugins. + +Plugins import from this module and nothing else in the project:: + + from plugin_api import Grade, RunRecord, TestRecord + +Everything here is a plain frozen dataclass, so plugins never touch engine or +server internals and keep working across refactors of either. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +__all__ = ["Grade", "GradeEntry", "RunRecord", "TestRecord", "PluginInfo"] + + +@dataclass(frozen=True) +class TestRecord: + """One question and the model's answer to it.""" + + index: int + total: int + title: str + filename: str + prompt: str + ok: bool + response: Optional[str] = None + error: Optional[str] = None + metrics: Dict[str, Any] = field(default_factory=dict) + elapsed: float = 0.0 + + @property + def tokens_per_second(self) -> float: + return float(self.metrics.get("tokens_per_second", 0) or 0) + + @property + def total_tokens(self) -> int: + return int(self.metrics.get("total_tokens", 0) or 0) + + @property + def time_to_first_token(self) -> float: + return float(self.metrics.get("time_to_first_token", 0) or 0) + + +@dataclass(frozen=True) +class Grade: + """A score for one answer. + + ``score`` is clamped to 0.0–1.0. Return ``None`` from a grader instead of a + ``Grade`` to abstain — abstentions never count against the average. + """ + + score: float + label: str = "" + notes: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "score", max(0.0, min(1.0, float(self.score)))) + + +@dataclass(frozen=True) +class GradeEntry: + """A grade together with the plugin that produced it.""" + + grader: str + grade: Grade + + +@dataclass(frozen=True) +class RunRecord: + """A whole diagnostic run, handed to lifecycle and report hooks.""" + + id: str + provider: str + model_id: str + model_label: str + temperature: float + total: int + status: str + started_at: float + finished_at: Optional[float] = None + report_path: Optional[Path] = None + summary: Dict[str, Any] = field(default_factory=dict) + tests: List[TestRecord] = field(default_factory=list) + grades: Dict[int, List[GradeEntry]] = field(default_factory=dict) + + def score_for(self, test_index: int) -> Optional[float]: + """Mean score across every grader that scored this question.""" + entries = self.grades.get(test_index) or [] + if not entries: + return None + return sum(entry.grade.score for entry in entries) / len(entries) + + @property + def overall_score(self) -> Optional[float]: + """Mean of the per-question scores, over graded questions only.""" + scores = [s for s in (self.score_for(t.index) for t in self.tests) if s is not None] + if not scores: + return None + return sum(scores) / len(scores) + + +@dataclass(frozen=True) +class PluginInfo: + """What the server reports about a discovered plugin.""" + + name: str + slug: str + version: str + description: str + path: str + enabled: bool + hooks: List[str] = field(default_factory=list) + error: Optional[str] = None diff --git a/plugin_system.py b/plugin_system.py index 2293970..0dec409 100644 --- a/plugin_system.py +++ b/plugin_system.py @@ -1,35 +1,359 @@ -# Plugin system scaffold for LM-Gambit -# Drop .py files in the plugins/ directory. Each should define a 'register' function. +"""Plugin loader for LM-Gambit. + +Drop a ``.py`` file (or a package directory) into ``plugins/`` and it is picked +up at startup. Copy ``plugins/_skeleton.py`` to begin — it documents every hook. + +Hooks a plugin may define, all optional: + +=========================== ================================================== +``grade(test)`` Score one answer. Return a float, a ``Grade``, or + ``None`` to abstain. +``on_run_start(run)`` Fired once when a run begins. +``on_test_complete(test)`` Fired after each question, before the next starts. +``on_run_complete(run)`` Fired once when a run ends (also on cancel/failure). +``report_sections(run)`` Return extra markdown appended to the report. +``register_routes(router)`` Add FastAPI routes under ``/api/plugins/``. +=========================== ================================================== + +Design notes +------------ +* Modules load under a synthetic ``lmgambit_plugins`` package. Loading them by + bare stem would put e.g. ``plugins/config.py`` into ``sys.modules["config"]`` + and shadow the engine's own ``.core/config.py``, since ``.core`` is on + ``sys.path`` and imported flat. +* Every load and every hook call is isolated. One bad plugin is reported and + skipped; it never takes down a run or the server. +* Hook results are returned, not discarded, so hooks can contribute data. +""" + +from __future__ import annotations import importlib.util import sys +import traceback +import types +from dataclasses import dataclass from pathlib import Path +from typing import Any, Callable, List, Optional, Tuple + +from plugin_api import Grade, PluginInfo, RunRecord + +ROOT_DIR = Path(__file__).resolve().parent +PLUGIN_DIR = ROOT_DIR / "plugins" +NAMESPACE = "lmgambit_plugins" + +HOOK_NAMES = ( + "grade", + "on_run_start", + "on_test_complete", + "on_run_complete", + "report_sections", + "register_routes", +) + + +@dataclass +class LoadedPlugin: + slug: str + name: str + version: str + description: str + path: Path + enabled: bool + module: Optional[types.ModuleType] = None + error: Optional[str] = None + + @property + def hooks(self) -> List[str]: + if self.module is None: + return [] + return [hook for hook in HOOK_NAMES if callable(getattr(self.module, hook, None))] + + def info(self) -> PluginInfo: + return PluginInfo( + name=self.name, + slug=self.slug, + version=self.version, + description=self.description, + path=str(self.path), + enabled=self.enabled and self.error is None, + hooks=self.hooks, + error=self.error, + ) + + +def _ensure_namespace(plugin_dir: Path) -> None: + """Create the synthetic parent package plugins are loaded beneath.""" + package = sys.modules.get(NAMESPACE) + if package is None: + package = types.ModuleType(NAMESPACE) + package.__doc__ = "Namespace for locally installed LM-Gambit plugins." + sys.modules[NAMESPACE] = package + package.__path__ = [str(plugin_dir)] # type: ignore[attr-defined] + + +def _candidate_paths(plugin_dir: Path) -> List[Path]: + """Single-file plugins and package directories, ignoring ``_`` prefixes.""" + if not plugin_dir.is_dir(): + return [] + + candidates: List[Path] = [] + for entry in sorted(plugin_dir.iterdir()): + if entry.name.startswith((".", "_")) or entry.name == "__pycache__": + continue + if entry.is_file() and entry.suffix == ".py": + candidates.append(entry) + elif entry.is_dir() and (entry / "__init__.py").is_file(): + candidates.append(entry / "__init__.py") + return candidates -PLUGIN_DIR = Path(__file__).parent / "plugins" class PluginManager: - def __init__(self): - self.plugins = [] - self.load_plugins() + """Discovers plugins and dispatches hooks to them.""" - def load_plugins(self): - if not PLUGIN_DIR.exists(): - return - for file in PLUGIN_DIR.glob("*.py"): - name = file.stem - spec = importlib.util.spec_from_file_location(name, file) - if spec and spec.loader: - mod = importlib.util.module_from_spec(spec) - sys.modules[name] = mod - spec.loader.exec_module(mod) - if hasattr(mod, "register"): - self.plugins.append(mod) + def __init__(self, plugin_dir: Optional[Path] = None, *, auto_load: bool = True) -> None: + self.plugin_dir = Path(plugin_dir) if plugin_dir else PLUGIN_DIR + self.loaded: List[LoadedPlugin] = [] + if auto_load: + self.load() - def run_hook(self, hook_name, *args, **kwargs): - for plugin in self.plugins: - hook = getattr(plugin, hook_name, None) - if callable(hook): - try: - hook(*args, **kwargs) - except Exception as e: - print(f"Plugin {plugin.__name__} hook {hook_name} failed: {e}") + # ------------------------------------------------------------- discovery + + def load(self) -> List[LoadedPlugin]: + """(Re)discover everything in the plugin directory.""" + self.loaded = [] + if not self.plugin_dir.is_dir(): + return self.loaded + + _ensure_namespace(self.plugin_dir) + + for path in _candidate_paths(self.plugin_dir): + slug = path.parent.name if path.name == "__init__.py" else path.stem + self.loaded.append(self._load_one(slug, path)) + return self.loaded + + def _load_one(self, slug: str, path: Path) -> LoadedPlugin: + record = LoadedPlugin( + slug=slug, + name=slug.replace("_", " ").title(), + version="0.0.0", + description="", + path=path, + enabled=True, + ) + + module_name = f"{NAMESPACE}.{slug}" + is_package = path.name == "__init__.py" + try: + spec = importlib.util.spec_from_file_location( + module_name, + path, + submodule_search_locations=[str(path.parent)] if is_package else None, + ) + if spec is None or spec.loader is None: + record.error = "Could not create a module spec for this file." + return record + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + record.error = _last_line(traceback.format_exc()) + return record + + record.module = module + record.name = str(getattr(module, "NAME", record.name)) + record.version = str(getattr(module, "VERSION", "0.0.0")) + record.description = str(getattr(module, "DESCRIPTION", "")) + record.enabled = bool(getattr(module, "ENABLED", True)) + + # `register()` is the place for one-time setup, if a plugin needs any. + register = getattr(module, "register", None) + if record.enabled and callable(register): + try: + register() + except Exception: + record.error = f"register() failed: {_last_line(traceback.format_exc())}" + record.enabled = False + + return record + + # -------------------------------------------------------------- querying + + @property + def active(self) -> List[LoadedPlugin]: + return [p for p in self.loaded if p.enabled and p.error is None and p.module is not None] + + def info(self) -> List[PluginInfo]: + return [plugin.info() for plugin in self.loaded] + + def with_hook(self, hook: str) -> List[LoadedPlugin]: + return [p for p in self.active if callable(getattr(p.module, hook, None))] + + # -------------------------------------------------------------- dispatch + + def emit(self, hook: str, *args: Any, **kwargs: Any) -> None: + """Fire a hook for its side effects, ignoring return values.""" + self.collect(hook, *args, **kwargs) + + def collect(self, hook: str, *args: Any, **kwargs: Any) -> List[Tuple[LoadedPlugin, Any]]: + """Call a hook on every plugin defining it and keep non-None results. + + A plugin that raises is reported on stderr and skipped for this call + only — it stays loaded for subsequent hooks. + """ + results: List[Tuple[LoadedPlugin, Any]] = [] + for plugin in self.with_hook(hook): + callback: Callable[..., Any] = getattr(plugin.module, hook) + try: + value = callback(*args, **kwargs) + except Exception: + print( + f"[plugin:{plugin.slug}] {hook}() failed: {_last_line(traceback.format_exc())}", + file=sys.stderr, + ) + continue + if value is not None: + results.append((plugin, value)) + return results + + def grade(self, test: Any) -> List[Tuple[str, Grade]]: + """Run every grader over one answer, normalising the return values.""" + grades: List[Tuple[str, Grade]] = [] + for plugin, value in self.collect("grade", test): + grade = _coerce_grade(value) + if grade is None: + print( + f"[plugin:{plugin.slug}] grade() returned {value!r}; " + "expected a number, a Grade, or None.", + file=sys.stderr, + ) + continue + grades.append((plugin.name, grade)) + return grades + + +def _last_line(text: str) -> str: + lines = [line for line in text.strip().splitlines() if line.strip()] + return lines[-1].strip() if lines else "unknown error" + + +def _coerce_grade(value: Any) -> Optional[Grade]: + """Accept a Grade, a number, a bool, or a dict from a grader.""" + if isinstance(value, Grade): + return value + if isinstance(value, bool): + return Grade(score=1.0 if value else 0.0, label="pass" if value else "fail") + if isinstance(value, (int, float)): + return Grade(score=float(value)) + if isinstance(value, dict) and "score" in value: + try: + return Grade( + score=float(value["score"]), + label=str(value.get("label", "")), + notes=str(value.get("notes", "")), + ) + except (TypeError, ValueError): + return None + return None + + +# --------------------------------------------------------------- report output +# These live here rather than in the server so the CLI and the web interface +# produce byte-identical reports for the same run. + + +def letter_grade(score: float) -> str: + for threshold, letter in ( + (0.97, "A+"), (0.93, "A"), (0.90, "A-"), + (0.87, "B+"), (0.83, "B"), (0.80, "B-"), + (0.77, "C+"), (0.73, "C"), (0.70, "C-"), + (0.67, "D+"), (0.60, "D"), + ): + if score >= threshold: + return letter + return "F" + + +def render_grade_section(record: RunRecord) -> Optional[str]: + """Render grades as the report's Qualitative Analysis block.""" + overall = record.overall_score + if overall is None: + return None + + graders = sorted({entry.grader for entries in record.grades.values() for entry in entries}) + + def escape(text: str) -> str: + return text.replace("|", "\\|").replace("\n", " ").strip() + + lines = [ + f"**Overall score: {overall:.0%} ({letter_grade(overall)})** " + f"— graded by {', '.join(f'`{g}`' for g in graders)}.", + "", + "Scores come from plugins in `plugins/`, so they reflect whatever those " + "plugins check — not a judgement of correctness. Review before relying on them.", + "", + "| # | Question | Score | Grader | Notes |", + "| --- | --- | --- | --- | --- |", + ] + + for test in record.tests: + for entry in record.grades.get(test.index) or []: + label = escape(entry.grade.label) + score = f"{entry.grade.score:.0%}" + (f" ({label})" if label else "") + lines.append( + f"| {test.index} | {escape(test.title)} | {score} " + f"| {escape(entry.grader)} | {escape(entry.grade.notes) or '—'} |" + ) + + ungraded = [t for t in record.tests if not record.grades.get(t.index)] + if ungraded: + lines.extend( + [ + "", + f"*{len(ungraded)} question(s) were not graded — no plugin had an " + "opinion on them. They are excluded from the overall score.*", + ] + ) + + return "\n".join(lines) + + +def collect_report_sections(record: RunRecord, manager: "PluginManager") -> List[str]: + """Normalize whatever `report_sections` hooks return into markdown blocks.""" + blocks: List[str] = [] + for _, value in manager.collect("report_sections", record): + blocks.extend(_normalize_section(value)) + return blocks + + +def _normalize_section(value: Any) -> List[str]: + if isinstance(value, str): + return [value] + if isinstance(value, (list, tuple)): + parts: List[str] = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, (list, tuple)) and len(item) == 2: + heading, body = item + parts.append(f"{heading}\n\n{body}") + return parts + return [] + + +_manager: Optional[PluginManager] = None + + +def get_plugin_manager() -> PluginManager: + """Process-wide plugin manager, created on first use.""" + global _manager + if _manager is None: + _manager = PluginManager() + return _manager + + +def reload_plugins() -> List[LoadedPlugin]: + """Re-scan the plugin directory. New routes still need a server restart.""" + return get_plugin_manager().load() diff --git a/plugins/_skeleton.py b/plugins/_skeleton.py new file mode 100644 index 0000000..0d7686e --- /dev/null +++ b/plugins/_skeleton.py @@ -0,0 +1,161 @@ +"""LM-Gambit plugin skeleton — copy this file to start a new plugin. + + cp plugins/_skeleton.py plugins/my_plugin.py + +Files starting with ``_`` are ignored by the loader, so this template never +runs. Rename it (no leading underscore) and it loads on the next server start. + +Every hook below is OPTIONAL. Delete the ones you don't need — a plugin that +only defines ``grade`` is perfectly valid. Anything you leave undefined is +simply never called. + +If a hook raises, LM-Gambit logs it and carries on. Your plugin stays loaded +and its other hooks keep firing, so a bug in one grader can't break a run. +""" + +from plugin_api import Grade, RunRecord, TestRecord + +# -------------------------------------------------------------------------- +# Metadata — all optional. NAME is what shows up in Settings → Plugins. +# -------------------------------------------------------------------------- + +NAME = "My Plugin" +VERSION = "1.0.0" +DESCRIPTION = "One line describing what this plugin does." +ENABLED = True # flip to False to keep the file but stop loading it + + +def register() -> None: + """One-time setup, called once when the plugin loads. + + Open a database, read a config file, check that a CLI tool you depend on + exists. Raising here disables the plugin and shows the error in Settings. + """ + + +# -------------------------------------------------------------------------- +# Grading — score an answer +# -------------------------------------------------------------------------- + + +def grade(test: TestRecord): + """Score one answer, or return None to abstain. + + Abstaining is normal and costs nothing: a grader that only understands + SwiftUI questions should return None for everything else, and those + questions simply won't count toward the average. + + This is never called for a question the provider failed on — there is no + answer to judge. Use `on_test_complete` if you need to see failures too. + + You may return: + None abstain — this grader has no opinion + 0.0 – 1.0 a bare score + True / False shorthand for 1.0 / 0.0 + Grade(...) a score plus a label and notes shown in the report + + `test` gives you: + test.index 1-based position in the run + test.total how many questions the run covers + test.title first line of the prompt + test.filename e.g. "test3.txt" + test.prompt the full prompt text sent to the model + test.ok False if the provider errored + test.response the model's answer (None when ok is False) + test.error the provider error (None when ok is True) + test.metrics raw provider metrics dict + test.tokens_per_second convenience accessors over metrics + test.total_tokens + test.time_to_first_token + """ + # Only judge questions this plugin actually understands. + if "swift" not in test.prompt.lower(): + return None + + has_code_block = "```" in test.response + return Grade( + score=1.0 if has_code_block else 0.3, + label="has code" if has_code_block else "prose only", + notes="Checked that the answer contains a fenced code block.", + ) + + +# -------------------------------------------------------------------------- +# Run lifecycle — react to a run starting, progressing and finishing +# -------------------------------------------------------------------------- + + +def on_run_start(run: RunRecord) -> None: + """Fired once, before the first question is sent.""" + print(f"[my_plugin] starting {run.total} questions against {run.model_label}") + + +def on_test_complete(test: TestRecord) -> None: + """Fired after each question, before the next one starts. + + This runs on the run's worker thread, so slow work here delays the next + question. Keep it quick, or hand it off to a thread of your own. + """ + + +def on_run_complete(run: RunRecord) -> None: + """Fired once when the run ends — including when cancelled or failed. + + Check `run.status`: "completed", "cancelled" or "failed". Useful for + notifications, CSV logs, or copying `run.report_path` somewhere else. + + run.summary {"average_tokens_per_second": ..., "passed": ...} + run.overall_score mean of graded questions, or None if nothing graded + run.score_for(3) score for question 3, or None + run.tests every TestRecord from the run + run.report_path Path to the generated markdown report + """ + score = run.overall_score + if score is not None: + print(f"[my_plugin] {run.model_label} scored {score:.0%}") + + +# -------------------------------------------------------------------------- +# Report — add your own markdown to the generated report +# -------------------------------------------------------------------------- + + +def report_sections(run: RunRecord): + """Return extra markdown appended to the report, or None to add nothing. + + Return either a markdown string, or a list of (heading, body) pairs. + Headings should start at `##` to sit alongside the built-in sections. + """ + slowest = min( + (t for t in run.tests if t.ok and t.tokens_per_second), + key=lambda t: t.tokens_per_second, + default=None, + ) + if slowest is None: + return None + + return [ + ( + "## Slowest question", + f"`{slowest.filename}` — {slowest.title}\n\n" + f"* **Throughput:** {slowest.tokens_per_second} tok/s", + ) + ] + + +# -------------------------------------------------------------------------- +# HTTP — expose your own API endpoints +# -------------------------------------------------------------------------- + + +def register_routes(router) -> None: + """Add FastAPI routes, mounted under /api/plugins/. + + For a plugin saved as `plugins/my_plugin.py`, the route below is served at + /api/plugins/my_plugin/ping. Routes are registered when the server starts, + so adding or changing them requires a restart. + """ + + @router.get("/ping") + async def ping() -> dict: + return {"plugin": NAME, "version": VERSION, "status": "ok"} diff --git a/plugins/response_checks.py b/plugins/response_checks.py new file mode 100644 index 0000000..7a015b1 --- /dev/null +++ b/plugins/response_checks.py @@ -0,0 +1,161 @@ +"""Requirement coverage grader. + +Scores an answer on whether it actually used the API symbols the question asked +for. Prompts in this suite name their requirements in backticks — +``func generateFizzBuzz(upTo max: Int) -> [String]``, ``actor``, ``@Observable`` +— which makes them checkable without any language-specific parsing. + +Two wrinkles this handles: + +* **Negated requirements.** "Do not use the old ``INIntent`` based system" names + a symbol that must be *absent*. Treating every backticked span as required + would penalise a correct answer, so the sentence around each span is checked + for a negation first. +* **Abstaining.** A question with no backticked requirements gets no score + rather than a bad one. Abstentions are excluded from the average. + +This is a starting point, not a final rubric — copy it and encode whatever +"correct" means for your own suite. +""" + +import re + +from plugin_api import Grade, RunRecord, TestRecord + +NAME = "Requirement coverage" +VERSION = "1.0.0" +DESCRIPTION = "Checks that answers use the API symbols each question asks for." +ENABLED = True + +# A backticked span is a requirement only if it looks like an identifier or a +# signature — this filters out prose asides and single punctuation marks. +_BACKTICK = re.compile(r"`([^`\n]{2,120})`") + +# Markers that flip a requirement into a prohibition. Both an auxiliary-verb +# negation ("should not use", "must never rely on", "doesn't need") and a set of +# fixed phrases, since prompts word this many different ways. +_NEGATION_RE = re.compile( + r"\b(?:do|does|did|should|shall|must|will|would|can|could|may)\s*(?:n[o']t|not|never)\b" + r"|\bn't\b", + re.IGNORECASE, +) +_NEGATION_PHRASES = ( + "avoid", + "instead of", + "rather than", + "without using", + "no longer", + "deprecated", + "stop using", + "get rid of", + "remove the", + "not the old", + "old system", +) + + +def _is_negated(sentence: str) -> bool: + lowered = sentence.lower() + return bool(_NEGATION_RE.search(lowered)) or any(p in lowered for p in _NEGATION_PHRASES) + +_CODE_VERBS = ("write", "implement", "create", "refactor", "provide", "build", "generate") + + +def _sentences(text: str): + return re.split(r"(?<=[.!?])\s+|\n{2,}", text) + + +def _requirements(prompt: str): + """Split backticked spans into (required, forbidden) symbol lists.""" + required: list[str] = [] + forbidden: list[str] = [] + + for sentence in _sentences(prompt): + negated = _is_negated(sentence) + for raw in _BACKTICK.findall(sentence): + symbol = raw.strip() + # Skip spans that are only punctuation or whitespace. + if not symbol or not any(ch.isalnum() or ch in "@\\/" for ch in symbol): + continue + bucket = forbidden if negated else required + if symbol not in bucket: + bucket.append(symbol) + + # A symbol demanded somewhere and banned elsewhere is ambiguous — drop it. + contested = set(required) & set(forbidden) + return ( + [s for s in required if s not in contested], + [s for s in forbidden if s not in contested], + ) + + +def grade(test: TestRecord): + if not test.response: + return None + + required, forbidden = _requirements(test.prompt) + wants_code = any(verb in test.prompt.lower() for verb in _CODE_VERBS) + + if not required and not forbidden and not wants_code: + return None # nothing checkable — abstain + + response = test.response + checks: list[bool] = [] + problems: list[str] = [] + + for symbol in required: + present = symbol in response + checks.append(present) + if not present: + problems.append(f"missing `{symbol}`") + + for symbol in forbidden: + absent = symbol not in response + checks.append(absent) + if not absent: + problems.append(f"used forbidden `{symbol}`") + + if wants_code: + has_block = "```" in response + checks.append(has_block) + if not has_block: + problems.append("no fenced code block") + + if not checks: + return None + + score = sum(checks) / len(checks) + met = sum(checks) + + return Grade( + score=score, + label=f"{met}/{len(checks)} checks", + notes="; ".join(problems) if problems else "All stated requirements met.", + ) + + +def report_sections(run: RunRecord): + """Call out the questions where requirements were missed.""" + misses = [] + for test in run.tests: + for entry in run.grades.get(test.index, []): + if entry.grader == NAME and entry.grade.score < 1.0: + misses.append((test, entry.grade)) + + if not misses: + return None + + lines = [ + "These questions did not use every API symbol the prompt asked for. " + "Verify by hand before treating them as failures — a model may solve the " + "problem a different but valid way.", + "", + "| # | Question | Score | Problem |", + "| --- | --- | --- | --- |", + ] + for test, grade in misses: + problem = grade.notes.replace("|", "\\|") + title = test.title.replace("|", "\\|") + lines.append(f"| {test.index} | {title} | {grade.score:.0%} | {problem} |") + + return [("## Requirement coverage", "\n".join(lines))] diff --git a/requirements.txt b/requirements.txt index e81b3ca..6f38cc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ +# Web backend +fastapi>=0.115 +uvicorn[standard]>=0.30 + # Core dependencies requests>=2.31.0 llama-cpp-python>=0.2.82 # Apple Silicon (MLX) support (only needed on macOS ARM) mlx-lm>=0.10.0; platform_system == "Darwin" and platform_machine == "arm64" - -# GUI and plotting -matplotlib>=3.0 -tkhtmlview diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..22f47c5 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,9 @@ +"""LM-Gambit web backend. + +Wraps the existing ``.core`` diagnostic engine in a FastAPI application so the +React frontend can drive it. The engine itself is untouched: providers, the +runner and the markdown reporting pipeline behave exactly as they do for the +``auto-test.py`` CLI. +""" + +__version__ = "2.0.0" diff --git a/server/api.py b/server/api.py new file mode 100644 index 0000000..6b68847 --- /dev/null +++ b/server/api.py @@ -0,0 +1,464 @@ +"""HTTP surface for the LM-Gambit frontend.""" + +from __future__ import annotations + +import asyncio +import platform +import re +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, status +from fastapi.responses import StreamingResponse + +from . import __version__ +from .core_bridge import ( + DEFAULT_PROVIDER_NAME, + DEFAULT_TEMPERATURE, + MODELS_DIR, + RESULTS_DIR, + TEMPLATE_PATH, + TESTS_DIR, + EngineLoadError, + ProviderError, + build_provider, + detect_architecture, + list_provider_names, + load_engine_class, + load_settings, + save_settings, +) +from .plugins import get_plugin_manager +from .run_manager import run_manager +from .schemas import ( + PluginSummary, + ModelListResponse, + ModelPathEntry, + ModelSummary, + PlaygroundRequest, + PlaygroundResponse, + ProviderSummary, + ReportDetail, + ReportSummary, + RunRequest, + RunResponse, + SaveSuiteRequest, + SettingsResponse, + SettingsUpdate, + SystemInfo, + TestPrompt, + TestSuiteResponse, +) +from .suite import SuiteError, list_tests, find_tests, save_suite + +router = APIRouter(prefix="/api") + +_REPORT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+\.md$") +_REPORT_TITLE_RE = re.compile(r"^#\s*Automated Diagnostic Report:\s*(.+)$", re.MULTILINE) + + +# --------------------------------------------------------------------- system + + +@router.get("/system", response_model=SystemInfo) +async def get_system() -> SystemInfo: + descriptor = detect_architecture() + runtime_name = "unavailable" + try: + runtime_cls = load_engine_class(descriptor) + runtime_name = getattr(runtime_cls, "name", runtime_cls.__name__) + except EngineLoadError as exc: + runtime_name = f"unavailable ({exc})" + + return SystemInfo( + version=__version__, + engine_architecture=descriptor.architecture, + engine_runtime=runtime_name, + template_ok=TEMPLATE_PATH.exists(), + python_version=platform.python_version(), + metrics={ + "platform": f"{platform.system()} {platform.machine()}", + "models_dir": str(MODELS_DIR), + }, + ) + + +# -------------------------------------------------------------------- plugins + + +@router.get("/plugins", response_model=List[PluginSummary]) +async def get_plugins() -> List[PluginSummary]: + """Everything discovered in plugins/, including files that failed to load.""" + return [PluginSummary(**vars(info)) for info in get_plugin_manager().info()] + + +@router.post("/plugins/reload", response_model=List[PluginSummary]) +async def reload_plugins_endpoint() -> List[PluginSummary]: + """Re-scan plugins/. Newly added HTTP routes still need a server restart.""" + if run_manager.active() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail="A run is in progress. Reload plugins once it finishes.", + ) + manager = get_plugin_manager() + manager.load() + return [PluginSummary(**vars(info)) for info in manager.info()] + + +# ------------------------------------------------------------------ providers + + +@router.get("/providers", response_model=List[ProviderSummary]) +async def get_providers() -> List[ProviderSummary]: + settings = load_settings() + preferred = str(settings.get("default_provider") or DEFAULT_PROVIDER_NAME) + return [ + ProviderSummary(name=name, is_default=name == preferred) + for name in list_provider_names() + ] + + +@router.get("/providers/{provider_name}/models", response_model=ModelListResponse) +async def get_models(provider_name: str) -> ModelListResponse: + def _load() -> List[ModelSummary]: + provider = build_provider(provider_name) + return [ + ModelSummary(id=model.id, display_name=model.display_name) + for model in provider.list_models() + ] + + try: + models = await asyncio.to_thread(_load) + except ProviderError as exc: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 - engine/hardware faults reach the UI + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"{type(exc).__name__}: {exc}", + ) from exc + + return ModelListResponse(provider=provider_name, models=models) + + +# ---------------------------------------------------------------- test suite + + +@router.get("/tests", response_model=TestSuiteResponse) +async def get_tests() -> TestSuiteResponse: + tests = [TestPrompt(**prompt) for prompt in list_tests()] + return TestSuiteResponse(tests=tests, directory=str(TESTS_DIR)) + + +@router.put("/tests", response_model=TestSuiteResponse) +async def put_tests(payload: SaveSuiteRequest) -> TestSuiteResponse: + if run_manager.active() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail="A run is in progress. Wait for it to finish before editing the suite.", + ) + try: + tests = save_suite([draft.prompt for draft in payload.tests]) + except SuiteError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return TestSuiteResponse( + tests=[TestPrompt(**prompt) for prompt in tests], + directory=str(TESTS_DIR), + ) + + +# ----------------------------------------------------------------------- runs + + +@router.post("/runs", response_model=RunResponse, status_code=status.HTTP_201_CREATED) +async def create_run(payload: RunRequest) -> RunResponse: + if not TEMPLATE_PATH.exists(): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Report template missing at .core/templates/test-block.md.", + ) + + try: + prompts = find_tests(payload.filenames) if payload.filenames else list_tests() + except SuiteError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + if not prompts: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="No questions in the suite. Add at least one in the Suite Builder.", + ) + + def _resolve_label() -> str: + provider = build_provider(payload.provider) + for model in provider.list_models(): + if model.id == payload.model_id: + return model.display_name + raise ProviderError(f"Model '{payload.model_id}' not found for {payload.provider}.") + + try: + model_label = await asyncio.to_thread(_resolve_label) + except ProviderError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + try: + run = run_manager.start( + provider_name=payload.provider, + model_id=payload.model_id, + model_label=model_label, + temperature=payload.temperature, + prompts=prompts, + loop=asyncio.get_running_loop(), + ) + except RuntimeError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return RunResponse(**run.as_dict()) + + +@router.get("/runs", response_model=List[RunResponse]) +async def get_runs() -> List[RunResponse]: + return [RunResponse(**run.as_dict()) for run in run_manager.history()] + + +@router.get("/runs/active", response_model=Optional[RunResponse]) +async def get_active_run() -> Optional[RunResponse]: + run = run_manager.active() + return RunResponse(**run.as_dict()) if run else None + + +@router.get("/runs/{run_id}", response_model=RunResponse) +async def get_run(run_id: str) -> RunResponse: + run = run_manager.get(run_id) + if run is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Unknown run id.") + return RunResponse(**run.as_dict()) + + +@router.get("/runs/{run_id}/events") +async def stream_run(run_id: str) -> StreamingResponse: + if run_manager.get(run_id) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Unknown run id.") + return StreamingResponse( + run_manager.stream(run_id), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/runs/{run_id}/cancel", response_model=RunResponse) +async def cancel_run(run_id: str) -> RunResponse: + run = run_manager.get(run_id) + if run is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Unknown run id.") + if not run_manager.cancel(run_id): + raise HTTPException( + status.HTTP_409_CONFLICT, + detail=f"Run is already {run.status}.", + ) + return RunResponse(**run.as_dict()) + + +# -------------------------------------------------------------------- reports + + +def _report_path(name: str) -> Path: + if not _REPORT_NAME_RE.match(name): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid report name.") + path = (RESULTS_DIR / name).resolve() + if path.parent != RESULTS_DIR.resolve() or not path.is_file(): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Report not found.") + return path + + +def _model_label_from(content: str, fallback: str) -> str: + match = _REPORT_TITLE_RE.search(content) + return match.group(1).strip() if match else fallback + + +@router.get("/reports", response_model=List[ReportSummary]) +async def get_reports() -> List[ReportSummary]: + if not RESULTS_DIR.exists(): + return [] + + summaries: List[ReportSummary] = [] + for path in RESULTS_DIR.glob("*.md"): + try: + stat = path.stat() + head = path.read_text(encoding="utf-8", errors="replace")[:400] + except OSError: + continue + summaries.append( + ReportSummary( + name=path.name, + model_label=_model_label_from(head, path.stem), + size_bytes=stat.st_size, + modified_at=stat.st_mtime, + ) + ) + summaries.sort(key=lambda item: item.modified_at, reverse=True) + return summaries + + +@router.get("/reports/{name}", response_model=ReportDetail) +async def get_report(name: str) -> ReportDetail: + path = _report_path(name) + try: + content = path.read_text(encoding="utf-8", errors="replace") + stat = path.stat() + except OSError as exc: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc + + return ReportDetail( + name=path.name, + model_label=_model_label_from(content, path.stem), + size_bytes=stat.st_size, + modified_at=stat.st_mtime, + content=content, + ) + + +@router.delete("/reports/{name}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_report(name: str) -> None: + path = _report_path(name) + try: + path.unlink() + except OSError as exc: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc + + +# ----------------------------------------------------------------- playground + + +@router.post("/playground", response_model=PlaygroundResponse) +async def run_playground(payload: PlaygroundRequest) -> PlaygroundResponse: + if run_manager.active() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail="A diagnostic run is in progress. The engine can only serve one at a time.", + ) + + def _execute() -> Dict[str, Any]: + provider = build_provider(payload.provider) + return provider.run_prompt( + payload.model_id, + payload.prompt, + temperature=payload.temperature, + ) + + started = time.time() + try: + result = await asyncio.to_thread(_execute) + except ProviderError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 - engine faults are user-facing here + return PlaygroundResponse( + error=f"{type(exc).__name__}: {exc}", + elapsed=round(time.time() - started, 2), + ) + + elapsed = round(time.time() - started, 2) + if "error" in result: + return PlaygroundResponse(error=str(result["error"]), elapsed=elapsed) + + return PlaygroundResponse( + response=str(result.get("response", "")), + metrics=result.get("metrics") or None, + elapsed=elapsed, + ) + + +# ------------------------------------------------------------------- settings + +_NICKNAME_KEY = "local_model_path_nicknames" + + +def _normalized_paths(settings: Dict[str, Any]) -> List[str]: + """Read model paths tolerating the legacy list-of-dicts format.""" + raw = settings.get("local_model_paths", []) + if not isinstance(raw, list): + return [] + paths: List[str] = [] + for entry in raw: + if isinstance(entry, str) and entry.strip(): + candidate = str(Path(entry).expanduser()) + elif isinstance(entry, dict) and str(entry.get("path", "")).strip(): + candidate = str(Path(str(entry["path"])).expanduser()) + else: + continue + if candidate not in paths: + paths.append(candidate) + return paths + + +def _nicknames(settings: Dict[str, Any]) -> Dict[str, str]: + """Nicknames live in their own key so the engine's path list stays strings.""" + nicknames: Dict[str, str] = {} + stored = settings.get(_NICKNAME_KEY) + if isinstance(stored, dict): + for key, value in stored.items(): + if isinstance(key, str) and isinstance(value, str): + nicknames[str(Path(key).expanduser())] = value + + # Recover nicknames from the legacy list-of-dicts shape. + raw = settings.get("local_model_paths", []) + if isinstance(raw, list): + for entry in raw: + if isinstance(entry, dict) and entry.get("path"): + key = str(Path(str(entry["path"])).expanduser()) + nicknames.setdefault(key, str(entry.get("nickname", ""))) + return nicknames + + +@router.get("/settings", response_model=SettingsResponse) +async def get_settings() -> SettingsResponse: + settings = load_settings() + nicknames = _nicknames(settings) + return SettingsResponse( + default_provider=str(settings.get("default_provider") or DEFAULT_PROVIDER_NAME), + default_temperature=float(settings.get("default_temperature") or DEFAULT_TEMPERATURE), + local_model_paths=[ + ModelPathEntry(nickname=nicknames.get(path, ""), path=path) + for path in _normalized_paths(settings) + ], + tests_dir=str(TESTS_DIR), + results_dir=str(RESULTS_DIR), + models_dir=str(MODELS_DIR), + ) + + +@router.put("/settings", response_model=SettingsResponse) +async def put_settings(payload: SettingsUpdate) -> SettingsResponse: + settings = load_settings() + + if payload.default_provider is not None: + if payload.default_provider not in list_provider_names(): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Unknown provider '{payload.default_provider}'.", + ) + settings["default_provider"] = payload.default_provider + + if payload.default_temperature is not None: + settings["default_temperature"] = payload.default_temperature + + if payload.local_model_paths is not None: + ordered: List[str] = [] + nicknames: Dict[str, str] = {} + for entry in payload.local_model_paths: + candidate = str(Path(entry.path).expanduser()) + if not candidate.strip() or candidate in ordered: + continue + ordered.append(candidate) + if entry.nickname.strip(): + nicknames[candidate] = entry.nickname.strip() + settings["local_model_paths"] = ordered + settings[_NICKNAME_KEY] = nicknames + + save_settings(settings) + return await get_settings() diff --git a/server/core_bridge.py b/server/core_bridge.py new file mode 100644 index 0000000..563bd3d --- /dev/null +++ b/server/core_bridge.py @@ -0,0 +1,103 @@ +"""Import shim for the ``.core`` engine package. + +``.core`` is a hidden directory, so it cannot be imported as a normal package. +The CLI (``auto-test.py``) works around this by putting the directory on +``sys.path`` and importing its modules flat. We do the same thing here, in one +place, so the rest of the server can just ``from .core_bridge import run_suite``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent +CORE_DIR = ROOT_DIR / ".core" + +if str(CORE_DIR) not in sys.path: + sys.path.insert(0, str(CORE_DIR)) + +# ruff: noqa: E402 (imports must follow the sys.path mutation above) +from config import ( # type: ignore[import-not-found] + DEFAULT_PROVIDER_NAME, + DEFAULT_TEMPERATURE, + MODELS_DIR, + RESULTS_DIR, + TEMPLATE_PATH, + TESTS_DIR, + ensure_directories, +) +from engine_loader import ( # type: ignore[import-not-found] + EngineLoadError, + detect_architecture, + load_engine_class, +) +from prompts import load_test_prompts # type: ignore[import-not-found] +from providers import ( # type: ignore[import-not-found] + LocalEngineProvider, + ModelInfo, + Provider, + ProviderError, + get_provider, + list_provider_names, +) +from reporting import ( # type: ignore[import-not-found] + TemplateNotFoundError, + append_sections, + finalize_report_summary, + replace_analysis_section, + sanitize_model_name, +) +from runner import TestRunError, run_suite # type: ignore[import-not-found] +from settings import ( # type: ignore[import-not-found] + get_local_model_paths, + load_settings, + save_settings, + set_local_model_paths, +) + +__all__ = [ + "CORE_DIR", + "DEFAULT_PROVIDER_NAME", + "EngineLoadError", + "detect_architecture", + "load_engine_class", + "DEFAULT_TEMPERATURE", + "MODELS_DIR", + "ModelInfo", + "Provider", + "ProviderError", + "RESULTS_DIR", + "ROOT_DIR", + "TEMPLATE_PATH", + "TESTS_DIR", + "TemplateNotFoundError", + "TestRunError", + "LocalEngineProvider", + "append_sections", + "ensure_directories", + "finalize_report_summary", + "replace_analysis_section", + "get_local_model_paths", + "get_provider", + "list_provider_names", + "load_settings", + "load_test_prompts", + "run_suite", + "sanitize_model_name", + "save_settings", + "set_local_model_paths", +] + + +def provider_kwargs(provider_name: str) -> dict: + """Build constructor kwargs for a provider, mirroring the runner's logic.""" + if provider_name == LocalEngineProvider.name: + custom_paths = [Path(p).expanduser() for p in get_local_model_paths()] + return {"search_paths": [path for path in custom_paths if path]} + return {} + + +def build_provider(provider_name: str) -> Provider: + """Instantiate a provider with the correct per-provider configuration.""" + return get_provider(provider_name, **provider_kwargs(provider_name)) diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..c70b8f1 --- /dev/null +++ b/server/main.py @@ -0,0 +1,118 @@ +"""FastAPI application factory. + +In production the same process serves the JSON API and the compiled React +bundle from ``web/dist``. During frontend development Vite serves the UI on its +own port and proxies ``/api`` back here, so CORS is opened for localhost only. +""" + +from __future__ import annotations + +from pathlib import Path + +import sys + +from fastapi import APIRouter, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from starlette.requests import Request + +from . import __version__ +from .api import router +from .core_bridge import ROOT_DIR, ensure_directories +from .plugins import get_plugin_manager + +WEB_DIST = ROOT_DIR / "web" / "dist" + +DEV_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] + + +def create_app() -> FastAPI: + ensure_directories() + + app = FastAPI( + title="LM-Gambit", + description="Automated LLM diagnostic suite.", + version=__version__, + docs_url="/api/docs", + openapi_url="/api/openapi.json", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=DEV_ORIGINS, + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(router) + _mount_plugin_routes(app) + + @app.get("/api/health") + async def health() -> dict: + return {"status": "ok", "version": __version__, "ui_built": WEB_DIST.is_dir()} + + _mount_frontend(app) + return app + + +def _mount_plugin_routes(app: FastAPI) -> None: + """Give each plugin defining register_routes its own /api/plugins/. + + Routes are bound at startup, so a plugin that adds or changes endpoints + needs a server restart — unlike its other hooks, which reload live. + """ + manager = get_plugin_manager() + for plugin in manager.with_hook("register_routes"): + plugin_router = APIRouter( + prefix=f"/api/plugins/{plugin.slug}", + tags=[f"plugin:{plugin.slug}"], + ) + try: + plugin.module.register_routes(plugin_router) # type: ignore[union-attr] + except Exception as exc: # noqa: BLE001 - never let a plugin block startup + print( + f"[plugin:{plugin.slug}] register_routes() failed: {exc}", + file=sys.stderr, + ) + continue + if plugin_router.routes: + app.include_router(plugin_router) + + +def _mount_frontend(app: FastAPI) -> None: + assets_dir = WEB_DIST / "assets" + if assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + async def spa(request: Request, full_path: str) -> object: + if full_path.startswith("api/"): + return JSONResponse({"detail": "Not found."}, status_code=404) + + index_file = WEB_DIST / "index.html" + if not index_file.is_file(): + return JSONResponse( + { + "detail": "Frontend not built.", + "fix": "Run 'npm install && npm run build' inside web/, " + "or start the Vite dev server with 'npm run dev'.", + }, + status_code=503, + ) + + # Serve real files (favicon, manifest, …) directly; everything else + # falls through to index.html so client-side routing works on reload. + if full_path: + candidate = (WEB_DIST / full_path).resolve() + if candidate.is_file() and WEB_DIST.resolve() in candidate.parents: + return FileResponse(candidate) + + return FileResponse(index_file) + + +app = create_app() diff --git a/server/plugins.py b/server/plugins.py new file mode 100644 index 0000000..806d597 --- /dev/null +++ b/server/plugins.py @@ -0,0 +1,93 @@ +"""Bridge between the server's internals and the plugin API. + +Plugins only ever see the frozen dataclasses in ``plugin_api``. This module does +the translation and owns the markdown rendering of grades, so nothing in the +plugin contract is coupled to how runs happen to be stored. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +ROOT_DIR = Path(__file__).resolve().parent.parent +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from plugin_api import Grade, GradeEntry, RunRecord, TestRecord # noqa: E402 +from plugin_system import ( # noqa: E402 + PluginManager, + collect_report_sections, + get_plugin_manager, + letter_grade, + render_grade_section, +) + +if TYPE_CHECKING: # pragma: no cover + from .run_manager import RunState, TestOutcome + +__all__ = [ + "Grade", + "GradeEntry", + "PluginManager", + "RunRecord", + "TestRecord", + "build_run_record", + "build_test_record", + "collect_report_sections", + "get_plugin_manager", + "letter_grade", + "render_grade_section", +] + + +def build_test_record( + outcome: "TestOutcome", + *, + total: int, + prompt_text: str, +) -> TestRecord: + return TestRecord( + index=outcome.index, + total=total, + title=outcome.title, + filename=outcome.filename, + prompt=prompt_text, + ok=outcome.status == "ok", + response=outcome.response, + error=outcome.error, + metrics=dict(outcome.metrics or {}), + elapsed=outcome.elapsed, + ) + + +def build_run_record( + run: "RunState", + *, + prompts_by_filename: Dict[str, str], + report_path: Optional[Path] = None, +) -> RunRecord: + tests = [ + build_test_record( + outcome, + total=run.total, + prompt_text=prompts_by_filename.get(outcome.filename, ""), + ) + for outcome in run.outcomes + ] + return RunRecord( + id=run.id, + provider=run.provider, + model_id=run.model_id, + model_label=run.model_label, + temperature=run.temperature, + total=run.total, + status=run.status, + started_at=run.started_at, + finished_at=run.finished_at, + report_path=report_path, + summary=run.summary(), + tests=tests, + grades={index: list(entries) for index, entries in run.grades.items()}, + ) diff --git a/server/run_manager.py b/server/run_manager.py new file mode 100644 index 0000000..83fd678 --- /dev/null +++ b/server/run_manager.py @@ -0,0 +1,523 @@ +"""Background execution of diagnostic runs with server-sent-event streaming. + +The engine's ``run_suite`` is synchronous and blocking, so each run executes on +a worker thread. Progress is pushed back onto the asyncio loop through +``loop.call_soon_threadsafe`` and fanned out to any connected SSE subscribers. + +Every event is also retained on the run, so a client that connects late (or +reconnects after a dropped connection) replays the full history before it +starts following the live feed. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterator, Dict, List, Optional + +from .core_bridge import ( + RESULTS_DIR, + TemplateNotFoundError, + TestRunError, + append_sections, + finalize_report_summary, + replace_analysis_section, + run_suite, + sanitize_model_name, +) +from .plugins import ( + GradeEntry, + build_run_record, + build_test_record, + collect_report_sections, + get_plugin_manager, + render_grade_section, +) + +TERMINAL_EVENTS = {"run.completed", "run.failed", "run.cancelled"} +MAX_RUN_HISTORY = 20 +KEEPALIVE_SECONDS = 15.0 + + +class RunCancelled(Exception): + """Raised inside the engine's progress callback to unwind a running suite. + + The engine has no cancellation hook of its own. Raising from the callback + stops the loop cleanly between tests -- results already written to the + report are kept and the summary is finalized with whatever completed. + """ + + +@dataclass +class TestOutcome: + index: int + title: str + filename: str + status: str + elapsed: float + response: Optional[str] = None + error: Optional[str] = None + metrics: Optional[Dict[str, Any]] = None + grades: List[Dict[str, Any]] = field(default_factory=list) + + @property + def score(self) -> Optional[float]: + if not self.grades: + return None + return sum(g["score"] for g in self.grades) / len(self.grades) + + def as_event(self, total: int) -> Dict[str, Any]: + return { + "type": "test.completed", + "index": self.index, + "total": total, + "title": self.title, + "filename": self.filename, + "status": self.status, + "elapsed": round(self.elapsed, 2), + "response": self.response, + "error": self.error, + "metrics": self.metrics, + "grades": self.grades, + "score": self.score, + } + + +@dataclass +class RunState: + id: str + provider: str + model_id: str + model_label: str + temperature: float + total: int + status: str = "running" + started_at: float = field(default_factory=time.time) + finished_at: Optional[float] = None + report_name: Optional[str] = None + error: Optional[str] = None + outcomes: List[TestOutcome] = field(default_factory=list) + events: List[Dict[str, Any]] = field(default_factory=list) + subscribers: List["asyncio.Queue[Dict[str, Any]]"] = field(default_factory=list) + cancel_event: threading.Event = field(default_factory=threading.Event) + grades: Dict[int, List[GradeEntry]] = field(default_factory=dict) + prompts_by_filename: Dict[str, str] = field(default_factory=dict) + + @property + def completed(self) -> int: + return len(self.outcomes) + + @property + def overall_score(self) -> Optional[float]: + scores = [o.score for o in self.outcomes if o.score is not None] + return sum(scores) / len(scores) if scores else None + + def summary(self) -> Dict[str, Any]: + ok = [o for o in self.outcomes if o.status == "ok" and o.metrics] + if ok: + avg_tps = sum(float(o.metrics.get("tokens_per_second", 0)) for o in ok) / len(ok) + avg_ttft = sum(float(o.metrics.get("time_to_first_token", 0)) for o in ok) / len(ok) + total_tokens = sum(int(o.metrics.get("total_tokens", 0)) for o in ok) + else: + avg_tps = avg_ttft = 0.0 + total_tokens = 0 + overall = self.overall_score + return { + "average_tokens_per_second": round(avg_tps, 2), + "average_time_to_first_token": round(avg_ttft, 2), + "total_tokens": total_tokens, + "passed": len([o for o in self.outcomes if o.status == "ok"]), + "failed": len([o for o in self.outcomes if o.status == "error"]), + "overall_score": round(overall, 4) if overall is not None else None, + "graded": len([o for o in self.outcomes if o.score is not None]), + } + + def as_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "status": self.status, + "provider": self.provider, + "model_id": self.model_id, + "model_label": self.model_label, + "temperature": self.temperature, + "total": self.total, + "completed": self.completed, + "started_at": self.started_at, + "finished_at": self.finished_at, + "report_name": self.report_name, + "error": self.error, + "summary": self.summary(), + } + + +def _format_sse(event: Dict[str, Any]) -> str: + return f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" + + +class RunManager: + """Owns the lifecycle of diagnostic runs. + + Only one run may be active at a time: the local engine loads model weights + into memory, so overlapping runs would compete for RAM and produce + meaningless throughput numbers. + """ + + def __init__(self) -> None: + self._runs: "OrderedDict[str, RunState]" = OrderedDict() + self._active_id: Optional[str] = None + self._lock = threading.Lock() + + # ------------------------------------------------------------------ query + + def get(self, run_id: str) -> Optional[RunState]: + with self._lock: + return self._runs.get(run_id) + + def active(self) -> Optional[RunState]: + with self._lock: + if self._active_id is None: + return None + return self._runs.get(self._active_id) + + def history(self) -> List[RunState]: + with self._lock: + return list(reversed(self._runs.values())) + + # ------------------------------------------------------------------ start + + def start( + self, + *, + provider_name: str, + model_id: str, + model_label: str, + temperature: float, + prompts: List[Dict[str, str]], + loop: asyncio.AbstractEventLoop, + ) -> RunState: + with self._lock: + if self._active_id is not None: + active = self._runs.get(self._active_id) + if active is not None and active.status == "running": + raise RuntimeError( + f"A run is already in progress ({active.model_label}). " + "Cancel it before starting another." + ) + + run = RunState( + id=uuid.uuid4().hex[:12], + provider=provider_name, + model_id=model_id, + model_label=model_label, + temperature=temperature, + total=len(prompts), + prompts_by_filename={ + p.get("filename", ""): p.get("prompt", "") for p in prompts + }, + ) + self._runs[run.id] = run + self._active_id = run.id + while len(self._runs) > MAX_RUN_HISTORY: + oldest, _ = next(iter(self._runs.items())) + if oldest == self._active_id: + break + self._runs.pop(oldest) + + run.events.append( + { + "type": "run.started", + "run": run.as_dict(), + "tests": [ + {"index": i, "title": p["title"], "filename": p["filename"]} + for i, p in enumerate(prompts, start=1) + ], + } + ) + + thread = threading.Thread( + target=self._worker, + args=(run, prompts, loop), + name=f"lm-gambit-run-{run.id}", + daemon=True, + ) + thread.start() + return run + + def cancel(self, run_id: str) -> bool: + run = self.get(run_id) + if run is None or run.status != "running": + return False + run.cancel_event.set() + return True + + # ----------------------------------------------------------------- worker + + def _worker( + self, + run: RunState, + prompts: List[Dict[str, str]], + loop: asyncio.AbstractEventLoop, + ) -> None: + last_tick = time.time() + + def progress_callback( + index: int, + total: int, + prompt: Dict[str, str], + result: Dict[str, Any], + ) -> None: + nonlocal last_tick + now = time.time() + elapsed = now - last_tick + last_tick = now + + if "error" in result: + outcome = TestOutcome( + index=index, + title=prompt.get("title", f"Test {index}"), + filename=prompt.get("filename", f"test{index}"), + status="error", + elapsed=elapsed, + error=str(result["error"]), + ) + else: + metrics = result.get("metrics") or {} + outcome = TestOutcome( + index=index, + title=prompt.get("title", f"Test {index}"), + filename=prompt.get("filename", f"test{index}"), + status="ok", + elapsed=elapsed, + response=str(result.get("response", "")), + metrics=dict(metrics), + ) + + run.outcomes.append(outcome) + self._grade(run, outcome) + + # Plugins observe the question after grading, so on_test_complete + # sees the same record the report will. + record = build_test_record( + outcome, + total=run.total, + prompt_text=run.prompts_by_filename.get(outcome.filename, ""), + ) + get_plugin_manager().emit("on_test_complete", record) + + self._publish(loop, run, outcome.as_event(total)) + + if run.cancel_event.is_set(): + raise RunCancelled + + get_plugin_manager().emit( + "on_run_start", + build_run_record(run, prompts_by_filename=run.prompts_by_filename), + ) + + try: + report_path = run_suite( + provider_name=run.provider, + model_id=run.model_id, + temperature=run.temperature, + progress_callback=progress_callback, + prompts=prompts, + ) + except RunCancelled: + self._finalize_partial_report(run) + run.status = "cancelled" + run.finished_at = time.time() + self._apply_plugin_report(run) + self._publish( + loop, + run, + {"type": "run.cancelled", "run": run.as_dict()}, + ) + except (TestRunError, TemplateNotFoundError) as exc: + run.status = "failed" + run.error = str(exc) + run.finished_at = time.time() + self._publish( + loop, + run, + {"type": "run.failed", "message": str(exc), "run": run.as_dict()}, + ) + except Exception as exc: # noqa: BLE001 - surface engine faults to the UI + run.status = "failed" + run.error = f"{type(exc).__name__}: {exc}" + run.finished_at = time.time() + self._publish( + loop, + run, + {"type": "run.failed", "message": run.error, "run": run.as_dict()}, + ) + else: + run.status = "completed" + run.report_name = report_path.name + run.finished_at = time.time() + self._apply_plugin_report(run) + self._publish( + loop, + run, + {"type": "run.completed", "run": run.as_dict()}, + ) + finally: + with self._lock: + if self._active_id == run.id: + self._active_id = None + get_plugin_manager().emit("on_run_complete", self._record(run)) + + # ---------------------------------------------------------------- plugins + + def _report_path(self, run: RunState) -> Path: + return RESULTS_DIR / f"automated_report_{sanitize_model_name(run.model_label)}.md" + + def _record(self, run: RunState): + path = self._report_path(run) + return build_run_record( + run, + prompts_by_filename=run.prompts_by_filename, + report_path=path if path.exists() else None, + ) + + def _grade(self, run: RunState, outcome: TestOutcome) -> None: + """Run grader plugins over one answer and attach the results. + + Graders run on this worker thread, so a slow grader delays the next + question. That is deliberate: grading is part of the run, and the + report should not be finalized before it lands. + + Questions the provider failed are never graded — there is no answer to + judge, and scoring them would let one careless plugin drag down the + overall score. Plugins that want to react to failures use + ``on_test_complete``, which fires for every question. + """ + if outcome.status != "ok": + return + + record = build_test_record( + outcome, + total=run.total, + prompt_text=run.prompts_by_filename.get(outcome.filename, ""), + ) + graded = get_plugin_manager().grade(record) + if not graded: + return + + run.grades[outcome.index] = [ + GradeEntry(grader=name, grade=grade) for name, grade in graded + ] + outcome.grades = [ + { + "grader": name, + "score": grade.score, + "label": grade.label, + "notes": grade.notes, + } + for name, grade in graded + ] + + def _apply_plugin_report(self, run: RunState) -> None: + """Write grades and plugin sections into the finished report.""" + report_path = self._report_path(run) + if not report_path.exists(): + return + + record = self._record(run) + manager = get_plugin_manager() + + grade_markdown = render_grade_section(record) + if grade_markdown: + try: + replace_analysis_section(report_path, grade_markdown) + except OSError: + pass + + try: + sections = collect_report_sections(record, manager) + except Exception: # noqa: BLE001 - a plugin fault must not fail the run + sections = [] + + if sections: + try: + append_sections(report_path, sections) + except OSError: + pass + + def _finalize_partial_report(self, run: RunState) -> None: + """Write the summary block for a run that stopped early.""" + report_path = RESULTS_DIR / f"automated_report_{sanitize_model_name(run.model_label)}.md" + if not report_path.exists(): + return + results: List[Dict[str, Any]] = [] + for outcome in run.outcomes: + if outcome.status == "ok" and outcome.metrics: + results.append({"response": outcome.response, "metrics": outcome.metrics}) + else: + results.append({"error": outcome.error or "cancelled"}) + try: + finalize_report_summary(report_path, results) + except (OSError, KeyError): + return + run.report_name = report_path.name + + # -------------------------------------------------------------- streaming + + def _publish( + self, + loop: asyncio.AbstractEventLoop, + run: RunState, + event: Dict[str, Any], + ) -> None: + """Hand an event from the worker thread to the event loop.""" + try: + loop.call_soon_threadsafe(self._emit, run, event) + except RuntimeError: + # Loop already closed (server shutting down); keep the history only. + run.events.append(event) + + @staticmethod + def _emit(run: RunState, event: Dict[str, Any]) -> None: + run.events.append(event) + for queue in list(run.subscribers): + queue.put_nowait(event) + + async def stream(self, run_id: str) -> AsyncIterator[str]: + run = self.get(run_id) + if run is None: + yield _format_sse({"type": "run.failed", "message": "Unknown run id."}) + return + + queue: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue() + # Subscribing and snapshotting in the same synchronous block guarantees + # no event is delivered twice or dropped between the two. + run.subscribers.append(queue) + history = list(run.events) + + try: + for event in history: + yield _format_sse(event) + if event["type"] in TERMINAL_EVENTS: + return + + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_SECONDS) + except asyncio.TimeoutError: + yield ": keepalive\n\n" + continue + + yield _format_sse(event) + if event["type"] in TERMINAL_EVENTS: + return + finally: + if queue in run.subscribers: + run.subscribers.remove(queue) + + +run_manager = RunManager() diff --git a/server/schemas.py b/server/schemas.py new file mode 100644 index 0000000..0d2f2d2 --- /dev/null +++ b/server/schemas.py @@ -0,0 +1,157 @@ +"""Pydantic request/response models for the LM-Gambit API.""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class ProviderSummary(BaseModel): + name: str + is_default: bool = False + + +class ModelSummary(BaseModel): + id: str + display_name: str + + +class ModelListResponse(BaseModel): + provider: str + models: List[ModelSummary] + + +class TestPrompt(BaseModel): + """A single question in the suite. + + ``title`` is derived from the first non-empty line of ``prompt`` by the + engine, so it is read-only here and returned for display purposes only. + """ + + filename: str + title: str + prompt: str + + +class TestSuiteResponse(BaseModel): + tests: List[TestPrompt] + directory: str + + +class TestDraft(BaseModel): + """One question as submitted by the suite builder form.""" + + prompt: str = Field(min_length=1) + + +class SaveSuiteRequest(BaseModel): + tests: List[TestDraft] + + +class RunMetrics(BaseModel): + tokens_per_second: float = 0.0 + total_tokens: int = 0 + time_to_first_token: float = 0.0 + stop_reason: str = "N/A" + + +class RunRequest(BaseModel): + provider: str + model_id: str + temperature: float = Field(default=0.1, ge=0.0, le=2.0) + filenames: Optional[List[str]] = Field( + default=None, + description="Subset of test filenames to run. Omit to run the whole suite.", + ) + + +class RunSummary(BaseModel): + average_tokens_per_second: float = 0.0 + average_time_to_first_token: float = 0.0 + total_tokens: int = 0 + passed: int = 0 + failed: int = 0 + overall_score: Optional[float] = None + graded: int = 0 + + +class PluginSummary(BaseModel): + name: str + slug: str + version: str + description: str + path: str + enabled: bool + hooks: List[str] = [] + error: Optional[str] = None + + +class RunResponse(BaseModel): + id: str + status: str + provider: str + model_id: str + model_label: str + temperature: float + total: int + completed: int + started_at: float + finished_at: Optional[float] = None + report_name: Optional[str] = None + error: Optional[str] = None + summary: RunSummary = RunSummary() + + +class PlaygroundRequest(BaseModel): + provider: str + model_id: str + prompt: str = Field(min_length=1) + temperature: float = Field(default=0.1, ge=0.0, le=2.0) + + +class PlaygroundResponse(BaseModel): + response: Optional[str] = None + error: Optional[str] = None + metrics: Optional[RunMetrics] = None + elapsed: float = 0.0 + + +class ReportSummary(BaseModel): + name: str + model_label: str + size_bytes: int + modified_at: float + + +class ReportDetail(ReportSummary): + content: str + + +class ModelPathEntry(BaseModel): + nickname: str = "" + path: str + + +class SettingsResponse(BaseModel): + default_provider: str + default_temperature: float + local_model_paths: List[ModelPathEntry] + tests_dir: str + results_dir: str + models_dir: str + + +class SettingsUpdate(BaseModel): + default_provider: Optional[str] = None + default_temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0) + local_model_paths: Optional[List[ModelPathEntry]] = None + + +class SystemInfo(BaseModel): + version: str + engine_architecture: str + engine_runtime: str + template_ok: bool + python_version: str + metrics: Dict[str, str] = {} diff --git a/server/suite.py b/server/suite.py new file mode 100644 index 0000000..d8cd0bd --- /dev/null +++ b/server/suite.py @@ -0,0 +1,73 @@ +"""Reading and writing the prompt suite in ``tests/``. + +The engine treats every ``tests/*.txt`` file as one prompt and derives its title +from the first non-empty line, ordering files by a natural sort of their names. +To keep the web editor and the ``auto-test.py`` CLI in perfect agreement, saving +rewrites the directory as ``test1.txt`` … ``testN.txt`` in the order shown in +the builder. That makes add / remove / reorder unambiguous for both entrypoints. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List + +from .core_bridge import TESTS_DIR, load_test_prompts + + +class SuiteError(Exception): + """Raised when the suite on disk cannot be read or written.""" + + +def derive_title(prompt: str, fallback: str) -> str: + for line in prompt.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return fallback + + +def list_tests() -> List[Dict[str, str]]: + """Return the suite exactly as the engine sees it.""" + TESTS_DIR.mkdir(parents=True, exist_ok=True) + return load_test_prompts() + + +def find_tests(filenames: List[str]) -> List[Dict[str, str]]: + """Return the prompts matching ``filenames``, preserving suite order.""" + wanted = set(filenames) + selected = [prompt for prompt in list_tests() if prompt["filename"] in wanted] + missing = wanted - {prompt["filename"] for prompt in selected} + if missing: + raise SuiteError(f"Unknown test file(s): {', '.join(sorted(missing))}") + return selected + + +def save_suite(prompts: List[str]) -> List[Dict[str, str]]: + """Replace the suite with ``prompts`` and return the resulting tests.""" + cleaned = [text.strip() for text in prompts] + for position, text in enumerate(cleaned, start=1): + if not text: + raise SuiteError(f"Question {position} is empty. Remove it or add a prompt.") + + TESTS_DIR.mkdir(parents=True, exist_ok=True) + + target_names = {f"test{index}.txt" for index in range(1, len(cleaned) + 1)} + written: List[Path] = [] + + for index, text in enumerate(cleaned, start=1): + path = TESTS_DIR / f"test{index}.txt" + try: + path.write_text(text + "\n", encoding="utf-8") + except OSError as exc: + raise SuiteError(f"Could not write {path.name}: {exc}") from exc + written.append(path) + + for stale in TESTS_DIR.glob("*.txt"): + if stale.name not in target_names: + try: + stale.unlink() + except OSError: + continue + + return list_tests() diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..041c9e0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + LM-Gambit + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..bc5a0c2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3519 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "lucide-react": "^1.27.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.76.0.tgz", + "integrity": "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.76.0.tgz", + "integrity": "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.76.0.tgz", + "integrity": "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.76.0.tgz", + "integrity": "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.76.0.tgz", + "integrity": "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.76.0.tgz", + "integrity": "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.76.0.tgz", + "integrity": "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.76.0.tgz", + "integrity": "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.76.0.tgz", + "integrity": "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.76.0.tgz", + "integrity": "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.76.0.tgz", + "integrity": "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.76.0.tgz", + "integrity": "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.76.0.tgz", + "integrity": "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.76.0.tgz", + "integrity": "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.76.0.tgz", + "integrity": "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.76.0.tgz", + "integrity": "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.76.0.tgz", + "integrity": "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.76.0.tgz", + "integrity": "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.76.0.tgz", + "integrity": "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lucide-react": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.76.0.tgz", + "integrity": "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.76.0", + "@oxlint/binding-android-arm64": "1.76.0", + "@oxlint/binding-darwin-arm64": "1.76.0", + "@oxlint/binding-darwin-x64": "1.76.0", + "@oxlint/binding-freebsd-x64": "1.76.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", + "@oxlint/binding-linux-arm-musleabihf": "1.76.0", + "@oxlint/binding-linux-arm64-gnu": "1.76.0", + "@oxlint/binding-linux-arm64-musl": "1.76.0", + "@oxlint/binding-linux-ppc64-gnu": "1.76.0", + "@oxlint/binding-linux-riscv64-gnu": "1.76.0", + "@oxlint/binding-linux-riscv64-musl": "1.76.0", + "@oxlint/binding-linux-s390x-gnu": "1.76.0", + "@oxlint/binding-linux-x64-gnu": "1.76.0", + "@oxlint/binding-linux-x64-musl": "1.76.0", + "@oxlint/binding-openharmony-arm64": "1.76.0", + "@oxlint/binding-win32-arm64-msvc": "1.76.0", + "@oxlint/binding-win32-ia32-msvc": "1.76.0", + "@oxlint/binding-win32-x64-msvc": "1.76.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9b6e671 --- /dev/null +++ b/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "lucide-react": "^1.27.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..93c66d1 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0cff4c0 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,24 @@ +import { Shell } from './components/Shell' +import { useRouter } from './lib/router' +import { useRunFeed } from './hooks/useRunFeed' +import { RunPage } from './pages/RunPage' +import { SuitePage } from './pages/SuitePage' +import { ReportsPage } from './pages/ReportsPage' +import { PlaygroundPage } from './pages/PlaygroundPage' +import { SettingsPage } from './pages/SettingsPage' +import { NotFoundPage } from './pages/NotFoundPage' + +export function App() { + const { path } = useRouter() + const { run, isLive } = useRunFeed() + + let page + if (path === '/') page = + else if (path === '/suite') page = + else if (path.startsWith('/reports')) page = + else if (path === '/playground') page = + else if (path === '/settings') page = + else page = + + return {page} +} diff --git a/web/src/components/Logo.tsx b/web/src/components/Logo.tsx new file mode 100644 index 0000000..96c860c --- /dev/null +++ b/web/src/components/Logo.tsx @@ -0,0 +1,45 @@ +/** LM-Gambit brand mark — a chess pawn cut from a warm gradient. */ + +import { useId } from 'react' + +export function Logo({ size = 30 }: { size?: number }) { + // The mark renders more than once per page (rail + mobile bar), so the + // gradient needs a unique id or the second instance resolves to the first. + const gradientId = useId() + + return ( + + ) +} diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx new file mode 100644 index 0000000..2d065fc --- /dev/null +++ b/web/src/components/Markdown.tsx @@ -0,0 +1,25 @@ +import { memo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeHighlight from 'rehype-highlight' +import { cx } from '../lib/format' + +/** Renders model output / reports. Raw HTML is never enabled. */ +export const Markdown = memo(function Markdown({ + children, + className, +}: { + children: string + className?: string +}) { + return ( +
+ + {children} + +
+ ) +}) diff --git a/web/src/components/ScoreBadge.tsx b/web/src/components/ScoreBadge.tsx new file mode 100644 index 0000000..2a77b57 --- /dev/null +++ b/web/src/components/ScoreBadge.tsx @@ -0,0 +1,52 @@ +/** + * A plugin-assigned score. + * + * Scores come from user-written graders, so they carry a deliberately neutral + * visual weight — mint/gold/rose signal the band, never "this answer is right". + */ + +import { cx } from '../lib/format' + +export function scoreTone(score: number): 'mint' | 'gold' | 'rose' { + if (score >= 0.8) return 'mint' + if (score >= 0.5) return 'gold' + return 'rose' +} + +export function letterGrade(score: number): string { + if (score >= 0.97) return 'A+' + if (score >= 0.93) return 'A' + if (score >= 0.9) return 'A-' + if (score >= 0.87) return 'B+' + if (score >= 0.83) return 'B' + if (score >= 0.8) return 'B-' + if (score >= 0.77) return 'C+' + if (score >= 0.73) return 'C' + if (score >= 0.7) return 'C-' + if (score >= 0.67) return 'D+' + if (score >= 0.6) return 'D' + return 'F' +} + +export function ScoreBadge({ + score, + title, + showLetter, +}: { + score: number + title?: string + showLetter?: boolean +}) { + const tone = scoreTone(score) + const toneClass = { + mint: 'chip-mint', + gold: 'chip-gold', + rose: 'chip-rose', + }[tone] + + return ( + + {Math.round(score * 100)}%{showLetter && ` · ${letterGrade(score)}`} + + ) +} diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx new file mode 100644 index 0000000..279aace --- /dev/null +++ b/web/src/components/Shell.tsx @@ -0,0 +1,157 @@ +/** App chrome: brand rail, navigation, engine status and the live-run beacon. */ + +import { useEffect, useState, type ReactNode } from 'react' +import { + Cpu, + FileText, + FlaskConical, + Gauge, + ListChecks, + Menu, + Settings2, + X, +} from 'lucide-react' +import { Logo } from './Logo' +import { Link, useRouter } from '../lib/router' +import { api, type Run, type SystemInfo } from '../lib/api' +import { cx } from '../lib/format' + +const NAV = [ + { to: '/', label: 'Run', icon: Gauge, hint: 'Execute the diagnostic suite' }, + { to: '/suite', label: 'Suite', icon: ListChecks, hint: 'Author the questions' }, + { to: '/reports', label: 'Reports', icon: FileText, hint: 'Read past results' }, + { to: '/playground', label: 'Playground', icon: FlaskConical, hint: 'Try a single prompt' }, + { to: '/settings', label: 'Settings', icon: Settings2, hint: 'Paths and defaults' }, +] + +function isActive(path: string, to: string) { + return to === '/' ? path === '/' : path.startsWith(to) +} + +export function Shell({ children, activeRun }: { children: ReactNode; activeRun: Run | null }) { + const { path } = useRouter() + const [system, setSystem] = useState(null) + const [drawerOpen, setDrawerOpen] = useState(false) + + useEffect(() => { + api.system().then(setSystem).catch(() => setSystem(null)) + }, []) + + useEffect(() => { + setDrawerOpen(false) + }, [path]) + + const nav = ( + + ) + + const engineChip = system && ( +
+
+ + Engine +
+
+ {system.engine_runtime} +
+
{system.engine_architecture}
+ {!system.template_ok && ( +
Report template missing
+ )} +
+ ) + + const sidebarBody = ( + <> + + +
+
+ LM-Gambit +
+
+ Diagnostic suite{system ? ` · v${system.version}` : ''} +
+
+ + {nav} +
{engineChip}
+ + ) + + return ( +
+ {/* desktop rail */} + + + {/* mobile drawer */} + {drawerOpen && ( +
+
setDrawerOpen(false)} + /> + +
+ )} + +
+
+ + + LM-Gambit +
+ +
+ {children} +
+
+
+ ) +} diff --git a/web/src/components/ThroughputChart.tsx b/web/src/components/ThroughputChart.tsx new file mode 100644 index 0000000..088b9fb --- /dev/null +++ b/web/src/components/ThroughputChart.tsx @@ -0,0 +1,227 @@ +/** + * Per-question throughput, as a horizontal bar chart. + * + * One measure, one series: magnitude is carried by bar length in a single hue + * (#b8892f — validated in-band and above 3:1 against the navy chart surface), + * so there is no legend to restate the title. Questions that errored have no + * throughput; they are listed under the plot with an icon and the word "Error" + * rather than being coloured into the series. + */ + +import { useId, useState } from 'react' +import { TriangleAlert } from 'lucide-react' +import type { ParsedTest } from '../lib/report' + +const BAR_COLOR = '#b8892f' +const BAR_HEIGHT = 18 // ≤ 24px mark +const ROW_GAP = 10 // ≥ 2px surface gap between adjacent bars +const LEFT = 34 +const RIGHT = 56 +const TOP = 22 +const BOTTOM = 26 + +type Measure = 'tokensPerSecond' | 'timeToFirstToken' + +const MEASURES: Record = { + tokensPerSecond: { label: 'Throughput', unit: 'tok/s', decimals: 1 }, + timeToFirstToken: { label: 'Time to first token', unit: 's', decimals: 2 }, +} + +/** Round up to a clean axis maximum without leaving the plot mostly empty. */ +function niceMax(value: number): number { + if (value <= 0) return 1 + const magnitude = 10 ** Math.floor(Math.log10(value)) + const normalized = value / magnitude + const step = [1, 1.2, 1.6, 2, 2.5, 3, 4, 5, 6, 8, 10].find((s) => normalized <= s) ?? 10 + return step * magnitude +} + +export function ThroughputChart({ tests }: { tests: ParsedTest[] }) { + const clipId = useId() + const [measure, setMeasure] = useState('tokensPerSecond') + const [hovered, setHovered] = useState(null) + + const scored = tests.filter((test) => test.ok && test[measure] != null) + const errored = tests.filter((test) => !test.ok) + + if (scored.length === 0) { + return ( +

+ No successful questions in this report, so there is nothing to plot. +

+ ) + } + + const config = MEASURES[measure] + const max = niceMax(Math.max(...scored.map((test) => test[measure] as number))) + const rowHeight = BAR_HEIGHT + ROW_GAP + const plotWidth = 660 + const height = TOP + scored.length * rowHeight + BOTTOM + const barArea = plotWidth - LEFT - RIGHT + const ticks = [0, 0.25, 0.5, 0.75, 1].map((fraction) => fraction * max) + + return ( +
+
+

+ {config.label} by question{' '} + ({config.unit}) +

+
+ {(Object.keys(MEASURES) as Measure[]).map((key) => ( + + ))} +
+
+ +
+ + + {/* Square at the baseline, 4px rounded at the data end. */} + + + + + + {/* recessive hairline gridlines */} + {ticks.map((tick) => { + const x = LEFT + (tick / max) * barArea + return ( + + + + {tick.toFixed(config.decimals === 2 && max < 5 ? 1 : 0)} + + + ) + })} + + {scored.map((test, row) => { + const value = test[measure] as number + const y = TOP + row * rowHeight + const width = Math.max((value / max) * barArea, 2) + const active = hovered === test.index + + return ( + setHovered(test.index)} + onMouseLeave={() => setHovered(null)} + > + {/* hit target larger than the mark */} + + + {String(test.index).padStart(2, '0')} + + + {/* square off the baseline end */} + + + {value.toFixed(config.decimals)} + + + ) + })} + + {/* baseline */} + + +
+ + {hovered != null && ( +
+ + {scored.find((test) => test.index === hovered)?.title} + + + {(scored.find((test) => test.index === hovered)?.[measure] ?? 0).toFixed( + config.decimals, + )}{' '} + {config.unit} + +
+ )} + + {errored.length > 0 && ( +
+

+ Not plotted — no measurement +

+
    + {errored.map((test) => ( +
  • + + + + {String(test.index).padStart(2, '0')} + + {test.title} + Error + +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx new file mode 100644 index 0000000..331568c --- /dev/null +++ b/web/src/components/ui.tsx @@ -0,0 +1,368 @@ +/** Shared presentational primitives. */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react' +import { cx } from '../lib/format' + +/* ------------------------------------------------------------------ layout */ + +export function PageHeader({ + title, + subtitle, + actions, +}: { + title: string + subtitle?: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ ) +} + +export function Card({ + children, + className, + raised, +}: { + children: ReactNode + className?: string + raised?: boolean +}) { + return
{children}
+} + +export function CardHeader({ + title, + icon, + actions, + hint, +}: { + title: string + icon?: ReactNode + actions?: ReactNode + hint?: string +}) { + return ( +
+
+

+ {icon && {icon}} + {title} +

+ {hint &&

{hint}

} +
+ {actions &&
{actions}
} +
+ ) +} + +/* ------------------------------------------------------------------- stats */ + +export function StatTile({ + label, + value, + unit, + tone = 'gold', + hint, +}: { + label: string + value: string | number + unit?: string + tone?: 'gold' | 'ember' | 'mint' | 'rose' | 'neutral' + hint?: string +}) { + const toneClass = { + gold: 'text-gold-400', + ember: 'text-ember-400', + mint: 'text-mint-400', + rose: 'text-rose-400', + neutral: 'text-ink-200', + }[tone] + + return ( +
+
+ {label} +
+
+ {value} + {unit && {unit}} +
+ {hint &&
{hint}
} +
+ ) +} + +/* ------------------------------------------------------------------ states */ + +export function Spinner({ className }: { className?: string }) { + return +} + +export function EmptyState({ + icon, + title, + description, + action, +}: { + icon: ReactNode + title: string + description?: string + action?: ReactNode +}) { + return ( +
+
+ {icon} +
+

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ) +} + +export function ErrorNote({ message, onRetry }: { message: string; onRetry?: () => void }) { + return ( +
+ +
+

{message}

+ {onRetry && ( + + )} +
+
+ ) +} + +export function SkeletonRows({ rows = 3 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, index) => ( +
+
+
+ ))} +
+ ) +} + +/* ------------------------------------------------------------------ toasts */ + +type ToastTone = 'success' | 'error' | 'info' +interface Toast { + id: number + tone: ToastTone + message: string +} + +const ToastContext = createContext<(message: string, tone?: ToastTone) => void>(() => {}) + +export function useToast() { + return useContext(ToastContext) +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const nextId = useRef(1) + + const push = useCallback((message: string, tone: ToastTone = 'info') => { + const id = nextId.current++ + setToasts((current) => [...current, { id, tone, message }]) + setTimeout(() => setToasts((current) => current.filter((t) => t.id !== id)), 5200) + }, []) + + const dismiss = (id: number) => setToasts((current) => current.filter((t) => t.id !== id)) + + return ( + + {children} +
+ {toasts.map((toast) => { + const Icon = { success: CheckCircle2, error: AlertTriangle, info: Info }[toast.tone] + const tone = { + success: 'border-mint-500/45 text-mint-400', + error: 'border-rose-500/45 text-rose-400', + info: 'border-navy-600 text-ink-300', + }[toast.tone] + return ( +
+ +

+ {toast.message} +

+ +
+ ) + })} +
+
+ ) +} + +/* ------------------------------------------------------------------- modal */ + +export function ConfirmDialog({ + open, + title, + body, + confirmLabel = 'Confirm', + destructive, + onConfirm, + onCancel, +}: { + open: boolean + title: string + body: string + confirmLabel?: string + destructive?: boolean + onConfirm: () => void + onCancel: () => void +}) { + useEffect(() => { + if (!open) return + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onCancel() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onCancel]) + + if (!open) return null + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + > +

{title}

+

{body}

+
+ + +
+
+
+ ) +} + +/* ------------------------------------------------------------------ slider */ + +export function TemperatureSlider({ + value, + onChange, + disabled, +}: { + value: number + onChange: (next: number) => void + disabled?: boolean +}) { + const percent = Math.min(100, Math.max(0, (value / 1) * 100)) + const descriptor = value <= 0.15 ? 'Deterministic' : value <= 0.5 ? 'Balanced' : 'Creative' + + return ( +
+
+ Temperature + {value.toFixed(2)} +
+ onChange(Number(event.target.value))} + className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-navy-950 [&::-webkit-slider-thumb]:bg-gold-400 [&::-webkit-slider-thumb]:shadow-[0_0_0_3px_rgba(227,173,70,0.22)]" + style={{ + background: `linear-gradient(90deg, var(--color-gold-500) 0%, var(--color-gold-500) ${percent}%, var(--color-navy-700) ${percent}%, var(--color-navy-700) 100%)`, + }} + /> +
{descriptor}
+
+ ) +} + +/* -------------------------------------------------------------- data hooks */ + +export function useAsync(loader: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [nonce, setNonce] = useState(0) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + loader() + .then((value) => { + if (!cancelled) setData(value) + }) + .catch((cause: unknown) => { + if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, nonce]) + + const reload = useCallback(() => setNonce((n) => n + 1), []) + return useMemo( + () => ({ data, error, loading, reload, setData }), + [data, error, loading, reload], + ) +} diff --git a/web/src/hooks/useRunFeed.tsx b/web/src/hooks/useRunFeed.tsx new file mode 100644 index 0000000..f71f9da --- /dev/null +++ b/web/src/hooks/useRunFeed.tsx @@ -0,0 +1,211 @@ +/** + * Owns the live state of a diagnostic run. + * + * Lives above the router so a run keeps streaming while the user browses other + * views, and re-attaches to an in-flight run after a page reload by asking the + * server what is currently active. + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { + api, + subscribeToRun, + type Run, + type RunEvent, + type TestCompletedEvent, +} from '../lib/api' + +export interface LogLine { + id: number + at: number + tone: 'info' | 'ok' | 'error' | 'warn' + message: string +} + +export interface PlannedTest { + index: number + title: string + filename: string +} + +interface RunFeedValue { + run: Run | null + plan: PlannedTest[] + outcomes: TestCompletedEvent[] + logs: LogLine[] + starting: boolean + isLive: boolean + start: (input: { + provider: string + model_id: string + temperature: number + filenames?: string[] + }) => Promise + cancel: () => Promise + clear: () => void + attach: (runId: string) => void +} + +const RunFeedContext = createContext(null) + +export function useRunFeed() { + const value = useContext(RunFeedContext) + if (!value) throw new Error('useRunFeed must be used inside ') + return value +} + +export function RunFeedProvider({ children }: { children: ReactNode }) { + const [run, setRun] = useState(null) + const [plan, setPlan] = useState([]) + const [outcomes, setOutcomes] = useState([]) + const [logs, setLogs] = useState([]) + const [starting, setStarting] = useState(false) + + const unsubscribe = useRef<(() => void) | null>(null) + const logId = useRef(1) + + const log = useCallback((message: string, tone: LogLine['tone'] = 'info') => { + setLogs((current) => [ + ...current.slice(-299), + { id: logId.current++, at: Date.now(), tone, message }, + ]) + }, []) + + const handleEvent = useCallback( + (event: RunEvent) => { + switch (event.type) { + case 'run.started': + setRun(event.run) + setPlan(event.tests) + log( + `Run started · ${event.run.model_label} · ${event.run.total} question${ + event.run.total === 1 ? '' : 's' + } · T=${event.run.temperature}`, + ) + break + + case 'test.completed': + setOutcomes((current) => + current.some((o) => o.index === event.index) ? current : [...current, event], + ) + setRun((current) => (current ? { ...current, completed: event.index } : current)) + log( + event.status === 'ok' + ? `[${event.index}/${event.total}] ${event.title} — ${ + event.metrics?.tokens_per_second ?? 0 + } tok/s in ${event.elapsed}s` + : `[${event.index}/${event.total}] ${event.title} — FAILED: ${event.error}`, + event.status === 'ok' ? 'ok' : 'error', + ) + break + + case 'run.completed': + setRun(event.run) + log(`Run complete · report saved as ${event.run.report_name}`, 'ok') + break + + case 'run.cancelled': + setRun(event.run) + log('Run cancelled. Partial report was saved.', 'warn') + break + + case 'run.failed': + setRun(event.run) + log(`Run failed: ${event.message ?? event.run.error ?? 'unknown error'}`, 'error') + break + } + }, + [log], + ) + + const attach = useCallback( + (runId: string) => { + unsubscribe.current?.() + unsubscribe.current = subscribeToRun(runId, handleEvent) + }, + [handleEvent], + ) + + // Re-attach to whatever the server is running when the app loads. + useEffect(() => { + let cancelled = false + api + .activeRun() + .then((active) => { + if (cancelled || !active) return + setRun(active) + setOutcomes([]) + setLogs([]) + attach(active.id) + }) + .catch(() => undefined) + return () => { + cancelled = true + unsubscribe.current?.() + } + }, [attach]) + + const start = useCallback( + async (input: { + provider: string + model_id: string + temperature: number + filenames?: string[] + }) => { + setStarting(true) + try { + setOutcomes([]) + setPlan([]) + setLogs([]) + const created = await api.startRun(input) + setRun(created) + attach(created.id) + } finally { + setStarting(false) + } + }, + [attach], + ) + + const cancel = useCallback(async () => { + if (!run) return + log('Cancelling after the current question finishes…', 'warn') + await api.cancelRun(run.id) + }, [run, log]) + + const clear = useCallback(() => { + unsubscribe.current?.() + unsubscribe.current = null + setRun(null) + setPlan([]) + setOutcomes([]) + setLogs([]) + }, []) + + const value = useMemo( + () => ({ + run, + plan, + outcomes, + logs, + starting, + isLive: run?.status === 'running', + start, + cancel, + clear, + attach, + }), + [run, plan, outcomes, logs, starting, start, cancel, clear, attach], + ) + + return {children} +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..2ae34f6 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,596 @@ +@import 'tailwindcss'; + +/* ============================================================================ + LM-Gambit design tokens + A muted navy field with orange as the action colour and gold reserved for + measurement — metrics, scores and anything the eye should read as "data". + ========================================================================== */ + +@theme { + /* Navy field, deepest to lightest */ + --color-navy-950: #080e19; + --color-navy-900: #0b1322; + --color-navy-850: #101a2c; + --color-navy-800: #142138; + --color-navy-750: #1a2a45; + --color-navy-700: #223353; + --color-navy-600: #2e4467; + --color-navy-500: #3d587f; + + /* Ink */ + --color-ink-100: #eaf0fb; + --color-ink-200: #c8d5ea; + --color-ink-300: #9aaecb; + --color-ink-400: #7288ab; + --color-ink-500: #55688a; + + /* Orange — actions, the running state, primary emphasis */ + --color-ember-300: #ffb083; + --color-ember-400: #ff9a5c; + --color-ember-500: #f4813f; + --color-ember-600: #dd6926; + --color-ember-700: #b3521c; + + /* Gold — metrics, highlights, selected state */ + --color-gold-300: #f7d68f; + --color-gold-400: #f0c469; + --color-gold-500: #e3ad46; + --color-gold-600: #c48f31; + --color-gold-700: #966c23; + + /* Semantic */ + --color-mint-400: #4ecfa6; + --color-mint-500: #2fb98d; + --color-rose-400: #f0736e; + --color-rose-500: #dc5450; + --color-sky-400: #62a9e8; + + --font-sans: + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Inter, Roboto, system-ui, + sans-serif; + --font-mono: + 'SF Mono', ui-monospace, SFMono-Regular, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, + monospace; + + --radius-card: 14px; + + --animate-fade-up: fade-up 0.32s cubic-bezier(0.22, 1, 0.36, 1) both; + --animate-fade-in: fade-in 0.2s ease-out both; + --animate-sweep: sweep 1.6s ease-in-out infinite; +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes sweep { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(340%); + } +} + +/* ========================================================================== */ + +@layer base { + * { + border-color: var(--color-navy-700); + } + + html { + color-scheme: dark; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + margin: 0; + min-height: 100vh; + background-color: var(--color-navy-950); + color: var(--color-ink-100); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.55; + /* A slow warm glow keeps the navy field from reading flat. */ + background-image: + radial-gradient( + 900px 520px at 8% -6%, + color-mix(in srgb, var(--color-ember-600) 11%, transparent), + transparent 62% + ), + radial-gradient( + 760px 460px at 96% 4%, + color-mix(in srgb, var(--color-gold-600) 8%, transparent), + transparent 60% + ); + background-attachment: fixed; + } + + ::selection { + background: color-mix(in srgb, var(--color-ember-500) 34%, transparent); + color: var(--color-ink-100); + } + + :focus-visible { + outline: 2px solid var(--color-ember-500); + outline-offset: 2px; + border-radius: 4px; + } + + h1, + h2, + h3, + h4 { + letter-spacing: -0.015em; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--color-navy-600) transparent; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: var(--color-navy-600); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-navy-500); + background-clip: content-box; + } + + input, + textarea, + select, + button { + font: inherit; + color: inherit; + } + + textarea { + resize: vertical; + } +} + +/* ============================ component layer ============================= */ + +@layer components { + .surface { + background: color-mix(in srgb, var(--color-navy-850) 82%, transparent); + border: 1px solid var(--color-navy-700); + border-radius: var(--radius-card); + backdrop-filter: blur(10px); + } + + .surface-raised { + background: color-mix(in srgb, var(--color-navy-800) 88%, transparent); + border: 1px solid var(--color-navy-700); + border-radius: var(--radius-card); + box-shadow: + 0 1px 0 0 color-mix(in srgb, white 5%, transparent) inset, + 0 18px 40px -24px rgb(0 0 0 / 0.75); + } + + /* --- buttons --- */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0.95rem; + border-radius: 10px; + border: 1px solid transparent; + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.005em; + cursor: pointer; + white-space: nowrap; + transition: + background-color 0.16s ease, + border-color 0.16s ease, + color 0.16s ease, + transform 0.16s ease, + box-shadow 0.16s ease; + } + + .btn:active:not(:disabled) { + transform: translateY(1px); + } + + .btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .btn-primary { + background: linear-gradient(180deg, var(--color-ember-500), var(--color-ember-600)); + color: #1a0e05; + box-shadow: 0 10px 24px -14px color-mix(in srgb, var(--color-ember-500) 90%, transparent); + } + + .btn-primary:hover:not(:disabled) { + background: linear-gradient(180deg, var(--color-ember-400), var(--color-ember-500)); + } + + .btn-ghost { + background: color-mix(in srgb, var(--color-navy-800) 70%, transparent); + border-color: var(--color-navy-700); + color: var(--color-ink-200); + } + + .btn-ghost:hover:not(:disabled) { + background: var(--color-navy-750); + border-color: var(--color-navy-600); + color: var(--color-ink-100); + } + + .btn-danger { + background: color-mix(in srgb, var(--color-rose-500) 16%, transparent); + border-color: color-mix(in srgb, var(--color-rose-500) 42%, transparent); + color: var(--color-rose-400); + } + + .btn-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-rose-500) 26%, transparent); + color: #ffd7d5; + } + + .btn-sm { + padding: 0.3rem 0.6rem; + font-size: 0.75rem; + border-radius: 8px; + } + + /* --- form controls --- */ + .field { + width: 100%; + padding: 0.5rem 0.75rem; + border-radius: 10px; + background: color-mix(in srgb, var(--color-navy-900) 75%, transparent); + border: 1px solid var(--color-navy-700); + color: var(--color-ink-100); + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + background-color 0.16s ease; + } + + .field::placeholder { + color: var(--color-ink-500); + } + + .field:hover:not(:disabled) { + border-color: var(--color-navy-600); + } + + .field:focus { + outline: none; + border-color: var(--color-ember-500); + background: color-mix(in srgb, var(--color-navy-900) 90%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ember-500) 18%, transparent); + } + + .field:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + select.field { + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--color-ink-400) 50%), + linear-gradient(135deg, var(--color-ink-400) 50%, transparent 50%); + background-position: + calc(100% - 17px) calc(50% + 1px), + calc(100% - 12px) calc(50% + 1px); + background-size: + 5px 5px, + 5px 5px; + background-repeat: no-repeat; + padding-right: 2rem; + } + + select.field option { + background: var(--color-navy-850); + color: var(--color-ink-100); + } + + .label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--color-ink-400); + margin-bottom: 0.4rem; + } + + /* --- chips --- */ + .chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid transparent; + white-space: nowrap; + } + + .chip-neutral { + background: color-mix(in srgb, var(--color-navy-700) 55%, transparent); + border-color: var(--color-navy-600); + color: var(--color-ink-300); + } + + .chip-gold { + background: color-mix(in srgb, var(--color-gold-500) 14%, transparent); + border-color: color-mix(in srgb, var(--color-gold-500) 38%, transparent); + color: var(--color-gold-300); + } + + .chip-ember { + background: color-mix(in srgb, var(--color-ember-500) 15%, transparent); + border-color: color-mix(in srgb, var(--color-ember-500) 40%, transparent); + color: var(--color-ember-300); + } + + .chip-mint { + background: color-mix(in srgb, var(--color-mint-500) 14%, transparent); + border-color: color-mix(in srgb, var(--color-mint-500) 38%, transparent); + color: var(--color-mint-400); + } + + .chip-rose { + background: color-mix(in srgb, var(--color-rose-500) 14%, transparent); + border-color: color-mix(in srgb, var(--color-rose-500) 38%, transparent); + color: var(--color-rose-400); + } + + /* --- misc --- */ + .hairline { + height: 1px; + background: linear-gradient( + 90deg, + transparent, + var(--color-navy-700) 18%, + var(--color-navy-700) 82%, + transparent + ); + } + + .num { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; + } +} + +/* ======================= markdown + code highlighting ===================== */ + +.md { + color: var(--color-ink-200); + font-size: 0.875rem; + line-height: 1.7; +} + +.md > *:first-child { + margin-top: 0; +} + +.md h1, +.md h2, +.md h3, +.md h4 { + color: var(--color-ink-100); + font-weight: 650; + line-height: 1.3; + margin: 1.6em 0 0.6em; +} + +.md h1 { + font-size: 1.4rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--color-navy-700); +} + +.md h2 { + font-size: 1.15rem; + color: var(--color-gold-300); +} + +.md h3 { + font-size: 1rem; + color: var(--color-ember-300); +} + +.md p { + margin: 0.85em 0; +} + +.md a { + color: var(--color-ember-400); + text-decoration: underline; + text-underline-offset: 2px; +} + +.md strong { + color: var(--color-ink-100); + font-weight: 650; +} + +.md ul, +.md ol { + margin: 0.85em 0; + padding-left: 1.4em; +} + +.md ul { + list-style: disc; +} + +.md ol { + list-style: decimal; +} + +.md li { + margin: 0.3em 0; +} + +.md li::marker { + color: var(--color-gold-600); +} + +.md blockquote { + margin: 1em 0; + padding: 0.1em 1em; + border-left: 3px solid var(--color-gold-600); + background: color-mix(in srgb, var(--color-navy-800) 55%, transparent); + border-radius: 0 8px 8px 0; + color: var(--color-ink-300); +} + +.md hr { + margin: 1.8em 0; + border: 0; + height: 1px; + background: var(--color-navy-700); +} + +.md table { + width: 100%; + margin: 1em 0; + border-collapse: collapse; + font-size: 0.8125rem; + display: block; + overflow-x: auto; +} + +.md th, +.md td { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-navy-700); + text-align: left; +} + +.md th { + background: color-mix(in srgb, var(--color-navy-800) 70%, transparent); + color: var(--color-ink-200); + font-weight: 600; +} + +.md :not(pre) > code { + font-family: var(--font-mono); + font-size: 0.8125em; + padding: 0.12em 0.4em; + border-radius: 5px; + background: color-mix(in srgb, var(--color-navy-750) 80%, transparent); + border: 1px solid var(--color-navy-700); + color: var(--color-gold-300); +} + +.md pre { + margin: 1em 0; + padding: 0.9rem 1rem; + border-radius: 11px; + background: var(--color-navy-950); + border: 1px solid var(--color-navy-700); + overflow-x: auto; + font-size: 0.8125rem; + line-height: 1.6; +} + +.md pre code { + font-family: var(--font-mono); + background: none; + border: 0; + padding: 0; + color: var(--color-ink-200); +} + +/* highlight.js — navy base with warm keywords */ +.hljs-comment, +.hljs-quote { + color: var(--color-ink-500); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-literal, +.hljs-section, +.hljs-doctag, +.hljs-type, +.hljs-name, +.hljs-strong { + color: var(--color-ember-400); +} + +.hljs-string, +.hljs-title, +.hljs-attr, +.hljs-attribute, +.hljs-regexp, +.hljs-addition { + color: var(--color-gold-300); +} + +.hljs-number, +.hljs-variable, +.hljs-template-variable, +.hljs-selector-attr, +.hljs-selector-pseudo, +.hljs-bullet, +.hljs-link { + color: var(--color-mint-400); +} + +.hljs-built_in, +.hljs-builtin-name, +.hljs-class .hljs-title, +.hljs-title.class_, +.hljs-title.function_ { + color: var(--color-sky-400); +} + +.hljs-meta, +.hljs-symbol { + color: var(--color-ink-400); +} + +.hljs-deletion { + color: var(--color-rose-400); +} + +.hljs-emphasis { + font-style: italic; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..09ad236 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,281 @@ +/** Typed client for the LM-Gambit Python API. */ + +export interface ProviderSummary { + name: string + is_default: boolean +} + +export interface ModelSummary { + id: string + display_name: string +} + +export interface TestPrompt { + filename: string + title: string + prompt: string +} + +export interface TestSuite { + tests: TestPrompt[] + directory: string +} + +export interface RunMetrics { + tokens_per_second: number + total_tokens: number + time_to_first_token: number + stop_reason: string +} + +export interface RunSummary { + average_tokens_per_second: number + average_time_to_first_token: number + total_tokens: number + passed: number + failed: number + overall_score: number | null + graded: number +} + +/** One plugin's verdict on one answer. */ +export interface GradeResult { + grader: string + score: number + label: string + notes: string +} + +export interface PluginSummary { + name: string + slug: string + version: string + description: string + path: string + enabled: boolean + hooks: string[] + error: string | null +} + +export type RunStatus = 'running' | 'completed' | 'failed' | 'cancelled' + +export interface Run { + id: string + status: RunStatus + provider: string + model_id: string + model_label: string + temperature: number + total: number + completed: number + started_at: number + finished_at: number | null + report_name: string | null + error: string | null + summary: RunSummary +} + +export interface ReportSummary { + name: string + model_label: string + size_bytes: number + modified_at: number +} + +export interface ReportDetail extends ReportSummary { + content: string +} + +export interface ModelPathEntry { + nickname: string + path: string +} + +export interface Settings { + default_provider: string + default_temperature: number + local_model_paths: ModelPathEntry[] + tests_dir: string + results_dir: string + models_dir: string +} + +export interface SystemInfo { + version: string + engine_architecture: string + engine_runtime: string + template_ok: boolean + python_version: string + metrics: Record +} + +export interface PlaygroundResult { + response: string | null + error: string | null + metrics: RunMetrics | null + elapsed: number +} + +/* ------------------------------------------------------------------ events */ + +export interface TestCompletedEvent { + type: 'test.completed' + index: number + total: number + title: string + filename: string + status: 'ok' | 'error' + elapsed: number + response: string | null + error: string | null + metrics: RunMetrics | null + grades: GradeResult[] + score: number | null +} + +export interface RunStartedEvent { + type: 'run.started' + run: Run + tests: { index: number; title: string; filename: string }[] +} + +export interface RunTerminalEvent { + type: 'run.completed' | 'run.failed' | 'run.cancelled' + run: Run + message?: string +} + +export type RunEvent = RunStartedEvent | TestCompletedEvent | RunTerminalEvent + +/* ------------------------------------------------------------------ client */ + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +async function request(path: string, init?: RequestInit): Promise { + let response: Response + try { + response = await fetch(`/api${path}`, { + headers: init?.body ? { 'Content-Type': 'application/json' } : undefined, + ...init, + }) + } catch { + throw new ApiError('Cannot reach the LM-Gambit server. Is it still running?', 0) + } + + if (response.status === 204) return undefined as T + + const raw = await response.text() + let payload: unknown = null + if (raw) { + try { + payload = JSON.parse(raw) + } catch { + payload = raw + } + } + + if (!response.ok) { + const detail = + payload && typeof payload === 'object' && 'detail' in payload + ? (payload as { detail: unknown }).detail + : payload + throw new ApiError( + typeof detail === 'string' ? detail : `Request failed (${response.status})`, + response.status, + ) + } + + return payload as T +} + +export const api = { + system: () => request('/system'), + + providers: () => request('/providers'), + models: (provider: string) => + request<{ provider: string; models: ModelSummary[] }>( + `/providers/${encodeURIComponent(provider)}/models`, + ), + + tests: () => request('/tests'), + saveTests: (prompts: string[]) => + request('/tests', { + method: 'PUT', + body: JSON.stringify({ tests: prompts.map((prompt) => ({ prompt })) }), + }), + + startRun: (body: { + provider: string + model_id: string + temperature: number + filenames?: string[] + }) => request('/runs', { method: 'POST', body: JSON.stringify(body) }), + runs: () => request('/runs'), + activeRun: () => request('/runs/active'), + cancelRun: (id: string) => request(`/runs/${id}/cancel`, { method: 'POST' }), + + reports: () => request('/reports'), + report: (name: string) => request(`/reports/${encodeURIComponent(name)}`), + deleteReport: (name: string) => + request(`/reports/${encodeURIComponent(name)}`, { method: 'DELETE' }), + + playground: (body: { + provider: string + model_id: string + prompt: string + temperature: number + }) => request('/playground', { method: 'POST', body: JSON.stringify(body) }), + + plugins: () => request('/plugins'), + reloadPlugins: () => request('/plugins/reload', { method: 'POST' }), + + settings: () => request('/settings'), + saveSettings: (body: { + default_provider?: string + default_temperature?: number + local_model_paths?: ModelPathEntry[] + }) => request('/settings', { method: 'PUT', body: JSON.stringify(body) }), +} + +/** Subscribe to a run's server-sent event feed. Returns an unsubscribe fn. */ +export function subscribeToRun( + runId: string, + onEvent: (event: RunEvent) => void, + onError?: () => void, +): () => void { + const source = new EventSource(`/api/runs/${runId}/events`) + + const handle = (raw: MessageEvent) => { + try { + onEvent(JSON.parse(raw.data) as RunEvent) + } catch { + /* ignore malformed frames */ + } + } + + for (const name of [ + 'run.started', + 'test.completed', + 'run.completed', + 'run.failed', + 'run.cancelled', + ]) { + source.addEventListener(name, handle as EventListener) + } + + source.onerror = () => { + // The server closes the stream once a run reaches a terminal state, which + // EventSource reports as an error. Only surface it while still connecting. + if (source.readyState === EventSource.CLOSED) onError?.() + } + + return () => source.close() +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..06ee6eb --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,52 @@ +/** Small display helpers shared across views. */ + +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '—' + if (seconds < 1) return `${Math.round(seconds * 1000)}ms` + if (seconds < 60) return `${seconds.toFixed(1)}s` + const minutes = Math.floor(seconds / 60) + const rest = Math.round(seconds % 60) + if (minutes < 60) return `${minutes}m ${rest}s` + return `${Math.floor(minutes / 60)}h ${minutes % 60}m` +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export function formatRelativeTime(epochSeconds: number): string { + const delta = Date.now() / 1000 - epochSeconds + if (delta < 60) return 'just now' + if (delta < 3600) return `${Math.floor(delta / 60)}m ago` + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago` + if (delta < 604800) return `${Math.floor(delta / 86400)}d ago` + return new Date(epochSeconds * 1000).toLocaleDateString() +} + +export function formatTimestamp(epochSeconds: number): string { + return new Date(epochSeconds * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function clockTime(epochMs: number = Date.now()): string { + return new Date(epochMs).toLocaleTimeString(undefined, { hour12: false }) +} + +/** Title shown for a question — the engine uses the first non-empty line. */ +export function deriveTitle(prompt: string, fallback = 'Untitled question'): string { + for (const line of prompt.split('\n')) { + const trimmed = line.trim() + if (trimmed) return trimmed + } + return fallback +} + +export function cx(...values: (string | false | null | undefined)[]): string { + return values.filter(Boolean).join(' ') +} diff --git a/web/src/lib/report.ts b/web/src/lib/report.ts new file mode 100644 index 0000000..84026e4 --- /dev/null +++ b/web/src/lib/report.ts @@ -0,0 +1,127 @@ +/** + * Parses the markdown reports the Python engine writes. + * + * The shape is fixed by `.core/templates/test-block.md`, so a report can be + * read back into structured metrics for charting without the server having to + * keep a parallel database of past runs. + */ + +export interface ParsedTest { + index: number + title: string + filename: string + ok: boolean + error: string | null + tokensPerSecond: number | null + totalTokens: number | null + timeToFirstToken: number | null + stopReason: string | null + /** Mean plugin score for this question, when the report carries grades. */ + score: number | null +} + +export interface ParsedReport { + modelLabel: string + tests: ParsedTest[] + averageTokensPerSecond: number | null + averageTimeToFirstToken: number | null + totalTokens: number | null + overallScore: number | null + graders: string[] +} + +function numberOrNull(raw: string | undefined): number | null { + if (!raw) return null + const value = Number.parseFloat(raw.replace(/[^\d.-]/g, '')) + return Number.isFinite(value) ? value : null +} + +function metric(block: string, label: string): string | undefined { + const match = block.match(new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`)) + return match?.[1]?.trim() +} + +/** + * Read grades back out of the analysis block that grader plugins write. + * + * Rows look like: `| 3 | Question title | 80% (4/5 checks) | My Grader | notes |` + * A question graded by several plugins gets one row each, so scores are + * averaged per question — matching how the run computed them live. + */ +function parseGrades(markdown: string): { + scores: Map + overall: number | null + graders: string[] +} { + const block = markdown.match(/([\s\S]*?)/)?.[1] + const scores = new Map() + const graders = new Set() + + if (!block) return { scores, overall: null, graders: [] } + + const overallMatch = block.match(/\*\*Overall score:\s*(\d+(?:\.\d+)?)%/) + const overall = overallMatch ? Number.parseFloat(overallMatch[1]) / 100 : null + + const collected = new Map() + const row = /^\|\s*(\d+)\s*\|[^|]*\|\s*(\d+(?:\.\d+)?)%[^|]*\|([^|]*)\|/gm + let match: RegExpExecArray | null + while ((match = row.exec(block)) !== null) { + const index = Number.parseInt(match[1], 10) + const value = Number.parseFloat(match[2]) / 100 + if (!Number.isFinite(index) || !Number.isFinite(value)) continue + collected.set(index, [...(collected.get(index) ?? []), value]) + const grader = match[3]?.trim() + if (grader && grader !== '—') graders.add(grader) + } + + for (const [index, values] of collected) { + scores.set(index, values.reduce((a, b) => a + b, 0) / values.length) + } + + return { scores, overall, graders: [...graders] } +} + +export function parseReport(markdown: string): ParsedReport { + const modelLabel = + markdown.match(/^#\s*Automated Diagnostic Report:\s*(.+)$/m)?.[1]?.trim() ?? 'Unknown model' + + const summary = markdown.match(/([\s\S]*?)/)?.[1] ?? '' + + const { scores, overall, graders } = parseGrades(markdown) + + const tests: ParsedTest[] = [] + // Split on the test headings the template emits; the first chunk is the preamble. + const chunks = markdown.split(/^##\s+Test\s+(\d+):\s*(.*)$/m) + + for (let i = 1; i < chunks.length; i += 3) { + const index = Number.parseInt(chunks[i], 10) + const title = (chunks[i + 1] ?? '').trim() + const body = chunks[i + 2] ?? '' + + const errorMatch = body.match(/\*\*ERROR:\*\*\s*(.+)/) + const stopReason = metric(body, 'Stop Reason') ?? null + + tests.push({ + index, + title: title || `Test ${index}`, + filename: body.match(/\*Source:\*\s*`([^`]+)`/)?.[1] ?? '', + ok: !errorMatch, + error: errorMatch?.[1]?.trim() ?? null, + tokensPerSecond: numberOrNull(metric(body, 'Tokens/s')), + totalTokens: numberOrNull(metric(body, 'Total Tokens')), + timeToFirstToken: numberOrNull(metric(body, 'Time to First Token')), + stopReason: stopReason === 'N/A' ? null : stopReason, + score: scores.get(index) ?? null, + }) + } + + return { + modelLabel, + tests, + averageTokensPerSecond: numberOrNull(metric(summary, 'Average Tokens/s')), + averageTimeToFirstToken: numberOrNull(metric(summary, 'Average Time to First Token')), + totalTokens: numberOrNull(metric(summary, 'Total Tokens Generated')), + overallScore: overall, + graders, + } +} diff --git a/web/src/lib/router.tsx b/web/src/lib/router.tsx new file mode 100644 index 0000000..d8c1e08 --- /dev/null +++ b/web/src/lib/router.tsx @@ -0,0 +1,74 @@ +/** + * A ~40 line History API router. + * + * The app has five top-level views and no data loaders, so a routing library + * would be pure overhead. This gives real URLs, working back/forward buttons + * and deep links (e.g. /reports/automated_report_Qwen.md) with no dependency. + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' + +interface RouterValue { + path: string + navigate: (to: string, options?: { replace?: boolean }) => void +} + +const RouterContext = createContext({ path: '/', navigate: () => {} }) + +export function RouterProvider({ children }: { children: ReactNode }) { + const [path, setPath] = useState(() => window.location.pathname || '/') + + useEffect(() => { + const onPop = () => setPath(window.location.pathname || '/') + window.addEventListener('popstate', onPop) + return () => window.removeEventListener('popstate', onPop) + }, []) + + const navigate = useCallback((to: string, options?: { replace?: boolean }) => { + if (to === window.location.pathname) return + window.history[options?.replace ? 'replaceState' : 'pushState']({}, '', to) + setPath(to) + window.scrollTo({ top: 0 }) + }, []) + + const value = useMemo(() => ({ path, navigate }), [path, navigate]) + return {children} +} + +export function useRouter() { + return useContext(RouterContext) +} + +export function Link({ + to, + className, + children, + ...rest +}: { to: string; className?: string; children: ReactNode } & Omit< + React.AnchorHTMLAttributes, + 'href' +>) { + const { navigate } = useRouter() + return ( + { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return + event.preventDefault() + navigate(to) + }} + {...rest} + > + {children} + + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..0ae6f34 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import { App } from './App' +import { RouterProvider } from './lib/router' +import { ToastProvider } from './components/ui' +import { RunFeedProvider } from './hooks/useRunFeed' + +createRoot(document.getElementById('root')!).render( + + + + + + + + + , +) diff --git a/web/src/pages/NotFoundPage.tsx b/web/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..91c41b1 --- /dev/null +++ b/web/src/pages/NotFoundPage.tsx @@ -0,0 +1,20 @@ +import { CircleSlash } from 'lucide-react' +import { Card, EmptyState } from '../components/ui' +import { Link } from '../lib/router' + +export function NotFoundPage() { + return ( + + } + title="Nothing here" + description="That page does not exist in LM-Gambit." + action={ + + Back to the run dashboard + + } + /> + + ) +} diff --git a/web/src/pages/PlaygroundPage.tsx b/web/src/pages/PlaygroundPage.tsx new file mode 100644 index 0000000..b8aca01 --- /dev/null +++ b/web/src/pages/PlaygroundPage.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'react' +import { FlaskConical, Info, Plus, Send, Zap } from 'lucide-react' +import { + Card, + CardHeader, + EmptyState, + ErrorNote, + PageHeader, + Spinner, + StatTile, + TemperatureSlider, + useAsync, + useToast, +} from '../components/ui' +import { Markdown } from '../components/Markdown' +import { Link } from '../lib/router' +import { api, ApiError, type PlaygroundResult } from '../lib/api' +import { formatDuration } from '../lib/format' +import { useRunFeed } from '../hooks/useRunFeed' + +export function PlaygroundPage() { + const toast = useToast() + const { isLive } = useRunFeed() + + const providers = useAsync(() => api.providers(), []) + const [provider, setProvider] = useState('') + const [modelId, setModelId] = useState('') + const [temperature, setTemperature] = useState(0.3) + const [prompt, setPrompt] = useState('') + const [result, setResult] = useState(null) + const [sending, setSending] = useState(false) + + useEffect(() => { + if (!providers.data?.length || provider) return + setProvider(providers.data.find((p) => p.is_default)?.name ?? providers.data[0].name) + }, [providers.data, provider]) + + const models = useAsync( + () => (provider ? api.models(provider) : Promise.resolve(null)), + [provider], + ) + + useEffect(() => { + const list = models.data?.models ?? [] + setModelId((current) => (list.some((m) => m.id === current) ? current : (list[0]?.id ?? ''))) + }, [models.data]) + + const send = async () => { + if (!prompt.trim() || !provider || !modelId) return + setSending(true) + setResult(null) + try { + setResult(await api.playground({ provider, model_id: modelId, prompt, temperature })) + } catch (cause) { + toast(cause instanceof ApiError ? cause.message : String(cause), 'error') + } finally { + setSending(false) + } + } + + const saveAsQuestion = async () => { + if (!prompt.trim()) return + try { + const suite = await api.tests() + await api.saveTests([...suite.tests.map((t) => t.prompt), prompt.trim()]) + toast('Added to the suite as the last question.', 'success') + } catch (cause) { + toast(cause instanceof ApiError ? cause.message : String(cause), 'error') + } + } + + const disabled = isLive || sending + + return ( + <> + + + {isLive && ( +
+ +

+ A diagnostic run is using the engine. The playground is paused until it finishes. +

+
+ )} + +
+
+ + } /> +
+ {providers.error && } +
+ + +
+
+ + {models.error ? ( + + ) : ( + + )} +
+ +
+
+ + + } + actions={ + + } + /> +
+