feat: add Settings and Suite pages with comprehensive settings management and question suite builder

- Implemented SettingsPage for configuring default provider, temperature, and model paths.
- Added SuitePage for managing a suite of questions with features to add, edit, duplicate, and delete questions.
- Introduced TypeScript configuration files for app and node environments.
- Set up Vite configuration for development server with API proxying to backend.
This commit is contained in:
Christopher Clendening
2026-07-27 17:41:08 -04:00
parent ce0e022f11
commit adf61ae1a0
50 changed files with 10952 additions and 164 deletions
Vendored
BIN
View File
Binary file not shown.
+41
View File
@@ -120,7 +120,9 @@ def initialize_report_file(model_label: str) -> Path:
## Qualitative Analysis ## Qualitative Analysis
<!--ANALYSIS_START-->
*(Manual grading and analysis of the responses is required to determine the final letter grade.)* *(Manual grading and analysis of the responses is required to determine the final letter grade.)*
<!--ANALYSIS_END-->
--- ---
@@ -204,3 +206,42 @@ def finalize_report_summary(report_path: Path, results: List[Dict[str, object]])
flags=re.DOTALL, flags=re.DOTALL,
) )
report_path.write_text(updated_content, encoding="utf-8") 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 "<!--ANALYSIS_START-->" not in content:
return
updated = re.sub(
r"<!--ANALYSIS_START-->.*?<!--ANALYSIS_END-->",
lambda _: f"<!--ANALYSIS_START-->\n{markdown.strip()}\n<!--ANALYSIS_END-->",
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")
+8 -1
View File
@@ -45,8 +45,14 @@ def run_suite(
model_id: Optional[str] = None, model_id: Optional[str] = None,
temperature: Optional[float] = None, temperature: Optional[float] = None,
progress_callback: Optional[Callable[[int, int, Dict[str, str], Dict[str, object]], None]] = None, progress_callback: Optional[Callable[[int, int, Dict[str, str], Dict[str, object]], None]] = None,
prompts: Optional[List[Dict[str, str]]] = None,
) -> Path: ) -> 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() ensure_directories()
reset_temp_directory() reset_temp_directory()
@@ -73,6 +79,7 @@ def run_suite(
"Template file missing. Please create '.core/templates/test-block.md' before running tests." "Template file missing. Please create '.core/templates/test-block.md' before running tests."
) )
if prompts is None:
prompts = load_test_prompts() prompts = load_test_prompts()
if not prompts: if not prompts:
raise TestRunError("No test prompts found in the 'tests' directory.") raise TestRunError("No test prompts found in the 'tests' directory.")
+11
View File
@@ -17,3 +17,14 @@ ENV/
# Ignore other build artifacts # Ignore other build artifacts
*.egg-info/ *.egg-info/
# Frontend
web/node_modules/
web/dist/
node_modules/
# Local model weights
models/
# Locally persisted GUI/CLI settings
.core/user_settings.json
+235 -129
View File
@@ -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.
Version 2.0.0 replaces the Tkinter GUI with a React web interface served by a FastAPI
# LM-Gambit v1.0.0: Automated LLM Diagnostic Suite backend. The diagnostic engine under `.core/` is unchanged — the CLI, providers, hardware
runtimes and report format all behave exactly as before.
**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.
--- ---
## Features ## Install
- **Automated Testing:** Run a suite of prompt-based diagnostics against any supported LLM provider. **Requirements**
- **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.
--- - Python 3.10+ (3.11 recommended)
- Node.js 20+ and npm (only to build the interface)
## Installation - macOS, Linux or Windows — Apple Silicon, NVIDIA, AMD or CPU-only
**Requirements:**
- Python 3.10+
- macOS, Linux, or Windows (Apple Silicon, NVIDIA, AMD, or CPU-only supported)
**Install dependencies:**
```bash ```bash
python -m pip install -r requirements.txt 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 <n>` | Serve on a specific port (default 8765; the next free port is used if taken) |
| `--host <addr>` | 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:** Saving from the Suite Builder rewrites the folder as `test1.txt … testN.txt` in the order
- `-h, --help` — Show usage instructions shown, so the web interface and `auto-test.py` always run the same suite in the same order.
- `-p <provider>` — Select provider (e.g., "Local Engine", "LM Studio") You can still edit the `.txt` files by hand.
- `-m <model>` — Select model by ID or filename
- `-l, --list` — List available providers or models
- `-t <test>` — (Reserved) Specify a test suite
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/<slug>` |
| `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 ```bash
python auto-test.py -p "Local Engine" -m Qwen3-Coder-30B.gguf python auto-test.py -p "Local Engine" -m Qwen3-Coder-30B.gguf
``` ```
Reports are saved to `results/automated_report_<model>.md`. | Flag | Purpose |
|---|---|
| `-h, --help` | Usage instructions |
| `-p <provider>` | Provider to use (e.g. `"Local Engine"`, `"LM Studio"`) |
| `-m <model>` | Model by id or filename |
| `-l, --list` | List providers, or models when combined with `-p` |
### GUI Usage Reports are written to `results/automated_report_<model>.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 ## How a run works
- Run, refresh, open prompts/results, and view latest report
- Live log of test progress 1. **Provider selection** — providers are registered in `.core/providers/`, each implementing
- Settings dialog for managing model search paths `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 The backend is a normal REST API — interactive docs at `http://localhost:8765/api/docs`.
- `providers/` — Provider adapters (LM Studio, Local Engine, etc.)
- `.engine/` — Hardware-specific runtime implementations (MLX, CUDA, ROCm, CPU) | Endpoint | Purpose |
- `runner.py` — Orchestrates test runs, progress, and reporting |---|---|
- `reporting.py` — Markdown report generation, code-fence hygiene | `GET /api/providers` · `GET /api/providers/{name}/models` | Discovery |
- `prompts.py` — Loads and parses prompt files from `tests/` | `GET /api/tests` · `PUT /api/tests` | Read and replace the suite |
- `templates/test-block.md` — Customizable template for each test result | `POST /api/runs` · `GET /api/runs/{id}/events` · `POST /api/runs/{id}/cancel` | Start, stream, cancel |
- `models/` — Drop-in directory for `.gguf` or MLX-compatible weights (auto-discovered) | `GET /api/reports` · `GET /api/reports/{name}` | Saved reports |
- `tests/` One prompt per `.txt` file (edit or add your own) | `POST /api/playground` | One-off prompt |
- `results/` — Markdown reports generated after each run | `GET /api/settings` · `PUT /api/settings` | Preferences |
- `auto-test.py` — CLI entrypoint | `GET /api/plugins` · `POST /api/plugins/reload` | Installed plugins |
- `index.py` — Tkinter GUI entrypoint | `/api/plugins/<slug>/…` | 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).
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.
---
## Configuration & Environment Variables
You can customize behavior via environment variables:
| Variable | Purpose | | Variable | Purpose |
|-------------------------|--------------------------------------------------------------| |---|---|
| `LM_STUDIO_BASE_URL` | Override LM Studio API endpoint (default: http://localhost:1234) | | `LM_STUDIO_BASE_URL` | LM Studio endpoint (default `http://localhost:1234`) |
| `AUTO_TEST_TEMPERATURE` | Default sampling temperature for test runs | | `AUTO_TEST_TEMPERATURE` | Default sampling temperature |
| `AUTO_TEST_PROVIDER` | Default provider for CLI runs | | `AUTO_TEST_PROVIDER` | Default provider for CLI runs |
| `LOCAL_LLM_PATHS` | `:`-separated list of extra directories for `.gguf` models | | `LOCAL_LLM_PATHS` | `os.pathsep`-separated extra directories to scan for `.gguf` weights |
Model search paths can also be managed via the GUI settings dialog (persisted in `.core/user_settings.json`). 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.
--- ---
## Frontend development
## Extending LM-Gambit ```bash
python app.py --no-browser # terminal 1 — API on :8765
cd web && npm run dev # terminal 2 — UI on :5173 with hot reload
```
**Add a new provider:** Vite proxies `/api` (including the event stream) through to the Python server, so both run
1. Create a new class in `.core/providers/` inheriting from `Provider` (see `base.py`). from one origin.
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/.<architecture>/<version>.py`.
2. Implement an `EngineRuntime` class inheriting from `BaseRuntime`.
3. The engine loader will auto-detect and use your runtime if the hardware matches.
--- ---
## Extending
## Customizing Prompts & Reports in 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/edit prompt `.txt` files in `tests/` (first non-empty line is the title). **A new engine runtime** — add `.core/.engine/.<architecture>/<version>.py` defining an
- Edit `.core/templates/test-block.md` to change the report format. `EngineRuntime` class that inherits from `BaseRuntime`. The loader picks it up when the
hardware matches.
--- ---
## Troubleshooting ## Troubleshooting
- **No models found?** **"Frontend not built"** — run `cd web && npm install && npm run build`. The API and CLI work
- Ensure your `.gguf` files are in `models/` or listed in `LOCAL_LLM_PATHS`. without it.
- For LM Studio, ensure the app is running and the API is enabled.
- **Engine errors?** **No models found** — put `.gguf` files in `models/`, add a path under Settings, or set
- Check your hardware drivers and Python dependencies. `LOCAL_LLM_PATHS`. For LM Studio, make sure the app is running with its API enabled.
- **GUI not launching?**
- Ensure `tkinter` is installed and available in your Python environment. **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 ## License
MIT License. See `LICENSE` for details. 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/`.
+114
View File
@@ -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.")
+108 -1
View File
@@ -7,9 +7,18 @@ if str(CORE_DIR) not in sys.path:
sys.path.insert(0, str(CORE_DIR)) sys.path.insert(0, str(CORE_DIR))
import argparse import argparse
import time
from runner import run_suite, TestRunError, TemplateNotFoundError from runner import run_suite, TestRunError, TemplateNotFoundError
from providers import list_provider_names, get_provider from providers import list_provider_names, get_provider
from config import DEFAULT_PROVIDER_NAME 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(): def list_providers():
print("Available providers:") print("Available providers:")
@@ -61,13 +70,60 @@ def main():
model = args.m model = args.m
# args.t is parsed but not yet implemented in runner # 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): def _print_progress(index: int, total: int, prompt, result):
status = "FAILED" if "error" in result else "DONE" status = "FAILED" if "error" in result else "DONE"
filename_label = prompt.get("filename", f"test{index}") filename_label = prompt.get("filename", f"test{index}")
title = prompt.get("title", 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}'") 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: try:
report_path = run_suite(provider_name=provider, model_id=model, progress_callback=_print_progress) report_path = run_suite(provider_name=provider, model_id=model, progress_callback=_print_progress)
except TestRunError as exc: except TestRunError as exc:
@@ -77,8 +133,59 @@ def main():
print(f"Error: {exc}") print(f"Error: {exc}")
return 1 return 1
_apply_plugins_to_report(report_path, records, grades, provider, model, started_at)
print(f"\n✅ Success! Report saved to '{report_path.name}'.") print(f"\n✅ Success! Report saved to '{report_path.name}'.")
return 0 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__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+117
View File
@@ -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.01.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
+349 -25
View File
@@ -1,35 +1,359 @@
# Plugin system scaffold for LM-Gambit """Plugin loader for LM-Gambit.
# Drop .py files in the plugins/ directory. Each should define a 'register' function.
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/<slug>``.
=========================== ==================================================
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 importlib.util
import sys import sys
import traceback
import types
from dataclasses import dataclass
from pathlib import Path 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: class PluginManager:
def __init__(self): """Discovers plugins and dispatches hooks to them."""
self.plugins = []
self.load_plugins()
def load_plugins(self): def __init__(self, plugin_dir: Optional[Path] = None, *, auto_load: bool = True) -> None:
if not PLUGIN_DIR.exists(): self.plugin_dir = Path(plugin_dir) if plugin_dir else PLUGIN_DIR
return self.loaded: List[LoadedPlugin] = []
for file in PLUGIN_DIR.glob("*.py"): if auto_load:
name = file.stem self.load()
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 run_hook(self, hook_name, *args, **kwargs): # ------------------------------------------------------------- discovery
for plugin in self.plugins:
hook = getattr(plugin, hook_name, None) def load(self) -> List[LoadedPlugin]:
if callable(hook): """(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: try:
hook(*args, **kwargs) spec = importlib.util.spec_from_file_location(
except Exception as e: module_name,
print(f"Plugin {plugin.__name__} hook {hook_name} failed: {e}") 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()
+161
View File
@@ -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/<filename-without-.py>.
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"}
+161
View File
@@ -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))]
+4 -4
View File
@@ -1,10 +1,10 @@
# Web backend
fastapi>=0.115
uvicorn[standard]>=0.30
# Core dependencies # Core dependencies
requests>=2.31.0 requests>=2.31.0
llama-cpp-python>=0.2.82 llama-cpp-python>=0.2.82
# Apple Silicon (MLX) support (only needed on macOS ARM) # Apple Silicon (MLX) support (only needed on macOS ARM)
mlx-lm>=0.10.0; platform_system == "Darwin" and platform_machine == "arm64" mlx-lm>=0.10.0; platform_system == "Darwin" and platform_machine == "arm64"
# GUI and plotting
matplotlib>=3.0
tkhtmlview
+9
View File
@@ -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"
+464
View File
@@ -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()
+103
View File
@@ -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))
+118
View File
@@ -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/<slug>.
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()
+93
View File
@@ -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()},
)
+523
View File
@@ -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()
+157
View File
@@ -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] = {}
+73
View File
@@ -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()
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="LM-Gambit — automated diagnostic suite for local and remote language models." />
<title>LM-Gambit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3519
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g" x1="4" y1="2" x2="36" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4813F"/>
<stop offset="1" stop-color="#E3AD46"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="38" height="38" rx="11" fill="url(#g)"/>
<path d="M20 9.4a4 4 0 0 1 2.35 7.24c1.5.93 2.4 2.42 2.4 4.06 0 1.5-.74 2.86-1.96 3.79l1.9 6.7h-9.38l1.9-6.7a4.78 4.78 0 0 1-1.96-3.79c0-1.64.9-3.13 2.4-4.06A4 4 0 0 1 20 9.4Z" fill="#160B04" fill-opacity="0.82"/>
<rect x="12.4" y="29.4" width="15.2" height="3.4" rx="1.7" fill="#160B04" fill-opacity="0.82"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+24
View File
@@ -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 = <RunPage />
else if (path === '/suite') page = <SuitePage />
else if (path.startsWith('/reports')) page = <ReportsPage />
else if (path === '/playground') page = <PlaygroundPage />
else if (path === '/settings') page = <SettingsPage />
else page = <NotFoundPage />
return <Shell activeRun={isLive ? run : null}>{page}</Shell>
}
+45
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id={gradientId} x1="4" y1="2" x2="36" y2="38" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4813F" />
<stop offset="1" stopColor="#E3AD46" />
</linearGradient>
</defs>
<rect x="1" y="1" width="38" height="38" rx="11" fill={`url(#${gradientId})`} />
<rect
x="1"
y="1"
width="38"
height="38"
rx="11"
stroke="#FFD9AE"
strokeOpacity="0.35"
strokeWidth="1.2"
/>
{/* pawn silhouette */}
<path
d="M20 9.4a4 4 0 0 1 2.35 7.24c1.5.93 2.4 2.42 2.4 4.06 0 1.5-.74 2.86-1.96 3.79l1.9 6.7h-9.38l1.9-6.7a4.78 4.78 0 0 1-1.96-3.79c0-1.64.9-3.13 2.4-4.06A4 4 0 0 1 20 9.4Z"
fill="#160B04"
fillOpacity="0.82"
/>
<rect x="12.4" y="29.4" width="15.2" height="3.4" rx="1.7" fill="#160B04" fillOpacity="0.82" />
</svg>
)
}
+25
View File
@@ -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 (
<div className={cx('md', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
>
{children}
</ReactMarkdown>
</div>
)
})
+52
View File
@@ -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 (
<span className={cx('chip num', toneClass)} title={title}>
{Math.round(score * 100)}%{showLetter && ` · ${letterGrade(score)}`}
</span>
)
}
+157
View File
@@ -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<SystemInfo | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
api.system().then(setSystem).catch(() => setSystem(null))
}, [])
useEffect(() => {
setDrawerOpen(false)
}, [path])
const nav = (
<nav className="flex flex-col gap-1">
{NAV.map(({ to, label, icon: Icon, hint }) => {
const active = isActive(path, to)
return (
<Link
key={to}
to={to}
title={hint}
className={cx(
'group relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-[0.8125rem] font-medium transition-all',
active
? 'bg-navy-750/80 text-ink-100 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]'
: 'text-ink-400 hover:bg-navy-800/60 hover:text-ink-200',
)}
>
<span
className={cx(
'absolute top-1/2 left-0 h-5 w-[3px] -translate-y-1/2 rounded-r-full transition-all',
active ? 'bg-ember-500' : 'bg-transparent',
)}
/>
<Icon size={16} className={active ? 'text-ember-400' : 'text-ink-500 group-hover:text-ink-300'} />
{label}
{to === '/' && activeRun && (
<span className="ml-auto h-1.5 w-1.5 animate-pulse rounded-full bg-ember-400 shadow-[0_0_8px_2px_rgba(244,129,63,0.5)]" />
)}
</Link>
)
})}
</nav>
)
const engineChip = system && (
<div className="rounded-xl border border-navy-700 bg-navy-900/50 px-3 py-2.5">
<div className="flex items-center gap-2 text-[0.6875rem] font-semibold tracking-wide text-ink-400 uppercase">
<Cpu size={12} className="text-gold-500" />
Engine
</div>
<div className="mt-1 truncate text-[0.75rem] font-medium text-ink-200" title={system.engine_runtime}>
{system.engine_runtime}
</div>
<div className="text-[0.6875rem] text-ink-500">{system.engine_architecture}</div>
{!system.template_ok && (
<div className="mt-1.5 text-[0.6875rem] text-rose-400">Report template missing</div>
)}
</div>
)
const sidebarBody = (
<>
<Link to="/" className="mb-7 flex items-center gap-3">
<Logo size={32} />
<div className="leading-tight">
<div className="text-[0.9375rem] font-semibold tracking-tight text-ink-100">
LM<span className="text-ember-400">-</span>Gambit
</div>
<div className="text-[0.6875rem] text-ink-500">
Diagnostic suite{system ? ` · v${system.version}` : ''}
</div>
</div>
</Link>
{nav}
<div className="mt-auto space-y-2 pt-6">{engineChip}</div>
</>
)
return (
<div className="flex min-h-screen">
{/* desktop rail */}
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col border-r border-navy-700/70 bg-navy-900/45 px-4 py-6 backdrop-blur-xl lg:flex">
{sidebarBody}
</aside>
{/* mobile drawer */}
{drawerOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="animate-fade-in absolute inset-0 bg-navy-950/75 backdrop-blur-sm"
onClick={() => setDrawerOpen(false)}
/>
<aside className="animate-fade-in relative flex h-full w-64 flex-col border-r border-navy-700 bg-navy-900 px-4 py-6">
<button
className="absolute top-5 right-4 text-ink-500 hover:text-ink-200"
onClick={() => setDrawerOpen(false)}
aria-label="Close navigation"
>
<X size={18} />
</button>
{sidebarBody}
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-navy-700/70 px-4 py-3 lg:hidden">
<button
className="btn btn-ghost btn-sm"
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation"
>
<Menu size={16} />
</button>
<Logo size={22} />
<span className="text-sm font-semibold">LM-Gambit</span>
</div>
<main className="mx-auto w-full max-w-[1400px] flex-1 px-5 py-7 sm:px-7 lg:px-9">
{children}
</main>
</div>
</div>
)
}
+227
View File
@@ -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<Measure, { label: string; unit: string; decimals: number }> = {
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<Measure>('tokensPerSecond')
const [hovered, setHovered] = useState<number | null>(null)
const scored = tests.filter((test) => test.ok && test[measure] != null)
const errored = tests.filter((test) => !test.ok)
if (scored.length === 0) {
return (
<p className="px-4 py-6 text-[0.8125rem] text-ink-500">
No successful questions in this report, so there is nothing to plot.
</p>
)
}
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 (
<div className="px-4 pt-2 pb-4">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-[0.8125rem] font-medium text-ink-300">
{config.label} by question{' '}
<span className="text-ink-500">({config.unit})</span>
</h3>
<div className="flex gap-1">
{(Object.keys(MEASURES) as Measure[]).map((key) => (
<button
key={key}
className={
measure === key
? 'btn btn-sm border-gold-500/40 bg-gold-500/15 text-gold-300'
: 'btn btn-ghost btn-sm'
}
onClick={() => setMeasure(key)}
>
{MEASURES[key].label}
</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${plotWidth} ${height}`}
width="100%"
style={{ minWidth: 420 }}
role="img"
aria-label={`${config.label} per question in ${config.unit}`}
>
<defs>
{/* Square at the baseline, 4px rounded at the data end. */}
<clipPath id={clipId}>
<rect x={0} y={0} width={plotWidth} height={height} />
</clipPath>
</defs>
{/* recessive hairline gridlines */}
{ticks.map((tick) => {
const x = LEFT + (tick / max) * barArea
return (
<g key={tick}>
<line
x1={x}
y1={TOP - 6}
x2={x}
y2={height - BOTTOM + 4}
stroke="#223353"
strokeWidth={1}
/>
<text
x={x}
y={height - BOTTOM + 17}
textAnchor="middle"
fill="#55688a"
fontSize={9.5}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{tick.toFixed(config.decimals === 2 && max < 5 ? 1 : 0)}
</text>
</g>
)
})}
{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 (
<g
key={test.index}
onMouseEnter={() => setHovered(test.index)}
onMouseLeave={() => setHovered(null)}
>
{/* hit target larger than the mark */}
<rect
x={0}
y={y - ROW_GAP / 2}
width={plotWidth}
height={rowHeight}
fill={active ? '#ffffff' : 'transparent'}
fillOpacity={active ? 0.035 : 0}
/>
<text
x={LEFT - 8}
y={y + BAR_HEIGHT / 2 + 3.5}
textAnchor="end"
fill="#7288ab"
fontSize={10}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{String(test.index).padStart(2, '0')}
</text>
<rect
x={LEFT}
y={y}
width={width}
height={BAR_HEIGHT}
rx={4}
fill={BAR_COLOR}
fillOpacity={active ? 1 : 0.88}
clipPath={`url(#${clipId})`}
/>
{/* square off the baseline end */}
<rect x={LEFT} y={y} width={Math.min(4, width)} height={BAR_HEIGHT} fill={BAR_COLOR} fillOpacity={active ? 1 : 0.88} />
<text
x={LEFT + width + 7}
y={y + BAR_HEIGHT / 2 + 3.5}
fill="#c8d5ea"
fontSize={10.5}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value.toFixed(config.decimals)}
</text>
</g>
)
})}
{/* baseline */}
<line x1={LEFT} y1={TOP - 6} x2={LEFT} y2={height - BOTTOM + 4} stroke="#2e4467" strokeWidth={1} />
</svg>
</div>
{hovered != null && (
<div className="mt-1 rounded-lg border border-navy-700 bg-navy-900/80 px-3 py-2 text-[0.75rem]">
<span className="text-ink-200">
{scored.find((test) => test.index === hovered)?.title}
</span>
<span className="num ml-2 text-gold-400">
{(scored.find((test) => test.index === hovered)?.[measure] ?? 0).toFixed(
config.decimals,
)}{' '}
{config.unit}
</span>
</div>
)}
{errored.length > 0 && (
<div className="mt-3 border-t border-navy-700 pt-3">
<p className="mb-1.5 text-[0.6875rem] font-semibold tracking-wide text-ink-500 uppercase">
Not plotted no measurement
</p>
<ul className="space-y-1">
{errored.map((test) => (
<li key={test.index} className="flex items-start gap-2 text-[0.75rem]">
<TriangleAlert size={12} className="mt-0.5 shrink-0 text-rose-400" />
<span className="text-ink-400">
<span className="num mr-1.5 text-ink-500">
{String(test.index).padStart(2, '0')}
</span>
{test.title}
<span className="ml-1.5 font-medium text-rose-400">Error</span>
</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}
+368
View File
@@ -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 (
<header className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-[1.6rem] leading-tight font-semibold text-ink-100">{title}</h1>
{subtitle && <p className="mt-1 max-w-2xl text-[0.8125rem] text-ink-400">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
)
}
export function Card({
children,
className,
raised,
}: {
children: ReactNode
className?: string
raised?: boolean
}) {
return <section className={cx(raised ? 'surface-raised' : 'surface', className)}>{children}</section>
}
export function CardHeader({
title,
icon,
actions,
hint,
}: {
title: string
icon?: ReactNode
actions?: ReactNode
hint?: string
}) {
return (
<div className="flex items-start justify-between gap-3 border-b border-navy-700 px-4 py-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-[0.8125rem] font-semibold tracking-wide text-ink-200">
{icon && <span className="text-gold-500">{icon}</span>}
{title}
</h2>
{hint && <p className="mt-0.5 text-[0.6875rem] text-ink-500">{hint}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)
}
/* ------------------------------------------------------------------- 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 (
<div className="surface px-3.5 py-3">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
{label}
</div>
<div className={cx('num mt-1 flex items-baseline gap-1 text-[1.35rem] font-semibold', toneClass)}>
{value}
{unit && <span className="text-[0.7rem] font-medium text-ink-500">{unit}</span>}
</div>
{hint && <div className="mt-0.5 text-[0.6875rem] text-ink-500">{hint}</div>}
</div>
)
}
/* ------------------------------------------------------------------ states */
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cx('animate-spin', className)} size={15} />
}
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full border border-navy-700 bg-navy-800/60 text-ink-500">
{icon}
</div>
<h3 className="text-sm font-semibold text-ink-200">{title}</h3>
{description && <p className="mt-1 max-w-sm text-[0.8125rem] text-ink-500">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export function ErrorNote({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="flex items-start gap-3 rounded-xl border border-rose-500/40 bg-rose-500/10 px-4 py-3">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-rose-400" />
<div className="min-w-0 flex-1">
<p className="text-[0.8125rem] break-words text-rose-400">{message}</p>
{onRetry && (
<button className="btn btn-ghost btn-sm mt-2" onClick={onRetry}>
Try again
</button>
)}
</div>
</div>
)
}
export function SkeletonRows({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-2 p-4">
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="relative h-11 overflow-hidden rounded-lg border border-navy-700 bg-navy-800/40"
>
<div className="animate-sweep absolute inset-y-0 w-1/3 bg-gradient-to-r from-transparent via-navy-700/50 to-transparent" />
</div>
))}
</div>
)
}
/* ------------------------------------------------------------------ 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<Toast[]>([])
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 (
<ToastContext.Provider value={push}>
{children}
<div className="pointer-events-none fixed right-5 bottom-5 z-50 flex w-[min(23rem,calc(100vw-2.5rem))] flex-col gap-2">
{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 (
<div
key={toast.id}
className={cx(
'animate-fade-up pointer-events-auto flex items-start gap-2.5 rounded-xl border bg-navy-850/95 px-3.5 py-2.5 shadow-[0_18px_40px_-20px_rgba(0,0,0,0.9)] backdrop-blur',
tone,
)}
>
<Icon size={15} className="mt-0.5 shrink-0" />
<p className="min-w-0 flex-1 text-[0.8125rem] break-words text-ink-200">
{toast.message}
</p>
<button
className="shrink-0 text-ink-500 transition-colors hover:text-ink-200"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss"
>
<X size={14} />
</button>
</div>
)
})}
</div>
</ToastContext.Provider>
)
}
/* ------------------------------------------------------------------- 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 (
<div
className="animate-fade-in fixed inset-0 z-50 flex items-center justify-center bg-navy-950/75 p-5 backdrop-blur-sm"
onClick={onCancel}
>
<div
className="surface-raised animate-fade-up w-full max-w-sm p-5"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
>
<h3 className="text-[0.9375rem] font-semibold text-ink-100">{title}</h3>
<p className="mt-2 text-[0.8125rem] text-ink-400">{body}</p>
<div className="mt-5 flex justify-end gap-2">
<button className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
<button
className={cx('btn', destructive ? 'btn-danger' : 'btn-primary')}
onClick={onConfirm}
autoFocus
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ 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 (
<div>
<div className="mb-1.5 flex items-baseline justify-between">
<span className="label mb-0">Temperature</span>
<span className="num text-[0.8125rem] font-semibold text-gold-400">{value.toFixed(2)}</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.05}
value={value}
disabled={disabled}
onChange={(event) => 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%)`,
}}
/>
<div className="mt-1 text-[0.6875rem] text-ink-500">{descriptor}</div>
</div>
)
}
/* -------------------------------------------------------------- data hooks */
export function useAsync<T>(loader: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(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],
)
}
+211
View File
@@ -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<void>
cancel: () => Promise<void>
clear: () => void
attach: (runId: string) => void
}
const RunFeedContext = createContext<RunFeedValue | null>(null)
export function useRunFeed() {
const value = useContext(RunFeedContext)
if (!value) throw new Error('useRunFeed must be used inside <RunFeedProvider>')
return value
}
export function RunFeedProvider({ children }: { children: ReactNode }) {
const [run, setRun] = useState<Run | null>(null)
const [plan, setPlan] = useState<PlannedTest[]>([])
const [outcomes, setOutcomes] = useState<TestCompletedEvent[]>([])
const [logs, setLogs] = useState<LogLine[]>([])
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<RunFeedValue>(
() => ({
run,
plan,
outcomes,
logs,
starting,
isLive: run?.status === 'running',
start,
cancel,
clear,
attach,
}),
[run, plan, outcomes, logs, starting, start, cancel, clear, attach],
)
return <RunFeedContext.Provider value={value}>{children}</RunFeedContext.Provider>
}
+596
View File
@@ -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;
}
+281
View File
@@ -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<string, string>
}
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<T>(path: string, init?: RequestInit): Promise<T> {
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<SystemInfo>('/system'),
providers: () => request<ProviderSummary[]>('/providers'),
models: (provider: string) =>
request<{ provider: string; models: ModelSummary[] }>(
`/providers/${encodeURIComponent(provider)}/models`,
),
tests: () => request<TestSuite>('/tests'),
saveTests: (prompts: string[]) =>
request<TestSuite>('/tests', {
method: 'PUT',
body: JSON.stringify({ tests: prompts.map((prompt) => ({ prompt })) }),
}),
startRun: (body: {
provider: string
model_id: string
temperature: number
filenames?: string[]
}) => request<Run>('/runs', { method: 'POST', body: JSON.stringify(body) }),
runs: () => request<Run[]>('/runs'),
activeRun: () => request<Run | null>('/runs/active'),
cancelRun: (id: string) => request<Run>(`/runs/${id}/cancel`, { method: 'POST' }),
reports: () => request<ReportSummary[]>('/reports'),
report: (name: string) => request<ReportDetail>(`/reports/${encodeURIComponent(name)}`),
deleteReport: (name: string) =>
request<void>(`/reports/${encodeURIComponent(name)}`, { method: 'DELETE' }),
playground: (body: {
provider: string
model_id: string
prompt: string
temperature: number
}) => request<PlaygroundResult>('/playground', { method: 'POST', body: JSON.stringify(body) }),
plugins: () => request<PluginSummary[]>('/plugins'),
reloadPlugins: () => request<PluginSummary[]>('/plugins/reload', { method: 'POST' }),
settings: () => request<Settings>('/settings'),
saveSettings: (body: {
default_provider?: string
default_temperature?: number
local_model_paths?: ModelPathEntry[]
}) => request<Settings>('/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()
}
+52
View File
@@ -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(' ')
}
+127
View File
@@ -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<number, number>
overall: number | null
graders: string[]
} {
const block = markdown.match(/<!--ANALYSIS_START-->([\s\S]*?)<!--ANALYSIS_END-->/)?.[1]
const scores = new Map<number, number>()
const graders = new Set<string>()
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<number, number[]>()
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(/<!--SUMMARY_START-->([\s\S]*?)<!--SUMMARY_END-->/)?.[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,
}
}
+74
View File
@@ -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<RouterValue>({ 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 <RouterContext.Provider value={value}>{children}</RouterContext.Provider>
}
export function useRouter() {
return useContext(RouterContext)
}
export function Link({
to,
className,
children,
...rest
}: { to: string; className?: string; children: ReactNode } & Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
'href'
>) {
const { navigate } = useRouter()
return (
<a
href={to}
className={className}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
event.preventDefault()
navigate(to)
}}
{...rest}
>
{children}
</a>
)
}
+19
View File
@@ -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(
<StrictMode>
<RouterProvider>
<ToastProvider>
<RunFeedProvider>
<App />
</RunFeedProvider>
</ToastProvider>
</RouterProvider>
</StrictMode>,
)
+20
View File
@@ -0,0 +1,20 @@
import { CircleSlash } from 'lucide-react'
import { Card, EmptyState } from '../components/ui'
import { Link } from '../lib/router'
export function NotFoundPage() {
return (
<Card>
<EmptyState
icon={<CircleSlash size={20} />}
title="Nothing here"
description="That page does not exist in LM-Gambit."
action={
<Link to="/" className="btn btn-primary">
Back to the run dashboard
</Link>
}
/>
</Card>
)
}
+261
View File
@@ -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<PlaygroundResult | null>(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 (
<>
<PageHeader
title="Playground"
subtitle="Try a single prompt against a model without touching the suite or writing a report."
/>
{isLive && (
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-ember-500/35 bg-ember-500/8 px-4 py-3">
<Info size={15} className="mt-0.5 shrink-0 text-ember-400" />
<p className="text-[0.8125rem] text-ember-300">
A diagnostic run is using the engine. The playground is paused until it finishes.
</p>
</div>
)}
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-5 xl:col-span-5">
<Card raised>
<CardHeader title="Target" icon={<Zap size={14} />} />
<div className="space-y-4 p-4">
{providers.error && <ErrorNote message={providers.error} onRetry={providers.reload} />}
<div>
<label className="label" htmlFor="pg-provider">
Provider
</label>
<select
id="pg-provider"
className="field"
value={provider}
disabled={disabled}
onChange={(event) => setProvider(event.target.value)}
>
{(providers.data ?? []).map((item) => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="pg-model">
Model
</label>
{models.error ? (
<ErrorNote message={models.error} onRetry={models.reload} />
) : (
<select
id="pg-model"
className="field"
value={modelId}
disabled={disabled || models.loading || !models.data?.models.length}
onChange={(event) => setModelId(event.target.value)}
>
{models.loading && <option>Discovering models</option>}
{!models.loading && !models.data?.models.length && (
<option>No models found</option>
)}
{(models.data?.models ?? []).map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
</select>
)}
</div>
<TemperatureSlider value={temperature} onChange={setTemperature} disabled={disabled} />
</div>
</Card>
<Card raised>
<CardHeader
title="Prompt"
icon={<FlaskConical size={14} />}
actions={
<button
className="btn btn-ghost btn-sm"
onClick={saveAsQuestion}
disabled={!prompt.trim() || disabled}
title="Append this prompt to the diagnostic suite"
>
<Plus size={12} />
Add to suite
</button>
}
/>
<div className="p-4">
<textarea
className="field min-h-44 font-mono text-[0.8125rem] leading-relaxed"
placeholder="Ask the model something…"
value={prompt}
spellCheck={false}
disabled={disabled}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') send()
}}
/>
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-ink-500"> to send</span>
<button
className="btn btn-primary"
onClick={send}
disabled={!prompt.trim() || !modelId || disabled}
>
{sending ? <Spinner /> : <Send size={14} />}
{sending ? 'Generating…' : 'Send'}
</button>
</div>
</div>
</Card>
</div>
<div className="xl:col-span-7">
<Card className="xl:sticky xl:top-6">
<CardHeader
title="Response"
icon={<Send size={14} />}
actions={
result?.elapsed ? (
<span className="chip chip-neutral num">{formatDuration(result.elapsed)}</span>
) : null
}
/>
{sending && (
<div className="flex items-center gap-2.5 px-4 py-10 text-[0.8125rem] text-ink-400">
<Spinner className="text-ember-400" />
Waiting on the model a cold model may take a moment to load.
</div>
)}
{!sending && !result && (
<EmptyState
icon={<FlaskConical size={20} />}
title="Nothing sent yet"
description="Write a prompt and hit Send. Responses render as markdown with syntax highlighting."
/>
)}
{!sending && result?.error && (
<div className="p-4">
<ErrorNote message={result.error} />
</div>
)}
{!sending && result?.response != null && (
<>
{result.metrics && (
<div className="grid grid-cols-2 gap-2.5 border-b border-navy-700 p-4 sm:grid-cols-4">
<StatTile
label="tok/s"
value={result.metrics.tokens_per_second.toFixed(1)}
tone="gold"
/>
<StatTile
label="TTFT"
value={result.metrics.time_to_first_token.toFixed(2)}
unit="s"
tone="gold"
/>
<StatTile
label="Tokens"
value={result.metrics.total_tokens.toLocaleString()}
tone="neutral"
/>
<StatTile label="Stop" value={result.metrics.stop_reason} tone="neutral" />
</div>
)}
<div className="max-h-[60vh] overflow-y-auto p-4">
<Markdown>{result.response || '_Empty response._'}</Markdown>
</div>
</>
)}
</Card>
</div>
</div>
<p className="mt-5 text-[0.75rem] text-ink-500">
Looking to save a set of questions instead?{' '}
<Link to="/suite" className="text-ember-400 underline underline-offset-2">
Open the Suite Builder
</Link>
.
</p>
</>
)
}
+371
View File
@@ -0,0 +1,371 @@
import { useEffect, useMemo, useState } from 'react'
import {
BarChart3,
CheckCircle2,
FileText,
LayoutList,
Table2,
Trash2,
TriangleAlert,
} from 'lucide-react'
import {
Card,
CardHeader,
ConfirmDialog,
EmptyState,
ErrorNote,
PageHeader,
SkeletonRows,
StatTile,
useAsync,
useToast,
} from '../components/ui'
import { Markdown } from '../components/Markdown'
import { ScoreBadge, scoreTone } from '../components/ScoreBadge'
import { ThroughputChart } from '../components/ThroughputChart'
import { api, ApiError, type ReportDetail } from '../lib/api'
import { parseReport } from '../lib/report'
import { cx, formatBytes, formatRelativeTime } from '../lib/format'
import { useRouter } from '../lib/router'
type View = 'chart' | 'table' | 'full'
/**
* Hide the sentinels the engine uses to locate rewritable blocks (the summary,
* and the analysis section grader plugins fill in). Only those exact markers
* are removed, so HTML inside a model's code fence is left untouched.
*/
function readableReport(markdown: string): string {
return markdown.replace(/^[ \t]*<!--(?:SUMMARY|ANALYSIS)_(?:START|END)-->[ \t]*\n?/gm, '')
}
export function ReportsPage() {
const toast = useToast()
const { path, navigate } = useRouter()
const reports = useAsync(() => api.reports(), [])
const routeName = useMemo(() => {
const match = path.match(/^\/reports\/(.+)$/)
return match ? decodeURIComponent(match[1]) : null
}, [path])
const [detail, setDetail] = useState<ReportDetail | null>(null)
const [detailError, setDetailError] = useState<string | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [view, setView] = useState<View>('chart')
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
// Default to the newest report when landing on /reports.
useEffect(() => {
if (routeName || !reports.data?.length) return
navigate(`/reports/${encodeURIComponent(reports.data[0].name)}`, { replace: true })
}, [routeName, reports.data, navigate])
useEffect(() => {
if (!routeName) {
setDetail(null)
return
}
let cancelled = false
setLoadingDetail(true)
setDetailError(null)
api
.report(routeName)
.then((value) => {
if (!cancelled) setDetail(value)
})
.catch((cause: unknown) => {
if (!cancelled) setDetailError(cause instanceof Error ? cause.message : String(cause))
})
.finally(() => {
if (!cancelled) setLoadingDetail(false)
})
return () => {
cancelled = true
}
}, [routeName])
const parsed = useMemo(() => (detail ? parseReport(detail.content) : null), [detail])
const confirmDelete = async () => {
if (!pendingDelete) return
try {
await api.deleteReport(pendingDelete)
toast(`Deleted ${pendingDelete}`, 'success')
if (routeName === pendingDelete) {
setDetail(null)
navigate('/reports', { replace: true })
}
reports.reload()
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setPendingDelete(null)
}
}
const items = reports.data ?? []
return (
<>
<PageHeader
title="Reports"
subtitle="Every run writes a markdown report to the results folder. Open one to review answers and throughput."
/>
{reports.error && <ErrorNote message={reports.error} onRetry={reports.reload} />}
{!reports.loading && items.length === 0 && !reports.error ? (
<Card>
<EmptyState
icon={<FileText size={20} />}
title="No reports yet"
description="Run the diagnostic suite and the report will show up here."
/>
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
{/* ------------------------------------------------------- list */}
<div className="xl:col-span-4">
<Card className="xl:sticky xl:top-6">
<CardHeader
title="Saved reports"
icon={<LayoutList size={14} />}
actions={<span className="chip chip-neutral num">{items.length}</span>}
/>
{reports.loading ? (
<SkeletonRows rows={3} />
) : (
<ul className="max-h-[70vh] divide-y divide-navy-700/60 overflow-y-auto">
{items.map((report) => {
const active = report.name === routeName
return (
<li key={report.name}>
<div
className={cx(
'group relative flex items-center gap-2 px-4 py-3 transition-colors',
active ? 'bg-navy-750/60' : 'hover:bg-navy-800/40',
)}
>
{active && (
<span className="absolute top-1/2 left-0 h-8 w-[3px] -translate-y-1/2 rounded-r-full bg-ember-500" />
)}
<button
className="min-w-0 flex-1 text-left"
onClick={() =>
navigate(`/reports/${encodeURIComponent(report.name)}`)
}
>
<div
className={cx(
'truncate text-[0.8125rem] font-medium',
active ? 'text-ink-100' : 'text-ink-200',
)}
>
{report.model_label}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[0.6875rem] text-ink-500">
<span>{formatRelativeTime(report.modified_at)}</span>
<span>·</span>
<span className="num">{formatBytes(report.size_bytes)}</span>
</div>
</button>
<button
className="shrink-0 rounded-md p-1.5 text-ink-500 opacity-0 transition-all group-hover:opacity-100 hover:bg-rose-500/15 hover:text-rose-400 focus-visible:opacity-100"
onClick={() => setPendingDelete(report.name)}
aria-label={`Delete ${report.name}`}
title="Delete report"
>
<Trash2 size={13} />
</button>
</div>
</li>
)
})}
</ul>
)}
</Card>
</div>
{/* ----------------------------------------------------- detail */}
<div className="space-y-5 xl:col-span-8">
{detailError && <ErrorNote message={detailError} />}
{loadingDetail && (
<Card>
<SkeletonRows rows={5} />
</Card>
)}
{detail && parsed && !loadingDetail && (
<>
<Card raised>
<div className="p-4">
<h2 className="text-[1.05rem] font-semibold text-ink-100">
{parsed.modelLabel}
</h2>
<p className="mt-0.5 font-mono text-[0.6875rem] text-ink-500">{detail.name}</p>
<div
className={cx(
'mt-3.5 grid grid-cols-2 gap-2.5',
parsed.overallScore != null ? 'sm:grid-cols-5' : 'sm:grid-cols-4',
)}
>
{parsed.overallScore != null && (
<StatTile
label="Score"
value={`${Math.round(parsed.overallScore * 100)}%`}
tone={scoreTone(parsed.overallScore)}
hint={parsed.graders.join(', ')}
/>
)}
<StatTile
label="Avg tok/s"
value={parsed.averageTokensPerSecond?.toFixed(1) ?? '—'}
tone="gold"
/>
<StatTile
label="Avg TTFT"
value={parsed.averageTimeToFirstToken?.toFixed(2) ?? '—'}
unit="s"
tone="gold"
/>
<StatTile
label="Tokens"
value={parsed.totalTokens?.toLocaleString() ?? '—'}
tone="neutral"
/>
<StatTile label="Questions" value={parsed.tests.length} tone="neutral" />
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="chip chip-mint">
<CheckCircle2 size={11} />
{parsed.tests.filter((t) => t.ok).length} answered
</span>
{parsed.tests.some((t) => !t.ok) && (
<span className="chip chip-rose">
<TriangleAlert size={11} />
{parsed.tests.filter((t) => !t.ok).length} errored
</span>
)}
</div>
</div>
</Card>
<Card>
<CardHeader
title="Analysis"
icon={<BarChart3 size={14} />}
actions={
<div className="flex gap-1">
{(
[
['chart', 'Chart', BarChart3],
['table', 'Table', Table2],
['full', 'Report', FileText],
] as const
).map(([key, label, Icon]) => (
<button
key={key}
className={
view === key
? 'btn btn-sm border-gold-500/40 bg-gold-500/15 text-gold-300'
: 'btn btn-ghost btn-sm'
}
onClick={() => setView(key)}
>
<Icon size={12} />
{label}
</button>
))}
</div>
}
/>
{view === 'chart' && <ThroughputChart tests={parsed.tests} />}
{view === 'table' && (
<div className="overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead>
<tr className="border-b border-navy-700 text-left text-[0.6875rem] tracking-wide text-ink-500 uppercase">
<th className="px-4 py-2.5 font-semibold">#</th>
<th className="px-3 py-2.5 font-semibold">Question</th>
{parsed.overallScore != null && (
<th className="px-3 py-2.5 text-right font-semibold">Score</th>
)}
<th className="px-3 py-2.5 text-right font-semibold">tok/s</th>
<th className="px-3 py-2.5 text-right font-semibold">Tokens</th>
<th className="px-3 py-2.5 text-right font-semibold">TTFT</th>
<th className="px-4 py-2.5 font-semibold">Stop</th>
</tr>
</thead>
<tbody className="divide-y divide-navy-700/60">
{parsed.tests.map((test) => (
<tr key={test.index} className="hover:bg-navy-800/40">
<td className="num px-4 py-2.5 text-ink-500">{test.index}</td>
<td className="max-w-xs px-3 py-2.5">
<span className="block truncate text-ink-200" title={test.title}>
{test.title}
</span>
{!test.ok && (
<span className="mt-0.5 flex items-center gap-1 text-[0.6875rem] text-rose-400">
<TriangleAlert size={10} />
Error: {test.error}
</span>
)}
</td>
{parsed.overallScore != null && (
<td className="px-3 py-2.5 text-right">
{test.score != null ? (
<ScoreBadge score={test.score} />
) : (
<span className="text-ink-500"></span>
)}
</td>
)}
<td className="num px-3 py-2.5 text-right text-gold-400">
{test.tokensPerSecond?.toFixed(1) ?? '—'}
</td>
<td className="num px-3 py-2.5 text-right text-ink-300">
{test.totalTokens?.toLocaleString() ?? '—'}
</td>
<td className="num px-3 py-2.5 text-right text-ink-300">
{test.timeToFirstToken != null
? `${test.timeToFirstToken.toFixed(2)}s`
: '—'}
</td>
<td className="px-4 py-2.5 text-ink-500">{test.stopReason ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{view === 'full' && (
<div className="max-h-[75vh] overflow-y-auto px-4 py-4">
<Markdown>{readableReport(detail.content)}</Markdown>
</div>
)}
</Card>
</>
)}
</div>
</div>
)}
<ConfirmDialog
open={!!pendingDelete}
title="Delete this report?"
body={`${pendingDelete} will be removed from the results folder. This cannot be undone.`}
confirmLabel="Delete"
destructive
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</>
)
}
+619
View File
@@ -0,0 +1,619 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Ban,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleSlash,
Clock,
FileText,
ListChecks,
Play,
RefreshCw,
Terminal,
TriangleAlert,
Zap,
} from 'lucide-react'
import {
Card,
CardHeader,
EmptyState,
ErrorNote,
PageHeader,
Spinner,
StatTile,
TemperatureSlider,
useAsync,
useToast,
} from '../components/ui'
import { Markdown } from '../components/Markdown'
import { ScoreBadge, scoreTone } from '../components/ScoreBadge'
import { Link } from '../lib/router'
import { api, ApiError, type TestCompletedEvent } from '../lib/api'
import { clockTime, cx, formatDuration } from '../lib/format'
import { useRunFeed } from '../hooks/useRunFeed'
export function RunPage() {
const toast = useToast()
const { run, outcomes, logs, starting, isLive, start, cancel, clear } = useRunFeed()
const providers = useAsync(() => api.providers(), [])
const suite = useAsync(() => api.tests(), [])
const [provider, setProvider] = useState('')
const [modelId, setModelId] = useState('')
const [temperature, setTemperature] = useState(0.1)
const [selected, setSelected] = useState<string[] | null>(null)
const [pickerOpen, setPickerOpen] = useState(false)
// Seed provider + temperature from saved settings, falling back to the
// provider the server marks as default.
useEffect(() => {
if (!providers.data?.length || provider) return
api
.settings()
.then((settings) => {
const preferred = providers.data?.find((p) => p.name === settings.default_provider)
setProvider(preferred?.name ?? providers.data?.find((p) => p.is_default)?.name ?? providers.data![0].name)
setTemperature(settings.default_temperature)
})
.catch(() => {
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 tests = suite.data?.tests ?? []
const selectedFilenames = selected ?? tests.map((t) => t.filename)
const allSelected = selectedFilenames.length === tests.length
const toggleTest = (filename: string) => {
const next = new Set(selectedFilenames)
if (next.has(filename)) next.delete(filename)
else next.add(filename)
setSelected(tests.filter((t) => next.has(t.filename)).map((t) => t.filename))
}
const launch = async () => {
if (!provider || !modelId) return
try {
await start({
provider,
model_id: modelId,
temperature,
filenames: allSelected ? undefined : selectedFilenames,
})
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
}
}
const doCancel = async () => {
try {
await cancel()
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
}
}
const canLaunch =
!!provider && !!modelId && selectedFilenames.length > 0 && !isLive && !starting
return (
<>
<PageHeader
title="Diagnostic Run"
subtitle="Every question is sent to the model one at a time. Results stream in as each one finishes."
actions={
run && !isLive ? (
<button className="btn btn-ghost" onClick={clear}>
<RefreshCw size={14} />
New run
</button>
) : null
}
/>
<div className="grid gap-5 xl:grid-cols-12">
{/* ------------------------------------------------ launch controls */}
<div className="space-y-5 xl:col-span-4">
<Card raised>
<CardHeader title="Target" icon={<Zap size={14} />} />
<div className="space-y-4 p-4">
{providers.error && <ErrorNote message={providers.error} onRetry={providers.reload} />}
<div>
<label className="label" htmlFor="provider">
Provider
</label>
<select
id="provider"
className="field"
value={provider}
disabled={isLive || providers.loading}
onChange={(event) => setProvider(event.target.value)}
>
{(providers.data ?? []).map((item) => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between">
<label className="label" htmlFor="model">
Model
</label>
<button
className="mb-1.5 flex items-center gap-1 text-[0.6875rem] text-ink-500 transition-colors hover:text-ember-400 disabled:opacity-50"
onClick={models.reload}
disabled={isLive || models.loading}
>
{models.loading ? <Spinner className="h-3 w-3" /> : <RefreshCw size={11} />}
Refresh
</button>
</div>
{models.error ? (
<ErrorNote message={models.error} onRetry={models.reload} />
) : (
<select
id="model"
className="field"
value={modelId}
disabled={isLive || models.loading || !models.data?.models.length}
onChange={(event) => setModelId(event.target.value)}
>
{models.loading && <option>Discovering models</option>}
{!models.loading && !models.data?.models.length && <option>No models found</option>}
{(models.data?.models ?? []).map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
</select>
)}
</div>
<TemperatureSlider value={temperature} onChange={setTemperature} disabled={isLive} />
</div>
</Card>
{/* question selection */}
<Card raised>
<CardHeader
title="Questions"
icon={<ListChecks size={14} />}
actions={
<span className="chip chip-gold num">
{selectedFilenames.length}/{tests.length}
</span>
}
/>
<div className="p-4">
{suite.error && <ErrorNote message={suite.error} onRetry={suite.reload} />}
{!suite.error && tests.length === 0 && !suite.loading && (
<EmptyState
icon={<ListChecks size={20} />}
title="No questions yet"
description="Add questions in the Suite Builder before running diagnostics."
action={
<Link to="/suite" className="btn btn-primary">
Open Suite Builder
</Link>
}
/>
)}
{tests.length > 0 && (
<>
<div className="mb-3 flex items-center gap-2">
<button
className="btn btn-ghost btn-sm"
disabled={isLive}
onClick={() => setSelected(allSelected ? [] : tests.map((t) => t.filename))}
>
{allSelected ? 'Clear all' : 'Select all'}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setPickerOpen((open) => !open)}
>
{pickerOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{pickerOpen ? 'Hide list' : 'Choose questions'}
</button>
</div>
{pickerOpen && (
<ul className="animate-fade-in max-h-72 space-y-1 overflow-y-auto pr-1">
{tests.map((test, index) => {
const checked = selectedFilenames.includes(test.filename)
return (
<li key={test.filename}>
<label
className={cx(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-2.5 py-2 transition-colors',
checked
? 'border-gold-500/35 bg-gold-500/8'
: 'border-navy-700 bg-navy-900/30 hover:border-navy-600',
isLive && 'cursor-not-allowed opacity-60',
)}
>
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-[#e3ad46]"
checked={checked}
disabled={isLive}
onChange={() => toggleTest(test.filename)}
/>
<span className="min-w-0">
<span className="num mr-1.5 text-[0.6875rem] text-ink-500">
{String(index + 1).padStart(2, '0')}
</span>
<span className="text-[0.75rem] text-ink-200">{test.title}</span>
</span>
</label>
</li>
)
})}
</ul>
)}
{!pickerOpen && (
<p className="text-[0.75rem] text-ink-500">
{allSelected
? `The full suite of ${tests.length} questions will run in order.`
: `${selectedFilenames.length} of ${tests.length} questions selected.`}
</p>
)}
</>
)}
</div>
</Card>
<div className="flex gap-2">
{isLive ? (
<button className="btn btn-danger flex-1" onClick={doCancel}>
<Ban size={15} />
Cancel run
</button>
) : (
<button className="btn btn-primary flex-1" onClick={launch} disabled={!canLaunch}>
{starting ? <Spinner /> : <Play size={15} />}
{starting ? 'Starting…' : 'Run diagnostics'}
</button>
)}
</div>
</div>
{/* ------------------------------------------------------ live feed */}
<div className="space-y-5 xl:col-span-8">
<RunStatusPanel />
<ResultsFeed outcomes={outcomes} live={isLive} hasRun={!!run} />
{logs.length > 0 && <ConsoleLog />}
</div>
</div>
</>
)
}
/* ------------------------------------------------------------------ panels */
function RunStatusPanel() {
const { run, outcomes, isLive } = useRunFeed()
if (!run) {
return (
<Card>
<EmptyState
icon={<Play size={20} />}
title="Ready when you are"
description="Pick a provider and model, choose which questions to ask, then start the run. Progress appears here live."
/>
</Card>
)
}
const percent = run.total ? Math.round((outcomes.length / run.total) * 100) : 0
const elapsed = (run.finished_at ?? Date.now() / 1000) - run.started_at
const statusChip = {
running: <span className="chip chip-ember">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-ember-400" />
Running
</span>,
completed: <span className="chip chip-mint"><CheckCircle2 size={11} />Complete</span>,
cancelled: <span className="chip chip-neutral"><CircleSlash size={11} />Cancelled</span>,
failed: <span className="chip chip-rose"><TriangleAlert size={11} />Failed</span>,
}[run.status]
return (
<Card raised>
<div className="p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{statusChip}
<h2 className="truncate text-[0.9375rem] font-semibold text-ink-100">
{run.model_label}
</h2>
</div>
<p className="mt-0.5 text-[0.75rem] text-ink-500">
{run.provider} · T={run.temperature} · started {clockTime(run.started_at * 1000)}
</p>
</div>
{run.report_name && (
<Link to={`/reports/${encodeURIComponent(run.report_name)}`} className="btn btn-ghost btn-sm">
<FileText size={13} />
Open report
</Link>
)}
</div>
{/* progress */}
<div className="mb-4">
<div className="mb-1.5 flex items-baseline justify-between text-[0.75rem]">
<span className="text-ink-400">
{outcomes.length} of {run.total} questions
</span>
<span className="num font-semibold text-gold-400">{percent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-navy-800">
<div
className={cx(
'h-full rounded-full transition-[width] duration-500 ease-out',
run.status === 'failed'
? 'bg-rose-500'
: 'bg-gradient-to-r from-ember-500 to-gold-400',
)}
style={{ width: `${Math.max(percent, 2)}%` }}
/>
</div>
</div>
{run.error && <ErrorNote message={run.error} />}
<div
className={cx(
'grid grid-cols-2 gap-2.5',
run.summary.overall_score != null ? 'sm:grid-cols-5' : 'sm:grid-cols-4',
)}
>
{run.summary.overall_score != null && (
<StatTile
label="Score"
value={`${Math.round(run.summary.overall_score * 100)}%`}
tone={scoreTone(run.summary.overall_score)}
hint={`${run.summary.graded} graded`}
/>
)}
<StatTile
label="Avg tok/s"
value={run.summary.average_tokens_per_second.toFixed(1)}
tone="gold"
/>
<StatTile
label="Avg TTFT"
value={run.summary.average_time_to_first_token.toFixed(2)}
unit="s"
tone="gold"
/>
<StatTile label="Tokens" value={run.summary.total_tokens.toLocaleString()} tone="neutral" />
<StatTile
label={isLive ? 'Elapsed' : 'Duration'}
value={formatDuration(elapsed)}
tone="neutral"
/>
</div>
{(run.summary.passed > 0 || run.summary.failed > 0) && (
<div className="mt-2.5 flex items-center gap-2 text-[0.75rem]">
<span className="chip chip-mint">
<CheckCircle2 size={11} />
{run.summary.passed} answered
</span>
{run.summary.failed > 0 && (
<span className="chip chip-rose">
<TriangleAlert size={11} />
{run.summary.failed} errored
</span>
)}
</div>
)}
</div>
</Card>
)
}
function ResultsFeed({
outcomes,
live,
hasRun,
}: {
outcomes: TestCompletedEvent[]
live: boolean
hasRun: boolean
}) {
if (!hasRun) return null
return (
<Card>
<CardHeader
title="Results"
icon={<ListChecks size={14} />}
hint={live ? 'Streaming as each question completes' : undefined}
actions={<span className="chip chip-neutral num">{outcomes.length}</span>}
/>
{outcomes.length === 0 ? (
<div className="flex items-center gap-2.5 px-4 py-8 text-[0.8125rem] text-ink-400">
<Spinner className="text-ember-400" />
Waiting on the first response the model may need to load into memory.
</div>
) : (
<ul className="divide-y divide-navy-700/70">
{outcomes.map((outcome) => (
<ResultRow key={outcome.index} outcome={outcome} />
))}
{live && (
<li className="flex items-center gap-2.5 px-4 py-3.5 text-[0.8125rem] text-ink-400">
<Spinner className="text-ember-400" />
Running question {outcomes.length + 1}
</li>
)}
</ul>
)}
</Card>
)
}
function ResultRow({ outcome }: { outcome: TestCompletedEvent }) {
const [open, setOpen] = useState(false)
const ok = outcome.status === 'ok'
return (
<li className="animate-fade-up">
<button
className="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-navy-800/40"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<span className="mt-0.5 shrink-0">
{ok ? (
<CheckCircle2 size={15} className="text-mint-400" />
) : (
<TriangleAlert size={15} className="text-rose-400" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="num text-[0.6875rem] text-ink-500">
{String(outcome.index).padStart(2, '0')}
</span>
<span className="truncate text-[0.8125rem] font-medium text-ink-100">
{outcome.title}
</span>
{outcome.score != null && (
<span className="ml-auto shrink-0">
<ScoreBadge
score={outcome.score}
title={outcome.grades
.map((g) => `${g.grader}: ${Math.round(g.score * 100)}%`)
.join('\n')}
/>
</span>
)}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.6875rem] text-ink-500">
<span className="font-mono">{outcome.filename}</span>
{ok && outcome.metrics && (
<>
<span className="num text-gold-400">
{outcome.metrics.tokens_per_second} tok/s
</span>
<span className="num">{outcome.metrics.total_tokens} tokens</span>
<span className="num">TTFT {outcome.metrics.time_to_first_token}s</span>
<span className="num">
<Clock size={9} className="mr-0.5 inline" />
{formatDuration(outcome.elapsed)}
</span>
</>
)}
{!ok && <span className="truncate text-rose-400">{outcome.error}</span>}
</span>
</span>
<ChevronRight
size={15}
className={cx('mt-0.5 shrink-0 text-ink-500 transition-transform', open && 'rotate-90')}
/>
</button>
{open && (
<div className="animate-fade-in border-t border-navy-700/60 bg-navy-950/40 px-4 py-3.5">
{outcome.grades.length > 0 && (
<div className="mb-3.5 space-y-1.5 rounded-xl border border-navy-700 bg-navy-900/50 px-3.5 py-3">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
Plugin grades
</div>
{outcome.grades.map((grade) => (
<div key={grade.grader} className="flex items-start gap-2.5 text-[0.75rem]">
<ScoreBadge score={grade.score} />
<div className="min-w-0">
<span className="text-ink-200">{grade.grader}</span>
{grade.label && <span className="ml-1.5 text-ink-500">({grade.label})</span>}
{grade.notes && (
<div className="text-[0.6875rem] text-ink-500">{grade.notes}</div>
)}
</div>
</div>
))}
</div>
)}
{ok ? (
<Markdown>{outcome.response ?? '_No response body._'}</Markdown>
) : (
<ErrorNote message={outcome.error ?? 'Unknown error'} />
)}
</div>
)}
</li>
)
}
function ConsoleLog() {
const { logs } = useRunFeed()
const [open, setOpen] = useState(false)
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) endRef.current?.scrollIntoView({ block: 'nearest' })
}, [logs, open])
const toneClass = useMemo(
() => ({
info: 'text-ink-400',
ok: 'text-mint-400',
warn: 'text-gold-400',
error: 'text-rose-400',
}),
[],
)
return (
<Card>
<CardHeader
title="Activity log"
icon={<Terminal size={14} />}
actions={
<button className="btn btn-ghost btn-sm" onClick={() => setOpen((value) => !value)}>
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{open ? 'Collapse' : 'Expand'}
</button>
}
/>
{open && (
<div className="max-h-64 overflow-y-auto px-4 py-3 font-mono text-[0.6875rem] leading-relaxed">
{logs.map((line) => (
<div key={line.id} className="flex gap-2.5">
<span className="shrink-0 text-ink-500">{clockTime(line.at)}</span>
<span className={cx('min-w-0 break-words', toneClass[line.tone])}>{line.message}</span>
</div>
))}
<div ref={endRef} />
</div>
)}
</Card>
)
}
+434
View File
@@ -0,0 +1,434 @@
import { useCallback, useEffect, useState } from 'react'
import {
Blocks,
CircleSlash,
Cpu,
FolderOpen,
HardDrive,
Info,
Plus,
RefreshCw,
RotateCcw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
TriangleAlert,
} from 'lucide-react'
import {
Card,
CardHeader,
ErrorNote,
PageHeader,
SkeletonRows,
Spinner,
TemperatureSlider,
useAsync,
useToast,
} from '../components/ui'
import { api, ApiError, type ModelPathEntry, type PluginSummary } from '../lib/api'
import { cx } from '../lib/format'
export function SettingsPage() {
const toast = useToast()
const providers = useAsync(() => api.providers(), [])
const system = useAsync(() => api.system(), [])
const [defaultProvider, setDefaultProvider] = useState('')
const [defaultTemperature, setDefaultTemperature] = useState(0.1)
const [paths, setPaths] = useState<ModelPathEntry[]>([])
const [dirs, setDirs] = useState<{ tests: string; results: string; models: string } | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
setLoading(true)
setLoadError(null)
api
.settings()
.then((settings) => {
setDefaultProvider(settings.default_provider)
setDefaultTemperature(settings.default_temperature)
setPaths(settings.local_model_paths)
setDirs({
tests: settings.tests_dir,
results: settings.results_dir,
models: settings.models_dir,
})
})
.catch((cause: unknown) =>
setLoadError(cause instanceof Error ? cause.message : String(cause)),
)
.finally(() => setLoading(false))
}, [])
useEffect(load, [load])
const save = async () => {
setSaving(true)
try {
const saved = await api.saveSettings({
default_provider: defaultProvider,
default_temperature: defaultTemperature,
local_model_paths: paths.filter((entry) => entry.path.trim()),
})
setPaths(saved.local_model_paths)
toast('Settings saved.', 'success')
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setSaving(false)
}
}
const updatePath = (index: number, patch: Partial<ModelPathEntry>) =>
setPaths((list) => list.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
return (
<>
<PageHeader
title="Settings"
subtitle="Defaults for new runs, plus where the Local Engine looks for model weights."
actions={
<>
<button className="btn btn-ghost" onClick={load} disabled={saving || loading}>
<RotateCcw size={14} />
Reload
</button>
<button className="btn btn-primary" onClick={save} disabled={saving || loading}>
{saving ? <Spinner /> : <Save size={14} />}
{saving ? 'Saving…' : 'Save settings'}
</button>
</>
}
/>
{loadError && <ErrorNote message={loadError} onRetry={load} />}
{loading ? (
<Card>
<SkeletonRows rows={4} />
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-5 xl:col-span-7">
<Card raised>
<CardHeader
title="Run defaults"
icon={<SlidersHorizontal size={14} />}
hint="Applied when the Run page loads"
/>
<div className="space-y-4 p-4">
<div>
<label className="label" htmlFor="default-provider">
Default provider
</label>
<select
id="default-provider"
className="field"
value={defaultProvider}
onChange={(event) => setDefaultProvider(event.target.value)}
>
{(providers.data ?? []).map((item) => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</select>
</div>
<TemperatureSlider value={defaultTemperature} onChange={setDefaultTemperature} />
</div>
</Card>
<Card raised>
<CardHeader
title="Model search paths"
icon={<FolderOpen size={14} />}
hint="Folders scanned for .gguf and MLX weights"
actions={
<button
className="btn btn-ghost btn-sm"
onClick={() => setPaths((list) => [...list, { nickname: '', path: '' }])}
>
<Plus size={12} />
Add
</button>
}
/>
<div className="space-y-2.5 p-4">
{paths.length === 0 && (
<p className="py-2 text-[0.8125rem] text-ink-500">
No extra paths. The engine still scans the bundled{' '}
<code className="font-mono text-[0.75rem] text-gold-300">models/</code> folder.
</p>
)}
{paths.map((entry, index) => (
<div key={index} className="flex items-start gap-2">
<input
className="field w-32 shrink-0"
placeholder="Nickname"
value={entry.nickname}
onChange={(event) => updatePath(index, { nickname: event.target.value })}
/>
<input
className="field min-w-0 flex-1 font-mono text-[0.75rem]"
placeholder="/path/to/models"
value={entry.path}
spellCheck={false}
onChange={(event) => updatePath(index, { path: event.target.value })}
/>
<button
className="mt-1 shrink-0 rounded-md p-1.5 text-ink-500 transition-colors hover:bg-rose-500/15 hover:text-rose-400"
onClick={() => setPaths((list) => list.filter((_, i) => i !== index))}
aria-label="Remove path"
>
<Trash2 size={14} />
</button>
</div>
))}
<div className="flex items-start gap-2 pt-1 text-[0.75rem] text-ink-500">
<Info size={13} className="mt-0.5 shrink-0 text-gold-600" />
<p>
Nicknames are for your reference only. Paths are shared with the CLI through{' '}
<code className="font-mono text-[0.6875rem]">.core/user_settings.json</code>,
and <code className="font-mono text-[0.6875rem]">LOCAL_LLM_PATHS</code> is still
honoured on top of them.
</p>
</div>
</div>
</Card>
</div>
<div className="space-y-5 xl:col-span-5">
<Card>
<CardHeader title="Engine" icon={<Cpu size={14} />} />
<div className="p-4">
{system.error && <ErrorNote message={system.error} onRetry={system.reload} />}
{system.data && (
<dl className="space-y-2.5 text-[0.8125rem]">
<InfoRow label="Runtime" value={system.data.engine_runtime} mono />
<InfoRow label="Architecture" value={system.data.engine_architecture} />
<InfoRow label="Platform" value={system.data.metrics.platform ?? '—'} />
<InfoRow label="Python" value={system.data.python_version} mono />
<InfoRow label="LM-Gambit" value={`v${system.data.version}`} mono />
<InfoRow
label="Report template"
value={system.data.template_ok ? 'Found' : 'Missing'}
tone={system.data.template_ok ? 'ok' : 'bad'}
/>
</dl>
)}
</div>
</Card>
<Card>
<CardHeader title="Folders" icon={<HardDrive size={14} />} />
<div className="space-y-3 p-4">
{dirs && (
<>
<PathRow label="Questions" path={dirs.tests} />
<PathRow label="Reports" path={dirs.results} />
<PathRow label="Bundled models" path={dirs.models} />
</>
)}
</div>
</Card>
<PluginsCard />
<Card>
<CardHeader title="Environment overrides" icon={<Settings2 size={14} />} />
<div className="space-y-2 p-4 text-[0.75rem] text-ink-400">
{[
['LM_STUDIO_BASE_URL', 'LM Studio endpoint'],
['AUTO_TEST_TEMPERATURE', 'Default temperature'],
['AUTO_TEST_PROVIDER', 'Default provider for the CLI'],
['LOCAL_LLM_PATHS', 'Extra model directories'],
].map(([name, description]) => (
<div key={name} className="flex items-baseline justify-between gap-3">
<code className="font-mono text-[0.6875rem] text-gold-300">{name}</code>
<span className="text-right text-ink-500">{description}</span>
</div>
))}
</div>
</Card>
</div>
</div>
)}
</>
)
}
function PluginsCard() {
const toast = useToast()
const plugins = useAsync(() => api.plugins(), [])
const [reloading, setReloading] = useState(false)
const reload = async () => {
setReloading(true)
try {
const next = await api.reloadPlugins()
plugins.setData(next)
const broken = next.filter((p) => p.error).length
toast(
broken
? `Reloaded ${next.length} plugin(s) — ${broken} failed to load.`
: `Reloaded ${next.length} plugin(s).`,
broken ? 'error' : 'success',
)
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setReloading(false)
}
}
const items = plugins.data ?? []
return (
<Card>
<CardHeader
title="Plugins"
icon={<Blocks size={14} />}
hint="Dropped into plugins/"
actions={
<button className="btn btn-ghost btn-sm" onClick={reload} disabled={reloading}>
{reloading ? <Spinner className="h-3 w-3" /> : <RefreshCw size={12} />}
Reload
</button>
}
/>
<div className="p-4">
{plugins.error && <ErrorNote message={plugins.error} onRetry={plugins.reload} />}
{!plugins.loading && items.length === 0 && !plugins.error && (
<p className="text-[0.8125rem] text-ink-500">
No plugins yet. Copy{' '}
<code className="font-mono text-[0.75rem] text-gold-300">plugins/_skeleton.py</code> to
a new name to write one.
</p>
)}
<div className="space-y-2.5">
{items.map((plugin) => (
<PluginRow key={plugin.slug} plugin={plugin} />
))}
</div>
{items.some((p) => p.hooks.includes('register_routes')) && (
<p className="mt-3 flex items-start gap-1.5 text-[0.6875rem] text-ink-500">
<Info size={12} className="mt-0.5 shrink-0 text-gold-600" />
Reloading picks up new graders and hooks. Plugins that add HTTP routes need a full
server restart.
</p>
)}
</div>
</Card>
)
}
function PluginRow({ plugin }: { plugin: PluginSummary }) {
const broken = !!plugin.error
const disabled = !plugin.enabled && !broken
return (
<div
className={cx(
'rounded-xl border px-3.5 py-2.5',
broken
? 'border-rose-500/40 bg-rose-500/8'
: disabled
? 'border-navy-700 bg-navy-900/30 opacity-65'
: 'border-navy-700 bg-navy-900/40',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
{broken ? (
<TriangleAlert size={12} className="shrink-0 text-rose-400" />
) : disabled ? (
<CircleSlash size={12} className="shrink-0 text-ink-500" />
) : (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-mint-400" />
)}
<span className="truncate text-[0.8125rem] font-medium text-ink-100">
{plugin.name}
</span>
<span className="num shrink-0 text-[0.6875rem] text-ink-500">v{plugin.version}</span>
</div>
{plugin.description && (
<p className="mt-0.5 text-[0.75rem] text-ink-400">{plugin.description}</p>
)}
</div>
<code className="shrink-0 font-mono text-[0.625rem] text-ink-500">{plugin.slug}</code>
</div>
{broken && (
<p className="mt-1.5 font-mono text-[0.6875rem] break-words text-rose-400">
{plugin.error}
</p>
)}
{disabled && (
<p className="mt-1.5 text-[0.6875rem] text-ink-500">
Disabled by <code className="font-mono">ENABLED = False</code>.
</p>
)}
{!broken && plugin.hooks.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{plugin.hooks.map((hook) => (
<span key={hook} className="chip chip-neutral font-mono !text-[0.625rem]">
{hook}
</span>
))}
</div>
)}
</div>
)
}
function InfoRow({
label,
value,
mono,
tone,
}: {
label: string
value: string
mono?: boolean
tone?: 'ok' | 'bad'
}) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-ink-500">{label}</dt>
<dd
className={[
'min-w-0 truncate text-right',
mono ? 'font-mono text-[0.75rem]' : '',
tone === 'ok' ? 'text-mint-400' : tone === 'bad' ? 'text-rose-400' : 'text-ink-200',
].join(' ')}
title={value}
>
{value}
</dd>
</div>
)
}
function PathRow({ label, path }: { label: string; path: string }) {
return (
<div>
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
{label}
</div>
<div className="mt-0.5 font-mono text-[0.6875rem] break-all text-ink-300" title={path}>
{path}
</div>
</div>
)
}
+441
View File
@@ -0,0 +1,441 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Copy,
Info,
ListChecks,
Plus,
RotateCcw,
Save,
Trash2,
} from 'lucide-react'
import {
Card,
ConfirmDialog,
EmptyState,
ErrorNote,
PageHeader,
SkeletonRows,
Spinner,
useToast,
} from '../components/ui'
import { api, ApiError } from '../lib/api'
import { cx, deriveTitle } from '../lib/format'
import { useRunFeed } from '../hooks/useRunFeed'
interface Draft {
key: string
prompt: string
filename: string | null
}
let keySeed = 1
const newKey = () => `q${keySeed++}`
export function SuitePage() {
const toast = useToast()
const { isLive } = useRunFeed()
const [drafts, setDrafts] = useState<Draft[]>([])
const [baseline, setBaseline] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const [focusKey, setFocusKey] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
setLoadError(null)
api
.tests()
.then((suite) => {
setDrafts(
suite.tests.map((test) => ({
key: newKey(),
prompt: test.prompt,
filename: test.filename,
})),
)
setBaseline(suite.tests.map((test) => test.prompt))
})
.catch((cause: unknown) =>
setLoadError(cause instanceof Error ? cause.message : String(cause)),
)
.finally(() => setLoading(false))
}, [])
useEffect(load, [load])
const current = drafts.map((draft) => draft.prompt)
const dirty =
current.length !== baseline.length ||
current.some((prompt, index) => prompt.trim() !== baseline[index]?.trim())
// Guard against losing edits on reload / close.
useEffect(() => {
if (!dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty])
const update = (key: string, prompt: string) =>
setDrafts((list) => list.map((d) => (d.key === key ? { ...d, prompt } : d)))
const addQuestion = () => {
const key = newKey()
setDrafts((list) => [...list, { key, prompt: '', filename: null }])
setFocusKey(key)
}
const duplicate = (key: string) =>
setDrafts((list) => {
const index = list.findIndex((d) => d.key === key)
if (index < 0) return list
const copy = { key: newKey(), prompt: list[index].prompt, filename: null }
return [...list.slice(0, index + 1), copy, ...list.slice(index + 1)]
})
const remove = (key: string) => setDrafts((list) => list.filter((d) => d.key !== key))
const move = (key: string, direction: -1 | 1) =>
setDrafts((list) => {
const index = list.findIndex((d) => d.key === key)
const target = index + direction
if (index < 0 || target < 0 || target >= list.length) return list
const next = [...list]
;[next[index], next[target]] = [next[target], next[index]]
return next
})
const save = async () => {
const blank = drafts.findIndex((d) => !d.prompt.trim())
if (blank >= 0) {
toast(`Question ${blank + 1} is empty. Add a prompt or remove it.`, 'error')
return
}
setSaving(true)
try {
const suite = await api.saveTests(drafts.map((d) => d.prompt))
setDrafts(
suite.tests.map((test) => ({
key: newKey(),
prompt: test.prompt,
filename: test.filename,
})),
)
setBaseline(suite.tests.map((test) => test.prompt))
toast(
`Saved ${suite.tests.length} question${suite.tests.length === 1 ? '' : 's'} to the suite.`,
'success',
)
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setSaving(false)
}
}
const deleting = drafts.find((d) => d.key === pendingDelete)
return (
<>
<PageHeader
title="Suite Builder"
subtitle="Each box is one question. They are sent to the model one at a time, in this order."
actions={
<>
{dirty && (
<button
className="btn btn-ghost"
onClick={() => setConfirmDiscard(true)}
disabled={saving}
>
<RotateCcw size={14} />
Discard
</button>
)}
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving || isLive}>
{saving ? <Spinner /> : <Save size={14} />}
{saving ? 'Saving…' : dirty ? 'Save suite' : 'Saved'}
</button>
</>
}
/>
{isLive && (
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-ember-500/35 bg-ember-500/8 px-4 py-3">
<Info size={15} className="mt-0.5 shrink-0 text-ember-400" />
<p className="text-[0.8125rem] text-ember-300">
A run is in progress. You can edit questions, but saving is blocked until it finishes.
</p>
</div>
)}
{loadError && <ErrorNote message={loadError} onRetry={load} />}
{loading ? (
<Card>
<SkeletonRows rows={4} />
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-3 xl:col-span-9">
{drafts.length === 0 ? (
<Card>
<EmptyState
icon={<ListChecks size={20} />}
title="The suite is empty"
description="Add your first question to start benchmarking."
action={
<button className="btn btn-primary" onClick={addQuestion}>
<Plus size={14} />
Add question
</button>
}
/>
</Card>
) : (
drafts.map((draft, index) => (
<QuestionCard
key={draft.key}
draft={draft}
index={index}
total={drafts.length}
autoFocus={focusKey === draft.key}
onFocused={() => setFocusKey(null)}
onChange={(prompt) => update(draft.key, prompt)}
onMoveUp={() => move(draft.key, -1)}
onMoveDown={() => move(draft.key, 1)}
onDuplicate={() => duplicate(draft.key)}
onDelete={() =>
draft.prompt.trim() ? setPendingDelete(draft.key) : remove(draft.key)
}
/>
))
)}
{drafts.length > 0 && (
<button
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-navy-600 py-3.5 text-[0.8125rem] font-medium text-ink-400 transition-colors hover:border-ember-500/60 hover:bg-ember-500/5 hover:text-ember-400"
onClick={addQuestion}
>
<Plus size={15} />
Add question
</button>
)}
</div>
<aside className="xl:col-span-3">
<div className="sticky top-6 space-y-3">
<Card>
<div className="p-4">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
Suite
</div>
<div className="num mt-1 text-[1.6rem] font-semibold text-gold-400">
{drafts.length}
</div>
<div className="text-[0.75rem] text-ink-500">
question{drafts.length === 1 ? '' : 's'}
{dirty && <span className="ml-1.5 text-ember-400">· unsaved</span>}
</div>
</div>
</Card>
<Card>
<div className="space-y-2.5 p-4 text-[0.75rem] leading-relaxed text-ink-400">
<p className="flex items-center gap-1.5 font-semibold text-ink-300">
<Info size={13} className="text-gold-500" />
How saving works
</p>
<p>
The whole prompt is sent to the model. Its{' '}
<span className="text-ink-200">first line</span> doubles as the title in reports,
so lead with the task.
</p>
<p>
Saving rewrites the suite as{' '}
<code className="rounded bg-navy-750 px-1 py-0.5 font-mono text-[0.6875rem] text-gold-300">
test1.txt testN.txt
</code>{' '}
in this order, so the CLI (
<code className="font-mono text-[0.6875rem]">auto-test.py</code>) sees exactly
what you see here.
</p>
</div>
</Card>
</div>
</aside>
</div>
)}
<ConfirmDialog
open={confirmDiscard}
title="Discard changes?"
body="Your unsaved edits will be replaced with the questions currently on disk."
confirmLabel="Discard"
destructive
onConfirm={() => {
setConfirmDiscard(false)
load()
}}
onCancel={() => setConfirmDiscard(false)}
/>
<ConfirmDialog
open={!!deleting}
title="Delete this question?"
body={deriveTitle(deleting?.prompt ?? '', 'This question')}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (pendingDelete) remove(pendingDelete)
setPendingDelete(null)
}}
onCancel={() => setPendingDelete(null)}
/>
</>
)
}
/* ------------------------------------------------------------------- card */
function QuestionCard({
draft,
index,
total,
autoFocus,
onFocused,
onChange,
onMoveUp,
onMoveDown,
onDuplicate,
onDelete,
}: {
draft: Draft
index: number
total: number
autoFocus: boolean
onFocused: () => void
onChange: (prompt: string) => void
onMoveUp: () => void
onMoveDown: () => void
onDuplicate: () => void
onDelete: () => void
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const title = deriveTitle(draft.prompt, 'Untitled question')
const empty = !draft.prompt.trim()
const words = draft.prompt.trim() ? draft.prompt.trim().split(/\s+/).length : 0
// Grow the box to fit its content so long prompts stay readable.
useLayoutEffect(() => {
const node = textareaRef.current
if (!node) return
node.style.height = 'auto'
node.style.height = `${Math.max(node.scrollHeight, 92)}px`
}, [draft.prompt])
useEffect(() => {
if (autoFocus) {
textareaRef.current?.focus()
onFocused()
}
}, [autoFocus, onFocused])
return (
<Card
className={cx('animate-fade-up overflow-hidden', empty && 'border-ember-500/40')}
>
<div className="flex items-center gap-3 border-b border-navy-700 px-3.5 py-2.5">
<span
className={cx(
'num flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[0.6875rem] font-semibold',
empty
? 'bg-ember-500/15 text-ember-400'
: 'bg-gold-500/12 text-gold-400',
)}
>
{index + 1}
</span>
<span
className={cx(
'min-w-0 flex-1 truncate text-[0.8125rem] font-medium',
empty ? 'text-ink-500 italic' : 'text-ink-100',
)}
title={title}
>
{empty ? 'Empty question — add a prompt' : title}
</span>
<span className="hidden shrink-0 items-center gap-2 text-[0.6875rem] text-ink-500 sm:flex">
{draft.filename && <span className="font-mono">{draft.filename}</span>}
<span className="num">{words}w</span>
</span>
<div className="flex shrink-0 items-center gap-0.5">
<IconButton label="Move up" disabled={index === 0} onClick={onMoveUp}>
<ArrowUp size={13} />
</IconButton>
<IconButton label="Move down" disabled={index === total - 1} onClick={onMoveDown}>
<ArrowDown size={13} />
</IconButton>
<IconButton label="Duplicate" onClick={onDuplicate}>
<Copy size={13} />
</IconButton>
<IconButton label="Delete" danger onClick={onDelete}>
<Trash2 size={13} />
</IconButton>
</div>
</div>
<textarea
ref={textareaRef}
value={draft.prompt}
onChange={(event) => onChange(event.target.value)}
placeholder={
'Write the question exactly as the model should receive it.\n\nExample: Write a Swift function that solves the FizzBuzz problem.'
}
spellCheck={false}
className="w-full resize-none border-0 bg-transparent px-4 py-3.5 font-mono text-[0.8125rem] leading-relaxed text-ink-200 placeholder:text-ink-500/70 focus:outline-none"
/>
</Card>
)
}
function IconButton({
children,
label,
onClick,
disabled,
danger,
}: {
children: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
danger?: boolean
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cx(
'flex h-6.5 w-6.5 items-center justify-center rounded-md p-1 transition-colors disabled:cursor-not-allowed disabled:opacity-30',
danger
? 'text-ink-500 hover:bg-rose-500/15 hover:text-rose-400'
: 'text-ink-500 hover:bg-navy-750 hover:text-ink-200',
)}
>
{children}
</button>
)
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// The Python server owns /api. In dev, Vite serves the UI on :5173 and proxies
// API + SSE calls through to uvicorn on :8765 so both share one origin.
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8765',
changeOrigin: true,
// Server-sent events must not be buffered by the proxy.
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
delete proxyRes.headers['content-length']
}
})
},
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 900,
},
})