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:
@@ -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")
|
||||||
|
|||||||
+9
-2
@@ -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,7 +79,8 @@ 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."
|
||||||
)
|
)
|
||||||
|
|
||||||
prompts = load_test_prompts()
|
if prompts is None:
|
||||||
|
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
@@ -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
|
||||||
|
|||||||
@@ -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:**
|
| Variable | Purpose |
|
||||||
- Providers (e.g., LM Studio, Local Engine) are registered in `.core/providers/`.
|
|---|---|
|
||||||
- Each provider implements `list_models()` and `run_prompt()`.
|
| `LM_STUDIO_BASE_URL` | LM Studio endpoint (default `http://localhost:1234`) |
|
||||||
- The Local Engine auto-selects the best runtime for your hardware (MLX, CUDA, ROCm, or CPU fallback).
|
| `AUTO_TEST_TEMPERATURE` | Default sampling temperature |
|
||||||
|
| `AUTO_TEST_PROVIDER` | Default provider for CLI runs |
|
||||||
|
| `LOCAL_LLM_PATHS` | `os.pathsep`-separated extra directories to scan for `.gguf` weights |
|
||||||
|
|
||||||
2. **Prompt Loading:**
|
Settings changed in the interface persist to `.core/user_settings.json` and are shared with
|
||||||
- Prompts are loaded from the `tests/` directory (one `.txt` file per test).
|
the CLI. Common LM Studio locations (`~/.lmstudio`, `~/.lmstudio/models`,
|
||||||
- Each prompt file's first non-empty line is used as the test title.
|
`~/Library/Application Support/lm-studio/models`) are scanned by default.
|
||||||
|
|
||||||
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
|
## Frontend development
|
||||||
|
|
||||||
You can customize behavior via environment variables:
|
```bash
|
||||||
|
python app.py --no-browser # terminal 1 — API on :8765
|
||||||
|
cd web && npm run dev # terminal 2 — UI on :5173 with hot reload
|
||||||
|
```
|
||||||
|
|
||||||
| Variable | Purpose |
|
Vite proxies `/api` (including the event stream) through to the Python server, so both run
|
||||||
|-------------------------|--------------------------------------------------------------|
|
from one origin.
|
||||||
| `LM_STUDIO_BASE_URL` | Override LM Studio API endpoint (default: http://localhost:1234) |
|
|
||||||
| `AUTO_TEST_TEMPERATURE` | Default sampling temperature for test runs |
|
|
||||||
| `AUTO_TEST_PROVIDER` | Default provider for CLI runs |
|
|
||||||
| `LOCAL_LLM_PATHS` | `:`-separated list of extra directories for `.gguf` models |
|
|
||||||
|
|
||||||
Model search paths can also be managed via the GUI settings dialog (persisted in `.core/user_settings.json`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
## Extending LM-Gambit
|
**A new provider** — add a class in `.core/providers/` inheriting from `Provider`, implement
|
||||||
|
`list_models()` and `run_prompt()`, and register it in `.core/providers/__init__.py`. It
|
||||||
|
appears in the interface automatically.
|
||||||
|
|
||||||
**Add a new provider:**
|
**A new engine runtime** — add `.core/.engine/.<architecture>/<version>.py` defining an
|
||||||
1. Create a new class in `.core/providers/` inheriting from `Provider` (see `base.py`).
|
`EngineRuntime` class that inherits from `BaseRuntime`. The loader picks it up when the
|
||||||
2. Implement `list_models()` and `run_prompt()`.
|
hardware matches.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## Customizing Prompts & Reports in LM-Gambit
|
|
||||||
|
|
||||||
- Add/edit prompt `.txt` files in `tests/` (first non-empty line is the title).
|
|
||||||
- Edit `.core/templates/test-block.md` to change the report format.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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/`.
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -0,0 +1,117 @@
|
|||||||
|
"""Stable types passed to LM-Gambit plugins.
|
||||||
|
|
||||||
|
Plugins import from this module and nothing else in the project::
|
||||||
|
|
||||||
|
from plugin_api import Grade, RunRecord, TestRecord
|
||||||
|
|
||||||
|
Everything here is a plain frozen dataclass, so plugins never touch engine or
|
||||||
|
server internals and keep working across refactors of either.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
__all__ = ["Grade", "GradeEntry", "RunRecord", "TestRecord", "PluginInfo"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TestRecord:
|
||||||
|
"""One question and the model's answer to it."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
total: int
|
||||||
|
title: str
|
||||||
|
filename: str
|
||||||
|
prompt: str
|
||||||
|
ok: bool
|
||||||
|
response: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
metrics: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
elapsed: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tokens_per_second(self) -> float:
|
||||||
|
return float(self.metrics.get("tokens_per_second", 0) or 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_tokens(self) -> int:
|
||||||
|
return int(self.metrics.get("total_tokens", 0) or 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_to_first_token(self) -> float:
|
||||||
|
return float(self.metrics.get("time_to_first_token", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Grade:
|
||||||
|
"""A score for one answer.
|
||||||
|
|
||||||
|
``score`` is clamped to 0.0–1.0. Return ``None`` from a grader instead of a
|
||||||
|
``Grade`` to abstain — abstentions never count against the average.
|
||||||
|
"""
|
||||||
|
|
||||||
|
score: float
|
||||||
|
label: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "score", max(0.0, min(1.0, float(self.score))))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GradeEntry:
|
||||||
|
"""A grade together with the plugin that produced it."""
|
||||||
|
|
||||||
|
grader: str
|
||||||
|
grade: Grade
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunRecord:
|
||||||
|
"""A whole diagnostic run, handed to lifecycle and report hooks."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
provider: str
|
||||||
|
model_id: str
|
||||||
|
model_label: str
|
||||||
|
temperature: float
|
||||||
|
total: int
|
||||||
|
status: str
|
||||||
|
started_at: float
|
||||||
|
finished_at: Optional[float] = None
|
||||||
|
report_path: Optional[Path] = None
|
||||||
|
summary: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
tests: List[TestRecord] = field(default_factory=list)
|
||||||
|
grades: Dict[int, List[GradeEntry]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def score_for(self, test_index: int) -> Optional[float]:
|
||||||
|
"""Mean score across every grader that scored this question."""
|
||||||
|
entries = self.grades.get(test_index) or []
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
return sum(entry.grade.score for entry in entries) / len(entries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overall_score(self) -> Optional[float]:
|
||||||
|
"""Mean of the per-question scores, over graded questions only."""
|
||||||
|
scores = [s for s in (self.score_for(t.index) for t in self.tests) if s is not None]
|
||||||
|
if not scores:
|
||||||
|
return None
|
||||||
|
return sum(scores) / len(scores)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PluginInfo:
|
||||||
|
"""What the server reports about a discovered plugin."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
path: str
|
||||||
|
enabled: bool
|
||||||
|
hooks: List[str] = field(default_factory=list)
|
||||||
|
error: Optional[str] = None
|
||||||
+350
-26
@@ -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."""
|
||||||
try:
|
self.loaded = []
|
||||||
hook(*args, **kwargs)
|
if not self.plugin_dir.is_dir():
|
||||||
except Exception as e:
|
return self.loaded
|
||||||
print(f"Plugin {plugin.__name__} hook {hook_name} failed: {e}")
|
|
||||||
|
_ensure_namespace(self.plugin_dir)
|
||||||
|
|
||||||
|
for path in _candidate_paths(self.plugin_dir):
|
||||||
|
slug = path.parent.name if path.name == "__init__.py" else path.stem
|
||||||
|
self.loaded.append(self._load_one(slug, path))
|
||||||
|
return self.loaded
|
||||||
|
|
||||||
|
def _load_one(self, slug: str, path: Path) -> LoadedPlugin:
|
||||||
|
record = LoadedPlugin(
|
||||||
|
slug=slug,
|
||||||
|
name=slug.replace("_", " ").title(),
|
||||||
|
version="0.0.0",
|
||||||
|
description="",
|
||||||
|
path=path,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
module_name = f"{NAMESPACE}.{slug}"
|
||||||
|
is_package = path.name == "__init__.py"
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
module_name,
|
||||||
|
path,
|
||||||
|
submodule_search_locations=[str(path.parent)] if is_package else None,
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
record.error = "Could not create a module spec for this file."
|
||||||
|
return record
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
except Exception:
|
||||||
|
sys.modules.pop(module_name, None)
|
||||||
|
record.error = _last_line(traceback.format_exc())
|
||||||
|
return record
|
||||||
|
|
||||||
|
record.module = module
|
||||||
|
record.name = str(getattr(module, "NAME", record.name))
|
||||||
|
record.version = str(getattr(module, "VERSION", "0.0.0"))
|
||||||
|
record.description = str(getattr(module, "DESCRIPTION", ""))
|
||||||
|
record.enabled = bool(getattr(module, "ENABLED", True))
|
||||||
|
|
||||||
|
# `register()` is the place for one-time setup, if a plugin needs any.
|
||||||
|
register = getattr(module, "register", None)
|
||||||
|
if record.enabled and callable(register):
|
||||||
|
try:
|
||||||
|
register()
|
||||||
|
except Exception:
|
||||||
|
record.error = f"register() failed: {_last_line(traceback.format_exc())}"
|
||||||
|
record.enabled = False
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- querying
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> List[LoadedPlugin]:
|
||||||
|
return [p for p in self.loaded if p.enabled and p.error is None and p.module is not None]
|
||||||
|
|
||||||
|
def info(self) -> List[PluginInfo]:
|
||||||
|
return [plugin.info() for plugin in self.loaded]
|
||||||
|
|
||||||
|
def with_hook(self, hook: str) -> List[LoadedPlugin]:
|
||||||
|
return [p for p in self.active if callable(getattr(p.module, hook, None))]
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- dispatch
|
||||||
|
|
||||||
|
def emit(self, hook: str, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Fire a hook for its side effects, ignoring return values."""
|
||||||
|
self.collect(hook, *args, **kwargs)
|
||||||
|
|
||||||
|
def collect(self, hook: str, *args: Any, **kwargs: Any) -> List[Tuple[LoadedPlugin, Any]]:
|
||||||
|
"""Call a hook on every plugin defining it and keep non-None results.
|
||||||
|
|
||||||
|
A plugin that raises is reported on stderr and skipped for this call
|
||||||
|
only — it stays loaded for subsequent hooks.
|
||||||
|
"""
|
||||||
|
results: List[Tuple[LoadedPlugin, Any]] = []
|
||||||
|
for plugin in self.with_hook(hook):
|
||||||
|
callback: Callable[..., Any] = getattr(plugin.module, hook)
|
||||||
|
try:
|
||||||
|
value = callback(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
print(
|
||||||
|
f"[plugin:{plugin.slug}] {hook}() failed: {_last_line(traceback.format_exc())}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if value is not None:
|
||||||
|
results.append((plugin, value))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def grade(self, test: Any) -> List[Tuple[str, Grade]]:
|
||||||
|
"""Run every grader over one answer, normalising the return values."""
|
||||||
|
grades: List[Tuple[str, Grade]] = []
|
||||||
|
for plugin, value in self.collect("grade", test):
|
||||||
|
grade = _coerce_grade(value)
|
||||||
|
if grade is None:
|
||||||
|
print(
|
||||||
|
f"[plugin:{plugin.slug}] grade() returned {value!r}; "
|
||||||
|
"expected a number, a Grade, or None.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
grades.append((plugin.name, grade))
|
||||||
|
return grades
|
||||||
|
|
||||||
|
|
||||||
|
def _last_line(text: str) -> str:
|
||||||
|
lines = [line for line in text.strip().splitlines() if line.strip()]
|
||||||
|
return lines[-1].strip() if lines else "unknown error"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_grade(value: Any) -> Optional[Grade]:
|
||||||
|
"""Accept a Grade, a number, a bool, or a dict from a grader."""
|
||||||
|
if isinstance(value, Grade):
|
||||||
|
return value
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return Grade(score=1.0 if value else 0.0, label="pass" if value else "fail")
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return Grade(score=float(value))
|
||||||
|
if isinstance(value, dict) and "score" in value:
|
||||||
|
try:
|
||||||
|
return Grade(
|
||||||
|
score=float(value["score"]),
|
||||||
|
label=str(value.get("label", "")),
|
||||||
|
notes=str(value.get("notes", "")),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- report output
|
||||||
|
# These live here rather than in the server so the CLI and the web interface
|
||||||
|
# produce byte-identical reports for the same run.
|
||||||
|
|
||||||
|
|
||||||
|
def letter_grade(score: float) -> str:
|
||||||
|
for threshold, letter in (
|
||||||
|
(0.97, "A+"), (0.93, "A"), (0.90, "A-"),
|
||||||
|
(0.87, "B+"), (0.83, "B"), (0.80, "B-"),
|
||||||
|
(0.77, "C+"), (0.73, "C"), (0.70, "C-"),
|
||||||
|
(0.67, "D+"), (0.60, "D"),
|
||||||
|
):
|
||||||
|
if score >= threshold:
|
||||||
|
return letter
|
||||||
|
return "F"
|
||||||
|
|
||||||
|
|
||||||
|
def render_grade_section(record: RunRecord) -> Optional[str]:
|
||||||
|
"""Render grades as the report's Qualitative Analysis block."""
|
||||||
|
overall = record.overall_score
|
||||||
|
if overall is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
graders = sorted({entry.grader for entries in record.grades.values() for entry in entries})
|
||||||
|
|
||||||
|
def escape(text: str) -> str:
|
||||||
|
return text.replace("|", "\\|").replace("\n", " ").strip()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"**Overall score: {overall:.0%} ({letter_grade(overall)})** "
|
||||||
|
f"— graded by {', '.join(f'`{g}`' for g in graders)}.",
|
||||||
|
"",
|
||||||
|
"Scores come from plugins in `plugins/`, so they reflect whatever those "
|
||||||
|
"plugins check — not a judgement of correctness. Review before relying on them.",
|
||||||
|
"",
|
||||||
|
"| # | Question | Score | Grader | Notes |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
|
||||||
|
for test in record.tests:
|
||||||
|
for entry in record.grades.get(test.index) or []:
|
||||||
|
label = escape(entry.grade.label)
|
||||||
|
score = f"{entry.grade.score:.0%}" + (f" ({label})" if label else "")
|
||||||
|
lines.append(
|
||||||
|
f"| {test.index} | {escape(test.title)} | {score} "
|
||||||
|
f"| {escape(entry.grader)} | {escape(entry.grade.notes) or '—'} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
ungraded = [t for t in record.tests if not record.grades.get(t.index)]
|
||||||
|
if ungraded:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"*{len(ungraded)} question(s) were not graded — no plugin had an "
|
||||||
|
"opinion on them. They are excluded from the overall score.*",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_report_sections(record: RunRecord, manager: "PluginManager") -> List[str]:
|
||||||
|
"""Normalize whatever `report_sections` hooks return into markdown blocks."""
|
||||||
|
blocks: List[str] = []
|
||||||
|
for _, value in manager.collect("report_sections", record):
|
||||||
|
blocks.extend(_normalize_section(value))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_section(value: Any) -> List[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
parts: List[str] = []
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
||||||
|
heading, body = item
|
||||||
|
parts.append(f"{heading}\n\n{body}")
|
||||||
|
return parts
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_manager: Optional[PluginManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_manager() -> PluginManager:
|
||||||
|
"""Process-wide plugin manager, created on first use."""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = PluginManager()
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def reload_plugins() -> List[LoadedPlugin]:
|
||||||
|
"""Re-scan the plugin directory. New routes still need a server restart."""
|
||||||
|
return get_plugin_manager().load()
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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()},
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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] = {}
|
||||||
@@ -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()
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+3519
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(' ')
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user