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

- Implemented SettingsPage for configuring default provider, temperature, and model paths.
- Added SuitePage for managing a suite of questions with features to add, edit, duplicate, and delete questions.
- Introduced TypeScript configuration files for app and node environments.
- Set up Vite configuration for development server with API proxying to backend.
This commit is contained in:
Christopher Clendening
2026-07-27 17:41:08 -04:00
parent ce0e022f11
commit adf61ae1a0
50 changed files with 10952 additions and 164 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="LM-Gambit — automated diagnostic suite for local and remote language models." />
<title>LM-Gambit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3519
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"lucide-react": "^1.27.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g" x1="4" y1="2" x2="36" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4813F"/>
<stop offset="1" stop-color="#E3AD46"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="38" height="38" rx="11" fill="url(#g)"/>
<path d="M20 9.4a4 4 0 0 1 2.35 7.24c1.5.93 2.4 2.42 2.4 4.06 0 1.5-.74 2.86-1.96 3.79l1.9 6.7h-9.38l1.9-6.7a4.78 4.78 0 0 1-1.96-3.79c0-1.64.9-3.13 2.4-4.06A4 4 0 0 1 20 9.4Z" fill="#160B04" fill-opacity="0.82"/>
<rect x="12.4" y="29.4" width="15.2" height="3.4" rx="1.7" fill="#160B04" fill-opacity="0.82"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+24
View File
@@ -0,0 +1,24 @@
import { Shell } from './components/Shell'
import { useRouter } from './lib/router'
import { useRunFeed } from './hooks/useRunFeed'
import { RunPage } from './pages/RunPage'
import { SuitePage } from './pages/SuitePage'
import { ReportsPage } from './pages/ReportsPage'
import { PlaygroundPage } from './pages/PlaygroundPage'
import { SettingsPage } from './pages/SettingsPage'
import { NotFoundPage } from './pages/NotFoundPage'
export function App() {
const { path } = useRouter()
const { run, isLive } = useRunFeed()
let page
if (path === '/') page = <RunPage />
else if (path === '/suite') page = <SuitePage />
else if (path.startsWith('/reports')) page = <ReportsPage />
else if (path === '/playground') page = <PlaygroundPage />
else if (path === '/settings') page = <SettingsPage />
else page = <NotFoundPage />
return <Shell activeRun={isLive ? run : null}>{page}</Shell>
}
+45
View File
@@ -0,0 +1,45 @@
/** LM-Gambit brand mark — a chess pawn cut from a warm gradient. */
import { useId } from 'react'
export function Logo({ size = 30 }: { size?: number }) {
// The mark renders more than once per page (rail + mobile bar), so the
// gradient needs a unique id or the second instance resolves to the first.
const gradientId = useId()
return (
<svg
width={size}
height={size}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id={gradientId} x1="4" y1="2" x2="36" y2="38" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4813F" />
<stop offset="1" stopColor="#E3AD46" />
</linearGradient>
</defs>
<rect x="1" y="1" width="38" height="38" rx="11" fill={`url(#${gradientId})`} />
<rect
x="1"
y="1"
width="38"
height="38"
rx="11"
stroke="#FFD9AE"
strokeOpacity="0.35"
strokeWidth="1.2"
/>
{/* pawn silhouette */}
<path
d="M20 9.4a4 4 0 0 1 2.35 7.24c1.5.93 2.4 2.42 2.4 4.06 0 1.5-.74 2.86-1.96 3.79l1.9 6.7h-9.38l1.9-6.7a4.78 4.78 0 0 1-1.96-3.79c0-1.64.9-3.13 2.4-4.06A4 4 0 0 1 20 9.4Z"
fill="#160B04"
fillOpacity="0.82"
/>
<rect x="12.4" y="29.4" width="15.2" height="3.4" rx="1.7" fill="#160B04" fillOpacity="0.82" />
</svg>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { memo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { cx } from '../lib/format'
/** Renders model output / reports. Raw HTML is never enabled. */
export const Markdown = memo(function Markdown({
children,
className,
}: {
children: string
className?: string
}) {
return (
<div className={cx('md', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
>
{children}
</ReactMarkdown>
</div>
)
})
+52
View File
@@ -0,0 +1,52 @@
/**
* A plugin-assigned score.
*
* Scores come from user-written graders, so they carry a deliberately neutral
* visual weight — mint/gold/rose signal the band, never "this answer is right".
*/
import { cx } from '../lib/format'
export function scoreTone(score: number): 'mint' | 'gold' | 'rose' {
if (score >= 0.8) return 'mint'
if (score >= 0.5) return 'gold'
return 'rose'
}
export function letterGrade(score: number): string {
if (score >= 0.97) return 'A+'
if (score >= 0.93) return 'A'
if (score >= 0.9) return 'A-'
if (score >= 0.87) return 'B+'
if (score >= 0.83) return 'B'
if (score >= 0.8) return 'B-'
if (score >= 0.77) return 'C+'
if (score >= 0.73) return 'C'
if (score >= 0.7) return 'C-'
if (score >= 0.67) return 'D+'
if (score >= 0.6) return 'D'
return 'F'
}
export function ScoreBadge({
score,
title,
showLetter,
}: {
score: number
title?: string
showLetter?: boolean
}) {
const tone = scoreTone(score)
const toneClass = {
mint: 'chip-mint',
gold: 'chip-gold',
rose: 'chip-rose',
}[tone]
return (
<span className={cx('chip num', toneClass)} title={title}>
{Math.round(score * 100)}%{showLetter && ` · ${letterGrade(score)}`}
</span>
)
}
+157
View File
@@ -0,0 +1,157 @@
/** App chrome: brand rail, navigation, engine status and the live-run beacon. */
import { useEffect, useState, type ReactNode } from 'react'
import {
Cpu,
FileText,
FlaskConical,
Gauge,
ListChecks,
Menu,
Settings2,
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { Link, useRouter } from '../lib/router'
import { api, type Run, type SystemInfo } from '../lib/api'
import { cx } from '../lib/format'
const NAV = [
{ to: '/', label: 'Run', icon: Gauge, hint: 'Execute the diagnostic suite' },
{ to: '/suite', label: 'Suite', icon: ListChecks, hint: 'Author the questions' },
{ to: '/reports', label: 'Reports', icon: FileText, hint: 'Read past results' },
{ to: '/playground', label: 'Playground', icon: FlaskConical, hint: 'Try a single prompt' },
{ to: '/settings', label: 'Settings', icon: Settings2, hint: 'Paths and defaults' },
]
function isActive(path: string, to: string) {
return to === '/' ? path === '/' : path.startsWith(to)
}
export function Shell({ children, activeRun }: { children: ReactNode; activeRun: Run | null }) {
const { path } = useRouter()
const [system, setSystem] = useState<SystemInfo | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
api.system().then(setSystem).catch(() => setSystem(null))
}, [])
useEffect(() => {
setDrawerOpen(false)
}, [path])
const nav = (
<nav className="flex flex-col gap-1">
{NAV.map(({ to, label, icon: Icon, hint }) => {
const active = isActive(path, to)
return (
<Link
key={to}
to={to}
title={hint}
className={cx(
'group relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-[0.8125rem] font-medium transition-all',
active
? 'bg-navy-750/80 text-ink-100 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]'
: 'text-ink-400 hover:bg-navy-800/60 hover:text-ink-200',
)}
>
<span
className={cx(
'absolute top-1/2 left-0 h-5 w-[3px] -translate-y-1/2 rounded-r-full transition-all',
active ? 'bg-ember-500' : 'bg-transparent',
)}
/>
<Icon size={16} className={active ? 'text-ember-400' : 'text-ink-500 group-hover:text-ink-300'} />
{label}
{to === '/' && activeRun && (
<span className="ml-auto h-1.5 w-1.5 animate-pulse rounded-full bg-ember-400 shadow-[0_0_8px_2px_rgba(244,129,63,0.5)]" />
)}
</Link>
)
})}
</nav>
)
const engineChip = system && (
<div className="rounded-xl border border-navy-700 bg-navy-900/50 px-3 py-2.5">
<div className="flex items-center gap-2 text-[0.6875rem] font-semibold tracking-wide text-ink-400 uppercase">
<Cpu size={12} className="text-gold-500" />
Engine
</div>
<div className="mt-1 truncate text-[0.75rem] font-medium text-ink-200" title={system.engine_runtime}>
{system.engine_runtime}
</div>
<div className="text-[0.6875rem] text-ink-500">{system.engine_architecture}</div>
{!system.template_ok && (
<div className="mt-1.5 text-[0.6875rem] text-rose-400">Report template missing</div>
)}
</div>
)
const sidebarBody = (
<>
<Link to="/" className="mb-7 flex items-center gap-3">
<Logo size={32} />
<div className="leading-tight">
<div className="text-[0.9375rem] font-semibold tracking-tight text-ink-100">
LM<span className="text-ember-400">-</span>Gambit
</div>
<div className="text-[0.6875rem] text-ink-500">
Diagnostic suite{system ? ` · v${system.version}` : ''}
</div>
</div>
</Link>
{nav}
<div className="mt-auto space-y-2 pt-6">{engineChip}</div>
</>
)
return (
<div className="flex min-h-screen">
{/* desktop rail */}
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col border-r border-navy-700/70 bg-navy-900/45 px-4 py-6 backdrop-blur-xl lg:flex">
{sidebarBody}
</aside>
{/* mobile drawer */}
{drawerOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="animate-fade-in absolute inset-0 bg-navy-950/75 backdrop-blur-sm"
onClick={() => setDrawerOpen(false)}
/>
<aside className="animate-fade-in relative flex h-full w-64 flex-col border-r border-navy-700 bg-navy-900 px-4 py-6">
<button
className="absolute top-5 right-4 text-ink-500 hover:text-ink-200"
onClick={() => setDrawerOpen(false)}
aria-label="Close navigation"
>
<X size={18} />
</button>
{sidebarBody}
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-navy-700/70 px-4 py-3 lg:hidden">
<button
className="btn btn-ghost btn-sm"
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation"
>
<Menu size={16} />
</button>
<Logo size={22} />
<span className="text-sm font-semibold">LM-Gambit</span>
</div>
<main className="mx-auto w-full max-w-[1400px] flex-1 px-5 py-7 sm:px-7 lg:px-9">
{children}
</main>
</div>
</div>
)
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Per-question throughput, as a horizontal bar chart.
*
* One measure, one series: magnitude is carried by bar length in a single hue
* (#b8892f — validated in-band and above 3:1 against the navy chart surface),
* so there is no legend to restate the title. Questions that errored have no
* throughput; they are listed under the plot with an icon and the word "Error"
* rather than being coloured into the series.
*/
import { useId, useState } from 'react'
import { TriangleAlert } from 'lucide-react'
import type { ParsedTest } from '../lib/report'
const BAR_COLOR = '#b8892f'
const BAR_HEIGHT = 18 // ≤ 24px mark
const ROW_GAP = 10 // ≥ 2px surface gap between adjacent bars
const LEFT = 34
const RIGHT = 56
const TOP = 22
const BOTTOM = 26
type Measure = 'tokensPerSecond' | 'timeToFirstToken'
const MEASURES: Record<Measure, { label: string; unit: string; decimals: number }> = {
tokensPerSecond: { label: 'Throughput', unit: 'tok/s', decimals: 1 },
timeToFirstToken: { label: 'Time to first token', unit: 's', decimals: 2 },
}
/** Round up to a clean axis maximum without leaving the plot mostly empty. */
function niceMax(value: number): number {
if (value <= 0) return 1
const magnitude = 10 ** Math.floor(Math.log10(value))
const normalized = value / magnitude
const step = [1, 1.2, 1.6, 2, 2.5, 3, 4, 5, 6, 8, 10].find((s) => normalized <= s) ?? 10
return step * magnitude
}
export function ThroughputChart({ tests }: { tests: ParsedTest[] }) {
const clipId = useId()
const [measure, setMeasure] = useState<Measure>('tokensPerSecond')
const [hovered, setHovered] = useState<number | null>(null)
const scored = tests.filter((test) => test.ok && test[measure] != null)
const errored = tests.filter((test) => !test.ok)
if (scored.length === 0) {
return (
<p className="px-4 py-6 text-[0.8125rem] text-ink-500">
No successful questions in this report, so there is nothing to plot.
</p>
)
}
const config = MEASURES[measure]
const max = niceMax(Math.max(...scored.map((test) => test[measure] as number)))
const rowHeight = BAR_HEIGHT + ROW_GAP
const plotWidth = 660
const height = TOP + scored.length * rowHeight + BOTTOM
const barArea = plotWidth - LEFT - RIGHT
const ticks = [0, 0.25, 0.5, 0.75, 1].map((fraction) => fraction * max)
return (
<div className="px-4 pt-2 pb-4">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-[0.8125rem] font-medium text-ink-300">
{config.label} by question{' '}
<span className="text-ink-500">({config.unit})</span>
</h3>
<div className="flex gap-1">
{(Object.keys(MEASURES) as Measure[]).map((key) => (
<button
key={key}
className={
measure === key
? 'btn btn-sm border-gold-500/40 bg-gold-500/15 text-gold-300'
: 'btn btn-ghost btn-sm'
}
onClick={() => setMeasure(key)}
>
{MEASURES[key].label}
</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${plotWidth} ${height}`}
width="100%"
style={{ minWidth: 420 }}
role="img"
aria-label={`${config.label} per question in ${config.unit}`}
>
<defs>
{/* Square at the baseline, 4px rounded at the data end. */}
<clipPath id={clipId}>
<rect x={0} y={0} width={plotWidth} height={height} />
</clipPath>
</defs>
{/* recessive hairline gridlines */}
{ticks.map((tick) => {
const x = LEFT + (tick / max) * barArea
return (
<g key={tick}>
<line
x1={x}
y1={TOP - 6}
x2={x}
y2={height - BOTTOM + 4}
stroke="#223353"
strokeWidth={1}
/>
<text
x={x}
y={height - BOTTOM + 17}
textAnchor="middle"
fill="#55688a"
fontSize={9.5}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{tick.toFixed(config.decimals === 2 && max < 5 ? 1 : 0)}
</text>
</g>
)
})}
{scored.map((test, row) => {
const value = test[measure] as number
const y = TOP + row * rowHeight
const width = Math.max((value / max) * barArea, 2)
const active = hovered === test.index
return (
<g
key={test.index}
onMouseEnter={() => setHovered(test.index)}
onMouseLeave={() => setHovered(null)}
>
{/* hit target larger than the mark */}
<rect
x={0}
y={y - ROW_GAP / 2}
width={plotWidth}
height={rowHeight}
fill={active ? '#ffffff' : 'transparent'}
fillOpacity={active ? 0.035 : 0}
/>
<text
x={LEFT - 8}
y={y + BAR_HEIGHT / 2 + 3.5}
textAnchor="end"
fill="#7288ab"
fontSize={10}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{String(test.index).padStart(2, '0')}
</text>
<rect
x={LEFT}
y={y}
width={width}
height={BAR_HEIGHT}
rx={4}
fill={BAR_COLOR}
fillOpacity={active ? 1 : 0.88}
clipPath={`url(#${clipId})`}
/>
{/* square off the baseline end */}
<rect x={LEFT} y={y} width={Math.min(4, width)} height={BAR_HEIGHT} fill={BAR_COLOR} fillOpacity={active ? 1 : 0.88} />
<text
x={LEFT + width + 7}
y={y + BAR_HEIGHT / 2 + 3.5}
fill="#c8d5ea"
fontSize={10.5}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value.toFixed(config.decimals)}
</text>
</g>
)
})}
{/* baseline */}
<line x1={LEFT} y1={TOP - 6} x2={LEFT} y2={height - BOTTOM + 4} stroke="#2e4467" strokeWidth={1} />
</svg>
</div>
{hovered != null && (
<div className="mt-1 rounded-lg border border-navy-700 bg-navy-900/80 px-3 py-2 text-[0.75rem]">
<span className="text-ink-200">
{scored.find((test) => test.index === hovered)?.title}
</span>
<span className="num ml-2 text-gold-400">
{(scored.find((test) => test.index === hovered)?.[measure] ?? 0).toFixed(
config.decimals,
)}{' '}
{config.unit}
</span>
</div>
)}
{errored.length > 0 && (
<div className="mt-3 border-t border-navy-700 pt-3">
<p className="mb-1.5 text-[0.6875rem] font-semibold tracking-wide text-ink-500 uppercase">
Not plotted no measurement
</p>
<ul className="space-y-1">
{errored.map((test) => (
<li key={test.index} className="flex items-start gap-2 text-[0.75rem]">
<TriangleAlert size={12} className="mt-0.5 shrink-0 text-rose-400" />
<span className="text-ink-400">
<span className="num mr-1.5 text-ink-500">
{String(test.index).padStart(2, '0')}
</span>
{test.title}
<span className="ml-1.5 font-medium text-rose-400">Error</span>
</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}
+368
View File
@@ -0,0 +1,368 @@
/** Shared presentational primitives. */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'
import { cx } from '../lib/format'
/* ------------------------------------------------------------------ layout */
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle?: string
actions?: ReactNode
}) {
return (
<header className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-[1.6rem] leading-tight font-semibold text-ink-100">{title}</h1>
{subtitle && <p className="mt-1 max-w-2xl text-[0.8125rem] text-ink-400">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
)
}
export function Card({
children,
className,
raised,
}: {
children: ReactNode
className?: string
raised?: boolean
}) {
return <section className={cx(raised ? 'surface-raised' : 'surface', className)}>{children}</section>
}
export function CardHeader({
title,
icon,
actions,
hint,
}: {
title: string
icon?: ReactNode
actions?: ReactNode
hint?: string
}) {
return (
<div className="flex items-start justify-between gap-3 border-b border-navy-700 px-4 py-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-[0.8125rem] font-semibold tracking-wide text-ink-200">
{icon && <span className="text-gold-500">{icon}</span>}
{title}
</h2>
{hint && <p className="mt-0.5 text-[0.6875rem] text-ink-500">{hint}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)
}
/* ------------------------------------------------------------------- stats */
export function StatTile({
label,
value,
unit,
tone = 'gold',
hint,
}: {
label: string
value: string | number
unit?: string
tone?: 'gold' | 'ember' | 'mint' | 'rose' | 'neutral'
hint?: string
}) {
const toneClass = {
gold: 'text-gold-400',
ember: 'text-ember-400',
mint: 'text-mint-400',
rose: 'text-rose-400',
neutral: 'text-ink-200',
}[tone]
return (
<div className="surface px-3.5 py-3">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
{label}
</div>
<div className={cx('num mt-1 flex items-baseline gap-1 text-[1.35rem] font-semibold', toneClass)}>
{value}
{unit && <span className="text-[0.7rem] font-medium text-ink-500">{unit}</span>}
</div>
{hint && <div className="mt-0.5 text-[0.6875rem] text-ink-500">{hint}</div>}
</div>
)
}
/* ------------------------------------------------------------------ states */
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cx('animate-spin', className)} size={15} />
}
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full border border-navy-700 bg-navy-800/60 text-ink-500">
{icon}
</div>
<h3 className="text-sm font-semibold text-ink-200">{title}</h3>
{description && <p className="mt-1 max-w-sm text-[0.8125rem] text-ink-500">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export function ErrorNote({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="flex items-start gap-3 rounded-xl border border-rose-500/40 bg-rose-500/10 px-4 py-3">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-rose-400" />
<div className="min-w-0 flex-1">
<p className="text-[0.8125rem] break-words text-rose-400">{message}</p>
{onRetry && (
<button className="btn btn-ghost btn-sm mt-2" onClick={onRetry}>
Try again
</button>
)}
</div>
</div>
)
}
export function SkeletonRows({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-2 p-4">
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="relative h-11 overflow-hidden rounded-lg border border-navy-700 bg-navy-800/40"
>
<div className="animate-sweep absolute inset-y-0 w-1/3 bg-gradient-to-r from-transparent via-navy-700/50 to-transparent" />
</div>
))}
</div>
)
}
/* ------------------------------------------------------------------ toasts */
type ToastTone = 'success' | 'error' | 'info'
interface Toast {
id: number
tone: ToastTone
message: string
}
const ToastContext = createContext<(message: string, tone?: ToastTone) => void>(() => {})
export function useToast() {
return useContext(ToastContext)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const nextId = useRef(1)
const push = useCallback((message: string, tone: ToastTone = 'info') => {
const id = nextId.current++
setToasts((current) => [...current, { id, tone, message }])
setTimeout(() => setToasts((current) => current.filter((t) => t.id !== id)), 5200)
}, [])
const dismiss = (id: number) => setToasts((current) => current.filter((t) => t.id !== id))
return (
<ToastContext.Provider value={push}>
{children}
<div className="pointer-events-none fixed right-5 bottom-5 z-50 flex w-[min(23rem,calc(100vw-2.5rem))] flex-col gap-2">
{toasts.map((toast) => {
const Icon = { success: CheckCircle2, error: AlertTriangle, info: Info }[toast.tone]
const tone = {
success: 'border-mint-500/45 text-mint-400',
error: 'border-rose-500/45 text-rose-400',
info: 'border-navy-600 text-ink-300',
}[toast.tone]
return (
<div
key={toast.id}
className={cx(
'animate-fade-up pointer-events-auto flex items-start gap-2.5 rounded-xl border bg-navy-850/95 px-3.5 py-2.5 shadow-[0_18px_40px_-20px_rgba(0,0,0,0.9)] backdrop-blur',
tone,
)}
>
<Icon size={15} className="mt-0.5 shrink-0" />
<p className="min-w-0 flex-1 text-[0.8125rem] break-words text-ink-200">
{toast.message}
</p>
<button
className="shrink-0 text-ink-500 transition-colors hover:text-ink-200"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss"
>
<X size={14} />
</button>
</div>
)
})}
</div>
</ToastContext.Provider>
)
}
/* ------------------------------------------------------------------- modal */
export function ConfirmDialog({
open,
title,
body,
confirmLabel = 'Confirm',
destructive,
onConfirm,
onCancel,
}: {
open: boolean
title: string
body: string
confirmLabel?: string
destructive?: boolean
onConfirm: () => void
onCancel: () => void
}) {
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onCancel])
if (!open) return null
return (
<div
className="animate-fade-in fixed inset-0 z-50 flex items-center justify-center bg-navy-950/75 p-5 backdrop-blur-sm"
onClick={onCancel}
>
<div
className="surface-raised animate-fade-up w-full max-w-sm p-5"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
>
<h3 className="text-[0.9375rem] font-semibold text-ink-100">{title}</h3>
<p className="mt-2 text-[0.8125rem] text-ink-400">{body}</p>
<div className="mt-5 flex justify-end gap-2">
<button className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
<button
className={cx('btn', destructive ? 'btn-danger' : 'btn-primary')}
onClick={onConfirm}
autoFocus
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ slider */
export function TemperatureSlider({
value,
onChange,
disabled,
}: {
value: number
onChange: (next: number) => void
disabled?: boolean
}) {
const percent = Math.min(100, Math.max(0, (value / 1) * 100))
const descriptor = value <= 0.15 ? 'Deterministic' : value <= 0.5 ? 'Balanced' : 'Creative'
return (
<div>
<div className="mb-1.5 flex items-baseline justify-between">
<span className="label mb-0">Temperature</span>
<span className="num text-[0.8125rem] font-semibold text-gold-400">{value.toFixed(2)}</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.05}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-navy-950 [&::-webkit-slider-thumb]:bg-gold-400 [&::-webkit-slider-thumb]:shadow-[0_0_0_3px_rgba(227,173,70,0.22)]"
style={{
background: `linear-gradient(90deg, var(--color-gold-500) 0%, var(--color-gold-500) ${percent}%, var(--color-navy-700) ${percent}%, var(--color-navy-700) 100%)`,
}}
/>
<div className="mt-1 text-[0.6875rem] text-ink-500">{descriptor}</div>
</div>
)
}
/* -------------------------------------------------------------- data hooks */
export function useAsync<T>(loader: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [nonce, setNonce] = useState(0)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
loader()
.then((value) => {
if (!cancelled) setData(value)
})
.catch((cause: unknown) => {
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, nonce])
const reload = useCallback(() => setNonce((n) => n + 1), [])
return useMemo(
() => ({ data, error, loading, reload, setData }),
[data, error, loading, reload],
)
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Owns the live state of a diagnostic run.
*
* Lives above the router so a run keeps streaming while the user browses other
* views, and re-attaches to an in-flight run after a page reload by asking the
* server what is currently active.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import {
api,
subscribeToRun,
type Run,
type RunEvent,
type TestCompletedEvent,
} from '../lib/api'
export interface LogLine {
id: number
at: number
tone: 'info' | 'ok' | 'error' | 'warn'
message: string
}
export interface PlannedTest {
index: number
title: string
filename: string
}
interface RunFeedValue {
run: Run | null
plan: PlannedTest[]
outcomes: TestCompletedEvent[]
logs: LogLine[]
starting: boolean
isLive: boolean
start: (input: {
provider: string
model_id: string
temperature: number
filenames?: string[]
}) => Promise<void>
cancel: () => Promise<void>
clear: () => void
attach: (runId: string) => void
}
const RunFeedContext = createContext<RunFeedValue | null>(null)
export function useRunFeed() {
const value = useContext(RunFeedContext)
if (!value) throw new Error('useRunFeed must be used inside <RunFeedProvider>')
return value
}
export function RunFeedProvider({ children }: { children: ReactNode }) {
const [run, setRun] = useState<Run | null>(null)
const [plan, setPlan] = useState<PlannedTest[]>([])
const [outcomes, setOutcomes] = useState<TestCompletedEvent[]>([])
const [logs, setLogs] = useState<LogLine[]>([])
const [starting, setStarting] = useState(false)
const unsubscribe = useRef<(() => void) | null>(null)
const logId = useRef(1)
const log = useCallback((message: string, tone: LogLine['tone'] = 'info') => {
setLogs((current) => [
...current.slice(-299),
{ id: logId.current++, at: Date.now(), tone, message },
])
}, [])
const handleEvent = useCallback(
(event: RunEvent) => {
switch (event.type) {
case 'run.started':
setRun(event.run)
setPlan(event.tests)
log(
`Run started · ${event.run.model_label} · ${event.run.total} question${
event.run.total === 1 ? '' : 's'
} · T=${event.run.temperature}`,
)
break
case 'test.completed':
setOutcomes((current) =>
current.some((o) => o.index === event.index) ? current : [...current, event],
)
setRun((current) => (current ? { ...current, completed: event.index } : current))
log(
event.status === 'ok'
? `[${event.index}/${event.total}] ${event.title}${
event.metrics?.tokens_per_second ?? 0
} tok/s in ${event.elapsed}s`
: `[${event.index}/${event.total}] ${event.title} — FAILED: ${event.error}`,
event.status === 'ok' ? 'ok' : 'error',
)
break
case 'run.completed':
setRun(event.run)
log(`Run complete · report saved as ${event.run.report_name}`, 'ok')
break
case 'run.cancelled':
setRun(event.run)
log('Run cancelled. Partial report was saved.', 'warn')
break
case 'run.failed':
setRun(event.run)
log(`Run failed: ${event.message ?? event.run.error ?? 'unknown error'}`, 'error')
break
}
},
[log],
)
const attach = useCallback(
(runId: string) => {
unsubscribe.current?.()
unsubscribe.current = subscribeToRun(runId, handleEvent)
},
[handleEvent],
)
// Re-attach to whatever the server is running when the app loads.
useEffect(() => {
let cancelled = false
api
.activeRun()
.then((active) => {
if (cancelled || !active) return
setRun(active)
setOutcomes([])
setLogs([])
attach(active.id)
})
.catch(() => undefined)
return () => {
cancelled = true
unsubscribe.current?.()
}
}, [attach])
const start = useCallback(
async (input: {
provider: string
model_id: string
temperature: number
filenames?: string[]
}) => {
setStarting(true)
try {
setOutcomes([])
setPlan([])
setLogs([])
const created = await api.startRun(input)
setRun(created)
attach(created.id)
} finally {
setStarting(false)
}
},
[attach],
)
const cancel = useCallback(async () => {
if (!run) return
log('Cancelling after the current question finishes…', 'warn')
await api.cancelRun(run.id)
}, [run, log])
const clear = useCallback(() => {
unsubscribe.current?.()
unsubscribe.current = null
setRun(null)
setPlan([])
setOutcomes([])
setLogs([])
}, [])
const value = useMemo<RunFeedValue>(
() => ({
run,
plan,
outcomes,
logs,
starting,
isLive: run?.status === 'running',
start,
cancel,
clear,
attach,
}),
[run, plan, outcomes, logs, starting, start, cancel, clear, attach],
)
return <RunFeedContext.Provider value={value}>{children}</RunFeedContext.Provider>
}
+596
View File
@@ -0,0 +1,596 @@
@import 'tailwindcss';
/* ============================================================================
LM-Gambit design tokens
A muted navy field with orange as the action colour and gold reserved for
measurement — metrics, scores and anything the eye should read as "data".
========================================================================== */
@theme {
/* Navy field, deepest to lightest */
--color-navy-950: #080e19;
--color-navy-900: #0b1322;
--color-navy-850: #101a2c;
--color-navy-800: #142138;
--color-navy-750: #1a2a45;
--color-navy-700: #223353;
--color-navy-600: #2e4467;
--color-navy-500: #3d587f;
/* Ink */
--color-ink-100: #eaf0fb;
--color-ink-200: #c8d5ea;
--color-ink-300: #9aaecb;
--color-ink-400: #7288ab;
--color-ink-500: #55688a;
/* Orange — actions, the running state, primary emphasis */
--color-ember-300: #ffb083;
--color-ember-400: #ff9a5c;
--color-ember-500: #f4813f;
--color-ember-600: #dd6926;
--color-ember-700: #b3521c;
/* Gold — metrics, highlights, selected state */
--color-gold-300: #f7d68f;
--color-gold-400: #f0c469;
--color-gold-500: #e3ad46;
--color-gold-600: #c48f31;
--color-gold-700: #966c23;
/* Semantic */
--color-mint-400: #4ecfa6;
--color-mint-500: #2fb98d;
--color-rose-400: #f0736e;
--color-rose-500: #dc5450;
--color-sky-400: #62a9e8;
--font-sans:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Inter, Roboto, system-ui,
sans-serif;
--font-mono:
'SF Mono', ui-monospace, SFMono-Regular, 'JetBrains Mono', 'Fira Code', Menlo, Consolas,
monospace;
--radius-card: 14px;
--animate-fade-up: fade-up 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-fade-in: fade-in 0.2s ease-out both;
--animate-sweep: sweep 1.6s ease-in-out infinite;
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(340%);
}
}
/* ========================================================================== */
@layer base {
* {
border-color: var(--color-navy-700);
}
html {
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--color-navy-950);
color: var(--color-ink-100);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.55;
/* A slow warm glow keeps the navy field from reading flat. */
background-image:
radial-gradient(
900px 520px at 8% -6%,
color-mix(in srgb, var(--color-ember-600) 11%, transparent),
transparent 62%
),
radial-gradient(
760px 460px at 96% 4%,
color-mix(in srgb, var(--color-gold-600) 8%, transparent),
transparent 60%
);
background-attachment: fixed;
}
::selection {
background: color-mix(in srgb, var(--color-ember-500) 34%, transparent);
color: var(--color-ink-100);
}
:focus-visible {
outline: 2px solid var(--color-ember-500);
outline-offset: 2px;
border-radius: 4px;
}
h1,
h2,
h3,
h4 {
letter-spacing: -0.015em;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-navy-600) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-navy-600);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-navy-500);
background-clip: content-box;
}
input,
textarea,
select,
button {
font: inherit;
color: inherit;
}
textarea {
resize: vertical;
}
}
/* ============================ component layer ============================= */
@layer components {
.surface {
background: color-mix(in srgb, var(--color-navy-850) 82%, transparent);
border: 1px solid var(--color-navy-700);
border-radius: var(--radius-card);
backdrop-filter: blur(10px);
}
.surface-raised {
background: color-mix(in srgb, var(--color-navy-800) 88%, transparent);
border: 1px solid var(--color-navy-700);
border-radius: var(--radius-card);
box-shadow:
0 1px 0 0 color-mix(in srgb, white 5%, transparent) inset,
0 18px 40px -24px rgb(0 0 0 / 0.75);
}
/* --- buttons --- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 0.95rem;
border-radius: 10px;
border: 1px solid transparent;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.005em;
cursor: pointer;
white-space: nowrap;
transition:
background-color 0.16s ease,
border-color 0.16s ease,
color 0.16s ease,
transform 0.16s ease,
box-shadow 0.16s ease;
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(180deg, var(--color-ember-500), var(--color-ember-600));
color: #1a0e05;
box-shadow: 0 10px 24px -14px color-mix(in srgb, var(--color-ember-500) 90%, transparent);
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(180deg, var(--color-ember-400), var(--color-ember-500));
}
.btn-ghost {
background: color-mix(in srgb, var(--color-navy-800) 70%, transparent);
border-color: var(--color-navy-700);
color: var(--color-ink-200);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-navy-750);
border-color: var(--color-navy-600);
color: var(--color-ink-100);
}
.btn-danger {
background: color-mix(in srgb, var(--color-rose-500) 16%, transparent);
border-color: color-mix(in srgb, var(--color-rose-500) 42%, transparent);
color: var(--color-rose-400);
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-rose-500) 26%, transparent);
color: #ffd7d5;
}
.btn-sm {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
border-radius: 8px;
}
/* --- form controls --- */
.field {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--color-navy-900) 75%, transparent);
border: 1px solid var(--color-navy-700);
color: var(--color-ink-100);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background-color 0.16s ease;
}
.field::placeholder {
color: var(--color-ink-500);
}
.field:hover:not(:disabled) {
border-color: var(--color-navy-600);
}
.field:focus {
outline: none;
border-color: var(--color-ember-500);
background: color-mix(in srgb, var(--color-navy-900) 90%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ember-500) 18%, transparent);
}
.field:disabled {
opacity: 0.5;
cursor: not-allowed;
}
select.field {
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--color-ink-400) 50%),
linear-gradient(135deg, var(--color-ink-400) 50%, transparent 50%);
background-position:
calc(100% - 17px) calc(50% + 1px),
calc(100% - 12px) calc(50% + 1px);
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
padding-right: 2rem;
}
select.field option {
background: var(--color-navy-850);
color: var(--color-ink-100);
}
.label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--color-ink-400);
margin-bottom: 0.4rem;
}
/* --- chips --- */
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.02em;
border: 1px solid transparent;
white-space: nowrap;
}
.chip-neutral {
background: color-mix(in srgb, var(--color-navy-700) 55%, transparent);
border-color: var(--color-navy-600);
color: var(--color-ink-300);
}
.chip-gold {
background: color-mix(in srgb, var(--color-gold-500) 14%, transparent);
border-color: color-mix(in srgb, var(--color-gold-500) 38%, transparent);
color: var(--color-gold-300);
}
.chip-ember {
background: color-mix(in srgb, var(--color-ember-500) 15%, transparent);
border-color: color-mix(in srgb, var(--color-ember-500) 40%, transparent);
color: var(--color-ember-300);
}
.chip-mint {
background: color-mix(in srgb, var(--color-mint-500) 14%, transparent);
border-color: color-mix(in srgb, var(--color-mint-500) 38%, transparent);
color: var(--color-mint-400);
}
.chip-rose {
background: color-mix(in srgb, var(--color-rose-500) 14%, transparent);
border-color: color-mix(in srgb, var(--color-rose-500) 38%, transparent);
color: var(--color-rose-400);
}
/* --- misc --- */
.hairline {
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--color-navy-700) 18%,
var(--color-navy-700) 82%,
transparent
);
}
.num {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
}
/* ======================= markdown + code highlighting ===================== */
.md {
color: var(--color-ink-200);
font-size: 0.875rem;
line-height: 1.7;
}
.md > *:first-child {
margin-top: 0;
}
.md h1,
.md h2,
.md h3,
.md h4 {
color: var(--color-ink-100);
font-weight: 650;
line-height: 1.3;
margin: 1.6em 0 0.6em;
}
.md h1 {
font-size: 1.4rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-navy-700);
}
.md h2 {
font-size: 1.15rem;
color: var(--color-gold-300);
}
.md h3 {
font-size: 1rem;
color: var(--color-ember-300);
}
.md p {
margin: 0.85em 0;
}
.md a {
color: var(--color-ember-400);
text-decoration: underline;
text-underline-offset: 2px;
}
.md strong {
color: var(--color-ink-100);
font-weight: 650;
}
.md ul,
.md ol {
margin: 0.85em 0;
padding-left: 1.4em;
}
.md ul {
list-style: disc;
}
.md ol {
list-style: decimal;
}
.md li {
margin: 0.3em 0;
}
.md li::marker {
color: var(--color-gold-600);
}
.md blockquote {
margin: 1em 0;
padding: 0.1em 1em;
border-left: 3px solid var(--color-gold-600);
background: color-mix(in srgb, var(--color-navy-800) 55%, transparent);
border-radius: 0 8px 8px 0;
color: var(--color-ink-300);
}
.md hr {
margin: 1.8em 0;
border: 0;
height: 1px;
background: var(--color-navy-700);
}
.md table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.8125rem;
display: block;
overflow-x: auto;
}
.md th,
.md td {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-navy-700);
text-align: left;
}
.md th {
background: color-mix(in srgb, var(--color-navy-800) 70%, transparent);
color: var(--color-ink-200);
font-weight: 600;
}
.md :not(pre) > code {
font-family: var(--font-mono);
font-size: 0.8125em;
padding: 0.12em 0.4em;
border-radius: 5px;
background: color-mix(in srgb, var(--color-navy-750) 80%, transparent);
border: 1px solid var(--color-navy-700);
color: var(--color-gold-300);
}
.md pre {
margin: 1em 0;
padding: 0.9rem 1rem;
border-radius: 11px;
background: var(--color-navy-950);
border: 1px solid var(--color-navy-700);
overflow-x: auto;
font-size: 0.8125rem;
line-height: 1.6;
}
.md pre code {
font-family: var(--font-mono);
background: none;
border: 0;
padding: 0;
color: var(--color-ink-200);
}
/* highlight.js — navy base with warm keywords */
.hljs-comment,
.hljs-quote {
color: var(--color-ink-500);
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-section,
.hljs-doctag,
.hljs-type,
.hljs-name,
.hljs-strong {
color: var(--color-ember-400);
}
.hljs-string,
.hljs-title,
.hljs-attr,
.hljs-attribute,
.hljs-regexp,
.hljs-addition {
color: var(--color-gold-300);
}
.hljs-number,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-bullet,
.hljs-link {
color: var(--color-mint-400);
}
.hljs-built_in,
.hljs-builtin-name,
.hljs-class .hljs-title,
.hljs-title.class_,
.hljs-title.function_ {
color: var(--color-sky-400);
}
.hljs-meta,
.hljs-symbol {
color: var(--color-ink-400);
}
.hljs-deletion {
color: var(--color-rose-400);
}
.hljs-emphasis {
font-style: italic;
}
+281
View File
@@ -0,0 +1,281 @@
/** Typed client for the LM-Gambit Python API. */
export interface ProviderSummary {
name: string
is_default: boolean
}
export interface ModelSummary {
id: string
display_name: string
}
export interface TestPrompt {
filename: string
title: string
prompt: string
}
export interface TestSuite {
tests: TestPrompt[]
directory: string
}
export interface RunMetrics {
tokens_per_second: number
total_tokens: number
time_to_first_token: number
stop_reason: string
}
export interface RunSummary {
average_tokens_per_second: number
average_time_to_first_token: number
total_tokens: number
passed: number
failed: number
overall_score: number | null
graded: number
}
/** One plugin's verdict on one answer. */
export interface GradeResult {
grader: string
score: number
label: string
notes: string
}
export interface PluginSummary {
name: string
slug: string
version: string
description: string
path: string
enabled: boolean
hooks: string[]
error: string | null
}
export type RunStatus = 'running' | 'completed' | 'failed' | 'cancelled'
export interface Run {
id: string
status: RunStatus
provider: string
model_id: string
model_label: string
temperature: number
total: number
completed: number
started_at: number
finished_at: number | null
report_name: string | null
error: string | null
summary: RunSummary
}
export interface ReportSummary {
name: string
model_label: string
size_bytes: number
modified_at: number
}
export interface ReportDetail extends ReportSummary {
content: string
}
export interface ModelPathEntry {
nickname: string
path: string
}
export interface Settings {
default_provider: string
default_temperature: number
local_model_paths: ModelPathEntry[]
tests_dir: string
results_dir: string
models_dir: string
}
export interface SystemInfo {
version: string
engine_architecture: string
engine_runtime: string
template_ok: boolean
python_version: string
metrics: Record<string, string>
}
export interface PlaygroundResult {
response: string | null
error: string | null
metrics: RunMetrics | null
elapsed: number
}
/* ------------------------------------------------------------------ events */
export interface TestCompletedEvent {
type: 'test.completed'
index: number
total: number
title: string
filename: string
status: 'ok' | 'error'
elapsed: number
response: string | null
error: string | null
metrics: RunMetrics | null
grades: GradeResult[]
score: number | null
}
export interface RunStartedEvent {
type: 'run.started'
run: Run
tests: { index: number; title: string; filename: string }[]
}
export interface RunTerminalEvent {
type: 'run.completed' | 'run.failed' | 'run.cancelled'
run: Run
message?: string
}
export type RunEvent = RunStartedEvent | TestCompletedEvent | RunTerminalEvent
/* ------------------------------------------------------------------ client */
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response
try {
response = await fetch(`/api${path}`, {
headers: init?.body ? { 'Content-Type': 'application/json' } : undefined,
...init,
})
} catch {
throw new ApiError('Cannot reach the LM-Gambit server. Is it still running?', 0)
}
if (response.status === 204) return undefined as T
const raw = await response.text()
let payload: unknown = null
if (raw) {
try {
payload = JSON.parse(raw)
} catch {
payload = raw
}
}
if (!response.ok) {
const detail =
payload && typeof payload === 'object' && 'detail' in payload
? (payload as { detail: unknown }).detail
: payload
throw new ApiError(
typeof detail === 'string' ? detail : `Request failed (${response.status})`,
response.status,
)
}
return payload as T
}
export const api = {
system: () => request<SystemInfo>('/system'),
providers: () => request<ProviderSummary[]>('/providers'),
models: (provider: string) =>
request<{ provider: string; models: ModelSummary[] }>(
`/providers/${encodeURIComponent(provider)}/models`,
),
tests: () => request<TestSuite>('/tests'),
saveTests: (prompts: string[]) =>
request<TestSuite>('/tests', {
method: 'PUT',
body: JSON.stringify({ tests: prompts.map((prompt) => ({ prompt })) }),
}),
startRun: (body: {
provider: string
model_id: string
temperature: number
filenames?: string[]
}) => request<Run>('/runs', { method: 'POST', body: JSON.stringify(body) }),
runs: () => request<Run[]>('/runs'),
activeRun: () => request<Run | null>('/runs/active'),
cancelRun: (id: string) => request<Run>(`/runs/${id}/cancel`, { method: 'POST' }),
reports: () => request<ReportSummary[]>('/reports'),
report: (name: string) => request<ReportDetail>(`/reports/${encodeURIComponent(name)}`),
deleteReport: (name: string) =>
request<void>(`/reports/${encodeURIComponent(name)}`, { method: 'DELETE' }),
playground: (body: {
provider: string
model_id: string
prompt: string
temperature: number
}) => request<PlaygroundResult>('/playground', { method: 'POST', body: JSON.stringify(body) }),
plugins: () => request<PluginSummary[]>('/plugins'),
reloadPlugins: () => request<PluginSummary[]>('/plugins/reload', { method: 'POST' }),
settings: () => request<Settings>('/settings'),
saveSettings: (body: {
default_provider?: string
default_temperature?: number
local_model_paths?: ModelPathEntry[]
}) => request<Settings>('/settings', { method: 'PUT', body: JSON.stringify(body) }),
}
/** Subscribe to a run's server-sent event feed. Returns an unsubscribe fn. */
export function subscribeToRun(
runId: string,
onEvent: (event: RunEvent) => void,
onError?: () => void,
): () => void {
const source = new EventSource(`/api/runs/${runId}/events`)
const handle = (raw: MessageEvent) => {
try {
onEvent(JSON.parse(raw.data) as RunEvent)
} catch {
/* ignore malformed frames */
}
}
for (const name of [
'run.started',
'test.completed',
'run.completed',
'run.failed',
'run.cancelled',
]) {
source.addEventListener(name, handle as EventListener)
}
source.onerror = () => {
// The server closes the stream once a run reaches a terminal state, which
// EventSource reports as an error. Only surface it while still connecting.
if (source.readyState === EventSource.CLOSED) onError?.()
}
return () => source.close()
}
+52
View File
@@ -0,0 +1,52 @@
/** Small display helpers shared across views. */
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—'
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`
if (seconds < 60) return `${seconds.toFixed(1)}s`
const minutes = Math.floor(seconds / 60)
const rest = Math.round(seconds % 60)
if (minutes < 60) return `${minutes}m ${rest}s`
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function formatRelativeTime(epochSeconds: number): string {
const delta = Date.now() / 1000 - epochSeconds
if (delta < 60) return 'just now'
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`
if (delta < 604800) return `${Math.floor(delta / 86400)}d ago`
return new Date(epochSeconds * 1000).toLocaleDateString()
}
export function formatTimestamp(epochSeconds: number): string {
return new Date(epochSeconds * 1000).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function clockTime(epochMs: number = Date.now()): string {
return new Date(epochMs).toLocaleTimeString(undefined, { hour12: false })
}
/** Title shown for a question — the engine uses the first non-empty line. */
export function deriveTitle(prompt: string, fallback = 'Untitled question'): string {
for (const line of prompt.split('\n')) {
const trimmed = line.trim()
if (trimmed) return trimmed
}
return fallback
}
export function cx(...values: (string | false | null | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Parses the markdown reports the Python engine writes.
*
* The shape is fixed by `.core/templates/test-block.md`, so a report can be
* read back into structured metrics for charting without the server having to
* keep a parallel database of past runs.
*/
export interface ParsedTest {
index: number
title: string
filename: string
ok: boolean
error: string | null
tokensPerSecond: number | null
totalTokens: number | null
timeToFirstToken: number | null
stopReason: string | null
/** Mean plugin score for this question, when the report carries grades. */
score: number | null
}
export interface ParsedReport {
modelLabel: string
tests: ParsedTest[]
averageTokensPerSecond: number | null
averageTimeToFirstToken: number | null
totalTokens: number | null
overallScore: number | null
graders: string[]
}
function numberOrNull(raw: string | undefined): number | null {
if (!raw) return null
const value = Number.parseFloat(raw.replace(/[^\d.-]/g, ''))
return Number.isFinite(value) ? value : null
}
function metric(block: string, label: string): string | undefined {
const match = block.match(new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`))
return match?.[1]?.trim()
}
/**
* Read grades back out of the analysis block that grader plugins write.
*
* Rows look like: `| 3 | Question title | 80% (4/5 checks) | My Grader | notes |`
* A question graded by several plugins gets one row each, so scores are
* averaged per question — matching how the run computed them live.
*/
function parseGrades(markdown: string): {
scores: Map<number, number>
overall: number | null
graders: string[]
} {
const block = markdown.match(/<!--ANALYSIS_START-->([\s\S]*?)<!--ANALYSIS_END-->/)?.[1]
const scores = new Map<number, number>()
const graders = new Set<string>()
if (!block) return { scores, overall: null, graders: [] }
const overallMatch = block.match(/\*\*Overall score:\s*(\d+(?:\.\d+)?)%/)
const overall = overallMatch ? Number.parseFloat(overallMatch[1]) / 100 : null
const collected = new Map<number, number[]>()
const row = /^\|\s*(\d+)\s*\|[^|]*\|\s*(\d+(?:\.\d+)?)%[^|]*\|([^|]*)\|/gm
let match: RegExpExecArray | null
while ((match = row.exec(block)) !== null) {
const index = Number.parseInt(match[1], 10)
const value = Number.parseFloat(match[2]) / 100
if (!Number.isFinite(index) || !Number.isFinite(value)) continue
collected.set(index, [...(collected.get(index) ?? []), value])
const grader = match[3]?.trim()
if (grader && grader !== '—') graders.add(grader)
}
for (const [index, values] of collected) {
scores.set(index, values.reduce((a, b) => a + b, 0) / values.length)
}
return { scores, overall, graders: [...graders] }
}
export function parseReport(markdown: string): ParsedReport {
const modelLabel =
markdown.match(/^#\s*Automated Diagnostic Report:\s*(.+)$/m)?.[1]?.trim() ?? 'Unknown model'
const summary = markdown.match(/<!--SUMMARY_START-->([\s\S]*?)<!--SUMMARY_END-->/)?.[1] ?? ''
const { scores, overall, graders } = parseGrades(markdown)
const tests: ParsedTest[] = []
// Split on the test headings the template emits; the first chunk is the preamble.
const chunks = markdown.split(/^##\s+Test\s+(\d+):\s*(.*)$/m)
for (let i = 1; i < chunks.length; i += 3) {
const index = Number.parseInt(chunks[i], 10)
const title = (chunks[i + 1] ?? '').trim()
const body = chunks[i + 2] ?? ''
const errorMatch = body.match(/\*\*ERROR:\*\*\s*(.+)/)
const stopReason = metric(body, 'Stop Reason') ?? null
tests.push({
index,
title: title || `Test ${index}`,
filename: body.match(/\*Source:\*\s*`([^`]+)`/)?.[1] ?? '',
ok: !errorMatch,
error: errorMatch?.[1]?.trim() ?? null,
tokensPerSecond: numberOrNull(metric(body, 'Tokens/s')),
totalTokens: numberOrNull(metric(body, 'Total Tokens')),
timeToFirstToken: numberOrNull(metric(body, 'Time to First Token')),
stopReason: stopReason === 'N/A' ? null : stopReason,
score: scores.get(index) ?? null,
})
}
return {
modelLabel,
tests,
averageTokensPerSecond: numberOrNull(metric(summary, 'Average Tokens/s')),
averageTimeToFirstToken: numberOrNull(metric(summary, 'Average Time to First Token')),
totalTokens: numberOrNull(metric(summary, 'Total Tokens Generated')),
overallScore: overall,
graders,
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* A ~40 line History API router.
*
* The app has five top-level views and no data loaders, so a routing library
* would be pure overhead. This gives real URLs, working back/forward buttons
* and deep links (e.g. /reports/automated_report_Qwen.md) with no dependency.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
interface RouterValue {
path: string
navigate: (to: string, options?: { replace?: boolean }) => void
}
const RouterContext = createContext<RouterValue>({ path: '/', navigate: () => {} })
export function RouterProvider({ children }: { children: ReactNode }) {
const [path, setPath] = useState(() => window.location.pathname || '/')
useEffect(() => {
const onPop = () => setPath(window.location.pathname || '/')
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
const navigate = useCallback((to: string, options?: { replace?: boolean }) => {
if (to === window.location.pathname) return
window.history[options?.replace ? 'replaceState' : 'pushState']({}, '', to)
setPath(to)
window.scrollTo({ top: 0 })
}, [])
const value = useMemo(() => ({ path, navigate }), [path, navigate])
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>
}
export function useRouter() {
return useContext(RouterContext)
}
export function Link({
to,
className,
children,
...rest
}: { to: string; className?: string; children: ReactNode } & Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
'href'
>) {
const { navigate } = useRouter()
return (
<a
href={to}
className={className}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
event.preventDefault()
navigate(to)
}}
{...rest}
>
{children}
</a>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { App } from './App'
import { RouterProvider } from './lib/router'
import { ToastProvider } from './components/ui'
import { RunFeedProvider } from './hooks/useRunFeed'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider>
<ToastProvider>
<RunFeedProvider>
<App />
</RunFeedProvider>
</ToastProvider>
</RouterProvider>
</StrictMode>,
)
+20
View File
@@ -0,0 +1,20 @@
import { CircleSlash } from 'lucide-react'
import { Card, EmptyState } from '../components/ui'
import { Link } from '../lib/router'
export function NotFoundPage() {
return (
<Card>
<EmptyState
icon={<CircleSlash size={20} />}
title="Nothing here"
description="That page does not exist in LM-Gambit."
action={
<Link to="/" className="btn btn-primary">
Back to the run dashboard
</Link>
}
/>
</Card>
)
}
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react'
import { FlaskConical, Info, Plus, Send, Zap } from 'lucide-react'
import {
Card,
CardHeader,
EmptyState,
ErrorNote,
PageHeader,
Spinner,
StatTile,
TemperatureSlider,
useAsync,
useToast,
} from '../components/ui'
import { Markdown } from '../components/Markdown'
import { Link } from '../lib/router'
import { api, ApiError, type PlaygroundResult } from '../lib/api'
import { formatDuration } from '../lib/format'
import { useRunFeed } from '../hooks/useRunFeed'
export function PlaygroundPage() {
const toast = useToast()
const { isLive } = useRunFeed()
const providers = useAsync(() => api.providers(), [])
const [provider, setProvider] = useState('')
const [modelId, setModelId] = useState('')
const [temperature, setTemperature] = useState(0.3)
const [prompt, setPrompt] = useState('')
const [result, setResult] = useState<PlaygroundResult | null>(null)
const [sending, setSending] = useState(false)
useEffect(() => {
if (!providers.data?.length || provider) return
setProvider(providers.data.find((p) => p.is_default)?.name ?? providers.data[0].name)
}, [providers.data, provider])
const models = useAsync(
() => (provider ? api.models(provider) : Promise.resolve(null)),
[provider],
)
useEffect(() => {
const list = models.data?.models ?? []
setModelId((current) => (list.some((m) => m.id === current) ? current : (list[0]?.id ?? '')))
}, [models.data])
const send = async () => {
if (!prompt.trim() || !provider || !modelId) return
setSending(true)
setResult(null)
try {
setResult(await api.playground({ provider, model_id: modelId, prompt, temperature }))
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setSending(false)
}
}
const saveAsQuestion = async () => {
if (!prompt.trim()) return
try {
const suite = await api.tests()
await api.saveTests([...suite.tests.map((t) => t.prompt), prompt.trim()])
toast('Added to the suite as the last question.', 'success')
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
}
}
const disabled = isLive || sending
return (
<>
<PageHeader
title="Playground"
subtitle="Try a single prompt against a model without touching the suite or writing a report."
/>
{isLive && (
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-ember-500/35 bg-ember-500/8 px-4 py-3">
<Info size={15} className="mt-0.5 shrink-0 text-ember-400" />
<p className="text-[0.8125rem] text-ember-300">
A diagnostic run is using the engine. The playground is paused until it finishes.
</p>
</div>
)}
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-5 xl:col-span-5">
<Card raised>
<CardHeader title="Target" icon={<Zap size={14} />} />
<div className="space-y-4 p-4">
{providers.error && <ErrorNote message={providers.error} onRetry={providers.reload} />}
<div>
<label className="label" htmlFor="pg-provider">
Provider
</label>
<select
id="pg-provider"
className="field"
value={provider}
disabled={disabled}
onChange={(event) => setProvider(event.target.value)}
>
{(providers.data ?? []).map((item) => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="pg-model">
Model
</label>
{models.error ? (
<ErrorNote message={models.error} onRetry={models.reload} />
) : (
<select
id="pg-model"
className="field"
value={modelId}
disabled={disabled || models.loading || !models.data?.models.length}
onChange={(event) => setModelId(event.target.value)}
>
{models.loading && <option>Discovering models</option>}
{!models.loading && !models.data?.models.length && (
<option>No models found</option>
)}
{(models.data?.models ?? []).map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
</select>
)}
</div>
<TemperatureSlider value={temperature} onChange={setTemperature} disabled={disabled} />
</div>
</Card>
<Card raised>
<CardHeader
title="Prompt"
icon={<FlaskConical size={14} />}
actions={
<button
className="btn btn-ghost btn-sm"
onClick={saveAsQuestion}
disabled={!prompt.trim() || disabled}
title="Append this prompt to the diagnostic suite"
>
<Plus size={12} />
Add to suite
</button>
}
/>
<div className="p-4">
<textarea
className="field min-h-44 font-mono text-[0.8125rem] leading-relaxed"
placeholder="Ask the model something…"
value={prompt}
spellCheck={false}
disabled={disabled}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') send()
}}
/>
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-ink-500"> to send</span>
<button
className="btn btn-primary"
onClick={send}
disabled={!prompt.trim() || !modelId || disabled}
>
{sending ? <Spinner /> : <Send size={14} />}
{sending ? 'Generating…' : 'Send'}
</button>
</div>
</div>
</Card>
</div>
<div className="xl:col-span-7">
<Card className="xl:sticky xl:top-6">
<CardHeader
title="Response"
icon={<Send size={14} />}
actions={
result?.elapsed ? (
<span className="chip chip-neutral num">{formatDuration(result.elapsed)}</span>
) : null
}
/>
{sending && (
<div className="flex items-center gap-2.5 px-4 py-10 text-[0.8125rem] text-ink-400">
<Spinner className="text-ember-400" />
Waiting on the model a cold model may take a moment to load.
</div>
)}
{!sending && !result && (
<EmptyState
icon={<FlaskConical size={20} />}
title="Nothing sent yet"
description="Write a prompt and hit Send. Responses render as markdown with syntax highlighting."
/>
)}
{!sending && result?.error && (
<div className="p-4">
<ErrorNote message={result.error} />
</div>
)}
{!sending && result?.response != null && (
<>
{result.metrics && (
<div className="grid grid-cols-2 gap-2.5 border-b border-navy-700 p-4 sm:grid-cols-4">
<StatTile
label="tok/s"
value={result.metrics.tokens_per_second.toFixed(1)}
tone="gold"
/>
<StatTile
label="TTFT"
value={result.metrics.time_to_first_token.toFixed(2)}
unit="s"
tone="gold"
/>
<StatTile
label="Tokens"
value={result.metrics.total_tokens.toLocaleString()}
tone="neutral"
/>
<StatTile label="Stop" value={result.metrics.stop_reason} tone="neutral" />
</div>
)}
<div className="max-h-[60vh] overflow-y-auto p-4">
<Markdown>{result.response || '_Empty response._'}</Markdown>
</div>
</>
)}
</Card>
</div>
</div>
<p className="mt-5 text-[0.75rem] text-ink-500">
Looking to save a set of questions instead?{' '}
<Link to="/suite" className="text-ember-400 underline underline-offset-2">
Open the Suite Builder
</Link>
.
</p>
</>
)
}
+371
View File
@@ -0,0 +1,371 @@
import { useEffect, useMemo, useState } from 'react'
import {
BarChart3,
CheckCircle2,
FileText,
LayoutList,
Table2,
Trash2,
TriangleAlert,
} from 'lucide-react'
import {
Card,
CardHeader,
ConfirmDialog,
EmptyState,
ErrorNote,
PageHeader,
SkeletonRows,
StatTile,
useAsync,
useToast,
} from '../components/ui'
import { Markdown } from '../components/Markdown'
import { ScoreBadge, scoreTone } from '../components/ScoreBadge'
import { ThroughputChart } from '../components/ThroughputChart'
import { api, ApiError, type ReportDetail } from '../lib/api'
import { parseReport } from '../lib/report'
import { cx, formatBytes, formatRelativeTime } from '../lib/format'
import { useRouter } from '../lib/router'
type View = 'chart' | 'table' | 'full'
/**
* Hide the sentinels the engine uses to locate rewritable blocks (the summary,
* and the analysis section grader plugins fill in). Only those exact markers
* are removed, so HTML inside a model's code fence is left untouched.
*/
function readableReport(markdown: string): string {
return markdown.replace(/^[ \t]*<!--(?:SUMMARY|ANALYSIS)_(?:START|END)-->[ \t]*\n?/gm, '')
}
export function ReportsPage() {
const toast = useToast()
const { path, navigate } = useRouter()
const reports = useAsync(() => api.reports(), [])
const routeName = useMemo(() => {
const match = path.match(/^\/reports\/(.+)$/)
return match ? decodeURIComponent(match[1]) : null
}, [path])
const [detail, setDetail] = useState<ReportDetail | null>(null)
const [detailError, setDetailError] = useState<string | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [view, setView] = useState<View>('chart')
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
// Default to the newest report when landing on /reports.
useEffect(() => {
if (routeName || !reports.data?.length) return
navigate(`/reports/${encodeURIComponent(reports.data[0].name)}`, { replace: true })
}, [routeName, reports.data, navigate])
useEffect(() => {
if (!routeName) {
setDetail(null)
return
}
let cancelled = false
setLoadingDetail(true)
setDetailError(null)
api
.report(routeName)
.then((value) => {
if (!cancelled) setDetail(value)
})
.catch((cause: unknown) => {
if (!cancelled) setDetailError(cause instanceof Error ? cause.message : String(cause))
})
.finally(() => {
if (!cancelled) setLoadingDetail(false)
})
return () => {
cancelled = true
}
}, [routeName])
const parsed = useMemo(() => (detail ? parseReport(detail.content) : null), [detail])
const confirmDelete = async () => {
if (!pendingDelete) return
try {
await api.deleteReport(pendingDelete)
toast(`Deleted ${pendingDelete}`, 'success')
if (routeName === pendingDelete) {
setDetail(null)
navigate('/reports', { replace: true })
}
reports.reload()
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setPendingDelete(null)
}
}
const items = reports.data ?? []
return (
<>
<PageHeader
title="Reports"
subtitle="Every run writes a markdown report to the results folder. Open one to review answers and throughput."
/>
{reports.error && <ErrorNote message={reports.error} onRetry={reports.reload} />}
{!reports.loading && items.length === 0 && !reports.error ? (
<Card>
<EmptyState
icon={<FileText size={20} />}
title="No reports yet"
description="Run the diagnostic suite and the report will show up here."
/>
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
{/* ------------------------------------------------------- list */}
<div className="xl:col-span-4">
<Card className="xl:sticky xl:top-6">
<CardHeader
title="Saved reports"
icon={<LayoutList size={14} />}
actions={<span className="chip chip-neutral num">{items.length}</span>}
/>
{reports.loading ? (
<SkeletonRows rows={3} />
) : (
<ul className="max-h-[70vh] divide-y divide-navy-700/60 overflow-y-auto">
{items.map((report) => {
const active = report.name === routeName
return (
<li key={report.name}>
<div
className={cx(
'group relative flex items-center gap-2 px-4 py-3 transition-colors',
active ? 'bg-navy-750/60' : 'hover:bg-navy-800/40',
)}
>
{active && (
<span className="absolute top-1/2 left-0 h-8 w-[3px] -translate-y-1/2 rounded-r-full bg-ember-500" />
)}
<button
className="min-w-0 flex-1 text-left"
onClick={() =>
navigate(`/reports/${encodeURIComponent(report.name)}`)
}
>
<div
className={cx(
'truncate text-[0.8125rem] font-medium',
active ? 'text-ink-100' : 'text-ink-200',
)}
>
{report.model_label}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[0.6875rem] text-ink-500">
<span>{formatRelativeTime(report.modified_at)}</span>
<span>·</span>
<span className="num">{formatBytes(report.size_bytes)}</span>
</div>
</button>
<button
className="shrink-0 rounded-md p-1.5 text-ink-500 opacity-0 transition-all group-hover:opacity-100 hover:bg-rose-500/15 hover:text-rose-400 focus-visible:opacity-100"
onClick={() => setPendingDelete(report.name)}
aria-label={`Delete ${report.name}`}
title="Delete report"
>
<Trash2 size={13} />
</button>
</div>
</li>
)
})}
</ul>
)}
</Card>
</div>
{/* ----------------------------------------------------- detail */}
<div className="space-y-5 xl:col-span-8">
{detailError && <ErrorNote message={detailError} />}
{loadingDetail && (
<Card>
<SkeletonRows rows={5} />
</Card>
)}
{detail && parsed && !loadingDetail && (
<>
<Card raised>
<div className="p-4">
<h2 className="text-[1.05rem] font-semibold text-ink-100">
{parsed.modelLabel}
</h2>
<p className="mt-0.5 font-mono text-[0.6875rem] text-ink-500">{detail.name}</p>
<div
className={cx(
'mt-3.5 grid grid-cols-2 gap-2.5',
parsed.overallScore != null ? 'sm:grid-cols-5' : 'sm:grid-cols-4',
)}
>
{parsed.overallScore != null && (
<StatTile
label="Score"
value={`${Math.round(parsed.overallScore * 100)}%`}
tone={scoreTone(parsed.overallScore)}
hint={parsed.graders.join(', ')}
/>
)}
<StatTile
label="Avg tok/s"
value={parsed.averageTokensPerSecond?.toFixed(1) ?? '—'}
tone="gold"
/>
<StatTile
label="Avg TTFT"
value={parsed.averageTimeToFirstToken?.toFixed(2) ?? '—'}
unit="s"
tone="gold"
/>
<StatTile
label="Tokens"
value={parsed.totalTokens?.toLocaleString() ?? '—'}
tone="neutral"
/>
<StatTile label="Questions" value={parsed.tests.length} tone="neutral" />
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="chip chip-mint">
<CheckCircle2 size={11} />
{parsed.tests.filter((t) => t.ok).length} answered
</span>
{parsed.tests.some((t) => !t.ok) && (
<span className="chip chip-rose">
<TriangleAlert size={11} />
{parsed.tests.filter((t) => !t.ok).length} errored
</span>
)}
</div>
</div>
</Card>
<Card>
<CardHeader
title="Analysis"
icon={<BarChart3 size={14} />}
actions={
<div className="flex gap-1">
{(
[
['chart', 'Chart', BarChart3],
['table', 'Table', Table2],
['full', 'Report', FileText],
] as const
).map(([key, label, Icon]) => (
<button
key={key}
className={
view === key
? 'btn btn-sm border-gold-500/40 bg-gold-500/15 text-gold-300'
: 'btn btn-ghost btn-sm'
}
onClick={() => setView(key)}
>
<Icon size={12} />
{label}
</button>
))}
</div>
}
/>
{view === 'chart' && <ThroughputChart tests={parsed.tests} />}
{view === 'table' && (
<div className="overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead>
<tr className="border-b border-navy-700 text-left text-[0.6875rem] tracking-wide text-ink-500 uppercase">
<th className="px-4 py-2.5 font-semibold">#</th>
<th className="px-3 py-2.5 font-semibold">Question</th>
{parsed.overallScore != null && (
<th className="px-3 py-2.5 text-right font-semibold">Score</th>
)}
<th className="px-3 py-2.5 text-right font-semibold">tok/s</th>
<th className="px-3 py-2.5 text-right font-semibold">Tokens</th>
<th className="px-3 py-2.5 text-right font-semibold">TTFT</th>
<th className="px-4 py-2.5 font-semibold">Stop</th>
</tr>
</thead>
<tbody className="divide-y divide-navy-700/60">
{parsed.tests.map((test) => (
<tr key={test.index} className="hover:bg-navy-800/40">
<td className="num px-4 py-2.5 text-ink-500">{test.index}</td>
<td className="max-w-xs px-3 py-2.5">
<span className="block truncate text-ink-200" title={test.title}>
{test.title}
</span>
{!test.ok && (
<span className="mt-0.5 flex items-center gap-1 text-[0.6875rem] text-rose-400">
<TriangleAlert size={10} />
Error: {test.error}
</span>
)}
</td>
{parsed.overallScore != null && (
<td className="px-3 py-2.5 text-right">
{test.score != null ? (
<ScoreBadge score={test.score} />
) : (
<span className="text-ink-500"></span>
)}
</td>
)}
<td className="num px-3 py-2.5 text-right text-gold-400">
{test.tokensPerSecond?.toFixed(1) ?? '—'}
</td>
<td className="num px-3 py-2.5 text-right text-ink-300">
{test.totalTokens?.toLocaleString() ?? '—'}
</td>
<td className="num px-3 py-2.5 text-right text-ink-300">
{test.timeToFirstToken != null
? `${test.timeToFirstToken.toFixed(2)}s`
: '—'}
</td>
<td className="px-4 py-2.5 text-ink-500">{test.stopReason ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{view === 'full' && (
<div className="max-h-[75vh] overflow-y-auto px-4 py-4">
<Markdown>{readableReport(detail.content)}</Markdown>
</div>
)}
</Card>
</>
)}
</div>
</div>
)}
<ConfirmDialog
open={!!pendingDelete}
title="Delete this report?"
body={`${pendingDelete} will be removed from the results folder. This cannot be undone.`}
confirmLabel="Delete"
destructive
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</>
)
}
+619
View File
@@ -0,0 +1,619 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Ban,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleSlash,
Clock,
FileText,
ListChecks,
Play,
RefreshCw,
Terminal,
TriangleAlert,
Zap,
} from 'lucide-react'
import {
Card,
CardHeader,
EmptyState,
ErrorNote,
PageHeader,
Spinner,
StatTile,
TemperatureSlider,
useAsync,
useToast,
} from '../components/ui'
import { Markdown } from '../components/Markdown'
import { ScoreBadge, scoreTone } from '../components/ScoreBadge'
import { Link } from '../lib/router'
import { api, ApiError, type TestCompletedEvent } from '../lib/api'
import { clockTime, cx, formatDuration } from '../lib/format'
import { useRunFeed } from '../hooks/useRunFeed'
export function RunPage() {
const toast = useToast()
const { run, outcomes, logs, starting, isLive, start, cancel, clear } = useRunFeed()
const providers = useAsync(() => api.providers(), [])
const suite = useAsync(() => api.tests(), [])
const [provider, setProvider] = useState('')
const [modelId, setModelId] = useState('')
const [temperature, setTemperature] = useState(0.1)
const [selected, setSelected] = useState<string[] | null>(null)
const [pickerOpen, setPickerOpen] = useState(false)
// Seed provider + temperature from saved settings, falling back to the
// provider the server marks as default.
useEffect(() => {
if (!providers.data?.length || provider) return
api
.settings()
.then((settings) => {
const preferred = providers.data?.find((p) => p.name === settings.default_provider)
setProvider(preferred?.name ?? providers.data?.find((p) => p.is_default)?.name ?? providers.data![0].name)
setTemperature(settings.default_temperature)
})
.catch(() => {
setProvider(providers.data?.find((p) => p.is_default)?.name ?? providers.data![0].name)
})
}, [providers.data, provider])
const models = useAsync(
() => (provider ? api.models(provider) : Promise.resolve(null)),
[provider],
)
useEffect(() => {
const list = models.data?.models ?? []
setModelId((current) => (list.some((m) => m.id === current) ? current : (list[0]?.id ?? '')))
}, [models.data])
const tests = suite.data?.tests ?? []
const selectedFilenames = selected ?? tests.map((t) => t.filename)
const allSelected = selectedFilenames.length === tests.length
const toggleTest = (filename: string) => {
const next = new Set(selectedFilenames)
if (next.has(filename)) next.delete(filename)
else next.add(filename)
setSelected(tests.filter((t) => next.has(t.filename)).map((t) => t.filename))
}
const launch = async () => {
if (!provider || !modelId) return
try {
await start({
provider,
model_id: modelId,
temperature,
filenames: allSelected ? undefined : selectedFilenames,
})
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
}
}
const doCancel = async () => {
try {
await cancel()
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
}
}
const canLaunch =
!!provider && !!modelId && selectedFilenames.length > 0 && !isLive && !starting
return (
<>
<PageHeader
title="Diagnostic Run"
subtitle="Every question is sent to the model one at a time. Results stream in as each one finishes."
actions={
run && !isLive ? (
<button className="btn btn-ghost" onClick={clear}>
<RefreshCw size={14} />
New run
</button>
) : null
}
/>
<div className="grid gap-5 xl:grid-cols-12">
{/* ------------------------------------------------ launch controls */}
<div className="space-y-5 xl:col-span-4">
<Card raised>
<CardHeader title="Target" icon={<Zap size={14} />} />
<div className="space-y-4 p-4">
{providers.error && <ErrorNote message={providers.error} onRetry={providers.reload} />}
<div>
<label className="label" htmlFor="provider">
Provider
</label>
<select
id="provider"
className="field"
value={provider}
disabled={isLive || providers.loading}
onChange={(event) => setProvider(event.target.value)}
>
{(providers.data ?? []).map((item) => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between">
<label className="label" htmlFor="model">
Model
</label>
<button
className="mb-1.5 flex items-center gap-1 text-[0.6875rem] text-ink-500 transition-colors hover:text-ember-400 disabled:opacity-50"
onClick={models.reload}
disabled={isLive || models.loading}
>
{models.loading ? <Spinner className="h-3 w-3" /> : <RefreshCw size={11} />}
Refresh
</button>
</div>
{models.error ? (
<ErrorNote message={models.error} onRetry={models.reload} />
) : (
<select
id="model"
className="field"
value={modelId}
disabled={isLive || models.loading || !models.data?.models.length}
onChange={(event) => setModelId(event.target.value)}
>
{models.loading && <option>Discovering models</option>}
{!models.loading && !models.data?.models.length && <option>No models found</option>}
{(models.data?.models ?? []).map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
</select>
)}
</div>
<TemperatureSlider value={temperature} onChange={setTemperature} disabled={isLive} />
</div>
</Card>
{/* question selection */}
<Card raised>
<CardHeader
title="Questions"
icon={<ListChecks size={14} />}
actions={
<span className="chip chip-gold num">
{selectedFilenames.length}/{tests.length}
</span>
}
/>
<div className="p-4">
{suite.error && <ErrorNote message={suite.error} onRetry={suite.reload} />}
{!suite.error && tests.length === 0 && !suite.loading && (
<EmptyState
icon={<ListChecks size={20} />}
title="No questions yet"
description="Add questions in the Suite Builder before running diagnostics."
action={
<Link to="/suite" className="btn btn-primary">
Open Suite Builder
</Link>
}
/>
)}
{tests.length > 0 && (
<>
<div className="mb-3 flex items-center gap-2">
<button
className="btn btn-ghost btn-sm"
disabled={isLive}
onClick={() => setSelected(allSelected ? [] : tests.map((t) => t.filename))}
>
{allSelected ? 'Clear all' : 'Select all'}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setPickerOpen((open) => !open)}
>
{pickerOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{pickerOpen ? 'Hide list' : 'Choose questions'}
</button>
</div>
{pickerOpen && (
<ul className="animate-fade-in max-h-72 space-y-1 overflow-y-auto pr-1">
{tests.map((test, index) => {
const checked = selectedFilenames.includes(test.filename)
return (
<li key={test.filename}>
<label
className={cx(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-2.5 py-2 transition-colors',
checked
? 'border-gold-500/35 bg-gold-500/8'
: 'border-navy-700 bg-navy-900/30 hover:border-navy-600',
isLive && 'cursor-not-allowed opacity-60',
)}
>
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-[#e3ad46]"
checked={checked}
disabled={isLive}
onChange={() => toggleTest(test.filename)}
/>
<span className="min-w-0">
<span className="num mr-1.5 text-[0.6875rem] text-ink-500">
{String(index + 1).padStart(2, '0')}
</span>
<span className="text-[0.75rem] text-ink-200">{test.title}</span>
</span>
</label>
</li>
)
})}
</ul>
)}
{!pickerOpen && (
<p className="text-[0.75rem] text-ink-500">
{allSelected
? `The full suite of ${tests.length} questions will run in order.`
: `${selectedFilenames.length} of ${tests.length} questions selected.`}
</p>
)}
</>
)}
</div>
</Card>
<div className="flex gap-2">
{isLive ? (
<button className="btn btn-danger flex-1" onClick={doCancel}>
<Ban size={15} />
Cancel run
</button>
) : (
<button className="btn btn-primary flex-1" onClick={launch} disabled={!canLaunch}>
{starting ? <Spinner /> : <Play size={15} />}
{starting ? 'Starting…' : 'Run diagnostics'}
</button>
)}
</div>
</div>
{/* ------------------------------------------------------ live feed */}
<div className="space-y-5 xl:col-span-8">
<RunStatusPanel />
<ResultsFeed outcomes={outcomes} live={isLive} hasRun={!!run} />
{logs.length > 0 && <ConsoleLog />}
</div>
</div>
</>
)
}
/* ------------------------------------------------------------------ panels */
function RunStatusPanel() {
const { run, outcomes, isLive } = useRunFeed()
if (!run) {
return (
<Card>
<EmptyState
icon={<Play size={20} />}
title="Ready when you are"
description="Pick a provider and model, choose which questions to ask, then start the run. Progress appears here live."
/>
</Card>
)
}
const percent = run.total ? Math.round((outcomes.length / run.total) * 100) : 0
const elapsed = (run.finished_at ?? Date.now() / 1000) - run.started_at
const statusChip = {
running: <span className="chip chip-ember">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-ember-400" />
Running
</span>,
completed: <span className="chip chip-mint"><CheckCircle2 size={11} />Complete</span>,
cancelled: <span className="chip chip-neutral"><CircleSlash size={11} />Cancelled</span>,
failed: <span className="chip chip-rose"><TriangleAlert size={11} />Failed</span>,
}[run.status]
return (
<Card raised>
<div className="p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{statusChip}
<h2 className="truncate text-[0.9375rem] font-semibold text-ink-100">
{run.model_label}
</h2>
</div>
<p className="mt-0.5 text-[0.75rem] text-ink-500">
{run.provider} · T={run.temperature} · started {clockTime(run.started_at * 1000)}
</p>
</div>
{run.report_name && (
<Link to={`/reports/${encodeURIComponent(run.report_name)}`} className="btn btn-ghost btn-sm">
<FileText size={13} />
Open report
</Link>
)}
</div>
{/* progress */}
<div className="mb-4">
<div className="mb-1.5 flex items-baseline justify-between text-[0.75rem]">
<span className="text-ink-400">
{outcomes.length} of {run.total} questions
</span>
<span className="num font-semibold text-gold-400">{percent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-navy-800">
<div
className={cx(
'h-full rounded-full transition-[width] duration-500 ease-out',
run.status === 'failed'
? 'bg-rose-500'
: 'bg-gradient-to-r from-ember-500 to-gold-400',
)}
style={{ width: `${Math.max(percent, 2)}%` }}
/>
</div>
</div>
{run.error && <ErrorNote message={run.error} />}
<div
className={cx(
'grid grid-cols-2 gap-2.5',
run.summary.overall_score != null ? 'sm:grid-cols-5' : 'sm:grid-cols-4',
)}
>
{run.summary.overall_score != null && (
<StatTile
label="Score"
value={`${Math.round(run.summary.overall_score * 100)}%`}
tone={scoreTone(run.summary.overall_score)}
hint={`${run.summary.graded} graded`}
/>
)}
<StatTile
label="Avg tok/s"
value={run.summary.average_tokens_per_second.toFixed(1)}
tone="gold"
/>
<StatTile
label="Avg TTFT"
value={run.summary.average_time_to_first_token.toFixed(2)}
unit="s"
tone="gold"
/>
<StatTile label="Tokens" value={run.summary.total_tokens.toLocaleString()} tone="neutral" />
<StatTile
label={isLive ? 'Elapsed' : 'Duration'}
value={formatDuration(elapsed)}
tone="neutral"
/>
</div>
{(run.summary.passed > 0 || run.summary.failed > 0) && (
<div className="mt-2.5 flex items-center gap-2 text-[0.75rem]">
<span className="chip chip-mint">
<CheckCircle2 size={11} />
{run.summary.passed} answered
</span>
{run.summary.failed > 0 && (
<span className="chip chip-rose">
<TriangleAlert size={11} />
{run.summary.failed} errored
</span>
)}
</div>
)}
</div>
</Card>
)
}
function ResultsFeed({
outcomes,
live,
hasRun,
}: {
outcomes: TestCompletedEvent[]
live: boolean
hasRun: boolean
}) {
if (!hasRun) return null
return (
<Card>
<CardHeader
title="Results"
icon={<ListChecks size={14} />}
hint={live ? 'Streaming as each question completes' : undefined}
actions={<span className="chip chip-neutral num">{outcomes.length}</span>}
/>
{outcomes.length === 0 ? (
<div className="flex items-center gap-2.5 px-4 py-8 text-[0.8125rem] text-ink-400">
<Spinner className="text-ember-400" />
Waiting on the first response the model may need to load into memory.
</div>
) : (
<ul className="divide-y divide-navy-700/70">
{outcomes.map((outcome) => (
<ResultRow key={outcome.index} outcome={outcome} />
))}
{live && (
<li className="flex items-center gap-2.5 px-4 py-3.5 text-[0.8125rem] text-ink-400">
<Spinner className="text-ember-400" />
Running question {outcomes.length + 1}
</li>
)}
</ul>
)}
</Card>
)
}
function ResultRow({ outcome }: { outcome: TestCompletedEvent }) {
const [open, setOpen] = useState(false)
const ok = outcome.status === 'ok'
return (
<li className="animate-fade-up">
<button
className="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-navy-800/40"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<span className="mt-0.5 shrink-0">
{ok ? (
<CheckCircle2 size={15} className="text-mint-400" />
) : (
<TriangleAlert size={15} className="text-rose-400" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="num text-[0.6875rem] text-ink-500">
{String(outcome.index).padStart(2, '0')}
</span>
<span className="truncate text-[0.8125rem] font-medium text-ink-100">
{outcome.title}
</span>
{outcome.score != null && (
<span className="ml-auto shrink-0">
<ScoreBadge
score={outcome.score}
title={outcome.grades
.map((g) => `${g.grader}: ${Math.round(g.score * 100)}%`)
.join('\n')}
/>
</span>
)}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.6875rem] text-ink-500">
<span className="font-mono">{outcome.filename}</span>
{ok && outcome.metrics && (
<>
<span className="num text-gold-400">
{outcome.metrics.tokens_per_second} tok/s
</span>
<span className="num">{outcome.metrics.total_tokens} tokens</span>
<span className="num">TTFT {outcome.metrics.time_to_first_token}s</span>
<span className="num">
<Clock size={9} className="mr-0.5 inline" />
{formatDuration(outcome.elapsed)}
</span>
</>
)}
{!ok && <span className="truncate text-rose-400">{outcome.error}</span>}
</span>
</span>
<ChevronRight
size={15}
className={cx('mt-0.5 shrink-0 text-ink-500 transition-transform', open && 'rotate-90')}
/>
</button>
{open && (
<div className="animate-fade-in border-t border-navy-700/60 bg-navy-950/40 px-4 py-3.5">
{outcome.grades.length > 0 && (
<div className="mb-3.5 space-y-1.5 rounded-xl border border-navy-700 bg-navy-900/50 px-3.5 py-3">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
Plugin grades
</div>
{outcome.grades.map((grade) => (
<div key={grade.grader} className="flex items-start gap-2.5 text-[0.75rem]">
<ScoreBadge score={grade.score} />
<div className="min-w-0">
<span className="text-ink-200">{grade.grader}</span>
{grade.label && <span className="ml-1.5 text-ink-500">({grade.label})</span>}
{grade.notes && (
<div className="text-[0.6875rem] text-ink-500">{grade.notes}</div>
)}
</div>
</div>
))}
</div>
)}
{ok ? (
<Markdown>{outcome.response ?? '_No response body._'}</Markdown>
) : (
<ErrorNote message={outcome.error ?? 'Unknown error'} />
)}
</div>
)}
</li>
)
}
function ConsoleLog() {
const { logs } = useRunFeed()
const [open, setOpen] = useState(false)
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) endRef.current?.scrollIntoView({ block: 'nearest' })
}, [logs, open])
const toneClass = useMemo(
() => ({
info: 'text-ink-400',
ok: 'text-mint-400',
warn: 'text-gold-400',
error: 'text-rose-400',
}),
[],
)
return (
<Card>
<CardHeader
title="Activity log"
icon={<Terminal size={14} />}
actions={
<button className="btn btn-ghost btn-sm" onClick={() => setOpen((value) => !value)}>
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{open ? 'Collapse' : 'Expand'}
</button>
}
/>
{open && (
<div className="max-h-64 overflow-y-auto px-4 py-3 font-mono text-[0.6875rem] leading-relaxed">
{logs.map((line) => (
<div key={line.id} className="flex gap-2.5">
<span className="shrink-0 text-ink-500">{clockTime(line.at)}</span>
<span className={cx('min-w-0 break-words', toneClass[line.tone])}>{line.message}</span>
</div>
))}
<div ref={endRef} />
</div>
)}
</Card>
)
}
+434
View File
@@ -0,0 +1,434 @@
import { useCallback, useEffect, useState } from 'react'
import {
Blocks,
CircleSlash,
Cpu,
FolderOpen,
HardDrive,
Info,
Plus,
RefreshCw,
RotateCcw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
TriangleAlert,
} from 'lucide-react'
import {
Card,
CardHeader,
ErrorNote,
PageHeader,
SkeletonRows,
Spinner,
TemperatureSlider,
useAsync,
useToast,
} from '../components/ui'
import { api, ApiError, type ModelPathEntry, type PluginSummary } from '../lib/api'
import { cx } from '../lib/format'
export function SettingsPage() {
const toast = useToast()
const providers = useAsync(() => api.providers(), [])
const system = useAsync(() => api.system(), [])
const [defaultProvider, setDefaultProvider] = useState('')
const [defaultTemperature, setDefaultTemperature] = useState(0.1)
const [paths, setPaths] = useState<ModelPathEntry[]>([])
const [dirs, setDirs] = useState<{ tests: string; results: string; models: string } | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
setLoading(true)
setLoadError(null)
api
.settings()
.then((settings) => {
setDefaultProvider(settings.default_provider)
setDefaultTemperature(settings.default_temperature)
setPaths(settings.local_model_paths)
setDirs({
tests: settings.tests_dir,
results: settings.results_dir,
models: settings.models_dir,
})
})
.catch((cause: unknown) =>
setLoadError(cause instanceof Error ? cause.message : String(cause)),
)
.finally(() => setLoading(false))
}, [])
useEffect(load, [load])
const save = async () => {
setSaving(true)
try {
const saved = await api.saveSettings({
default_provider: defaultProvider,
default_temperature: defaultTemperature,
local_model_paths: paths.filter((entry) => entry.path.trim()),
})
setPaths(saved.local_model_paths)
toast('Settings saved.', 'success')
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setSaving(false)
}
}
const updatePath = (index: number, patch: Partial<ModelPathEntry>) =>
setPaths((list) => list.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
return (
<>
<PageHeader
title="Settings"
subtitle="Defaults for new runs, plus where the Local Engine looks for model weights."
actions={
<>
<button className="btn btn-ghost" onClick={load} disabled={saving || loading}>
<RotateCcw size={14} />
Reload
</button>
<button className="btn btn-primary" onClick={save} disabled={saving || loading}>
{saving ? <Spinner /> : <Save size={14} />}
{saving ? 'Saving…' : 'Save settings'}
</button>
</>
}
/>
{loadError && <ErrorNote message={loadError} onRetry={load} />}
{loading ? (
<Card>
<SkeletonRows rows={4} />
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-5 xl:col-span-7">
<Card raised>
<CardHeader
title="Run defaults"
icon={<SlidersHorizontal size={14} />}
hint="Applied when the Run page loads"
/>
<div className="space-y-4 p-4">
<div>
<label className="label" htmlFor="default-provider">
Default provider
</label>
<select
id="default-provider"
className="field"
value={defaultProvider}
onChange={(event) => setDefaultProvider(event.target.value)}
>
{(providers.data ?? []).map((item) => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</select>
</div>
<TemperatureSlider value={defaultTemperature} onChange={setDefaultTemperature} />
</div>
</Card>
<Card raised>
<CardHeader
title="Model search paths"
icon={<FolderOpen size={14} />}
hint="Folders scanned for .gguf and MLX weights"
actions={
<button
className="btn btn-ghost btn-sm"
onClick={() => setPaths((list) => [...list, { nickname: '', path: '' }])}
>
<Plus size={12} />
Add
</button>
}
/>
<div className="space-y-2.5 p-4">
{paths.length === 0 && (
<p className="py-2 text-[0.8125rem] text-ink-500">
No extra paths. The engine still scans the bundled{' '}
<code className="font-mono text-[0.75rem] text-gold-300">models/</code> folder.
</p>
)}
{paths.map((entry, index) => (
<div key={index} className="flex items-start gap-2">
<input
className="field w-32 shrink-0"
placeholder="Nickname"
value={entry.nickname}
onChange={(event) => updatePath(index, { nickname: event.target.value })}
/>
<input
className="field min-w-0 flex-1 font-mono text-[0.75rem]"
placeholder="/path/to/models"
value={entry.path}
spellCheck={false}
onChange={(event) => updatePath(index, { path: event.target.value })}
/>
<button
className="mt-1 shrink-0 rounded-md p-1.5 text-ink-500 transition-colors hover:bg-rose-500/15 hover:text-rose-400"
onClick={() => setPaths((list) => list.filter((_, i) => i !== index))}
aria-label="Remove path"
>
<Trash2 size={14} />
</button>
</div>
))}
<div className="flex items-start gap-2 pt-1 text-[0.75rem] text-ink-500">
<Info size={13} className="mt-0.5 shrink-0 text-gold-600" />
<p>
Nicknames are for your reference only. Paths are shared with the CLI through{' '}
<code className="font-mono text-[0.6875rem]">.core/user_settings.json</code>,
and <code className="font-mono text-[0.6875rem]">LOCAL_LLM_PATHS</code> is still
honoured on top of them.
</p>
</div>
</div>
</Card>
</div>
<div className="space-y-5 xl:col-span-5">
<Card>
<CardHeader title="Engine" icon={<Cpu size={14} />} />
<div className="p-4">
{system.error && <ErrorNote message={system.error} onRetry={system.reload} />}
{system.data && (
<dl className="space-y-2.5 text-[0.8125rem]">
<InfoRow label="Runtime" value={system.data.engine_runtime} mono />
<InfoRow label="Architecture" value={system.data.engine_architecture} />
<InfoRow label="Platform" value={system.data.metrics.platform ?? '—'} />
<InfoRow label="Python" value={system.data.python_version} mono />
<InfoRow label="LM-Gambit" value={`v${system.data.version}`} mono />
<InfoRow
label="Report template"
value={system.data.template_ok ? 'Found' : 'Missing'}
tone={system.data.template_ok ? 'ok' : 'bad'}
/>
</dl>
)}
</div>
</Card>
<Card>
<CardHeader title="Folders" icon={<HardDrive size={14} />} />
<div className="space-y-3 p-4">
{dirs && (
<>
<PathRow label="Questions" path={dirs.tests} />
<PathRow label="Reports" path={dirs.results} />
<PathRow label="Bundled models" path={dirs.models} />
</>
)}
</div>
</Card>
<PluginsCard />
<Card>
<CardHeader title="Environment overrides" icon={<Settings2 size={14} />} />
<div className="space-y-2 p-4 text-[0.75rem] text-ink-400">
{[
['LM_STUDIO_BASE_URL', 'LM Studio endpoint'],
['AUTO_TEST_TEMPERATURE', 'Default temperature'],
['AUTO_TEST_PROVIDER', 'Default provider for the CLI'],
['LOCAL_LLM_PATHS', 'Extra model directories'],
].map(([name, description]) => (
<div key={name} className="flex items-baseline justify-between gap-3">
<code className="font-mono text-[0.6875rem] text-gold-300">{name}</code>
<span className="text-right text-ink-500">{description}</span>
</div>
))}
</div>
</Card>
</div>
</div>
)}
</>
)
}
function PluginsCard() {
const toast = useToast()
const plugins = useAsync(() => api.plugins(), [])
const [reloading, setReloading] = useState(false)
const reload = async () => {
setReloading(true)
try {
const next = await api.reloadPlugins()
plugins.setData(next)
const broken = next.filter((p) => p.error).length
toast(
broken
? `Reloaded ${next.length} plugin(s) — ${broken} failed to load.`
: `Reloaded ${next.length} plugin(s).`,
broken ? 'error' : 'success',
)
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setReloading(false)
}
}
const items = plugins.data ?? []
return (
<Card>
<CardHeader
title="Plugins"
icon={<Blocks size={14} />}
hint="Dropped into plugins/"
actions={
<button className="btn btn-ghost btn-sm" onClick={reload} disabled={reloading}>
{reloading ? <Spinner className="h-3 w-3" /> : <RefreshCw size={12} />}
Reload
</button>
}
/>
<div className="p-4">
{plugins.error && <ErrorNote message={plugins.error} onRetry={plugins.reload} />}
{!plugins.loading && items.length === 0 && !plugins.error && (
<p className="text-[0.8125rem] text-ink-500">
No plugins yet. Copy{' '}
<code className="font-mono text-[0.75rem] text-gold-300">plugins/_skeleton.py</code> to
a new name to write one.
</p>
)}
<div className="space-y-2.5">
{items.map((plugin) => (
<PluginRow key={plugin.slug} plugin={plugin} />
))}
</div>
{items.some((p) => p.hooks.includes('register_routes')) && (
<p className="mt-3 flex items-start gap-1.5 text-[0.6875rem] text-ink-500">
<Info size={12} className="mt-0.5 shrink-0 text-gold-600" />
Reloading picks up new graders and hooks. Plugins that add HTTP routes need a full
server restart.
</p>
)}
</div>
</Card>
)
}
function PluginRow({ plugin }: { plugin: PluginSummary }) {
const broken = !!plugin.error
const disabled = !plugin.enabled && !broken
return (
<div
className={cx(
'rounded-xl border px-3.5 py-2.5',
broken
? 'border-rose-500/40 bg-rose-500/8'
: disabled
? 'border-navy-700 bg-navy-900/30 opacity-65'
: 'border-navy-700 bg-navy-900/40',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
{broken ? (
<TriangleAlert size={12} className="shrink-0 text-rose-400" />
) : disabled ? (
<CircleSlash size={12} className="shrink-0 text-ink-500" />
) : (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-mint-400" />
)}
<span className="truncate text-[0.8125rem] font-medium text-ink-100">
{plugin.name}
</span>
<span className="num shrink-0 text-[0.6875rem] text-ink-500">v{plugin.version}</span>
</div>
{plugin.description && (
<p className="mt-0.5 text-[0.75rem] text-ink-400">{plugin.description}</p>
)}
</div>
<code className="shrink-0 font-mono text-[0.625rem] text-ink-500">{plugin.slug}</code>
</div>
{broken && (
<p className="mt-1.5 font-mono text-[0.6875rem] break-words text-rose-400">
{plugin.error}
</p>
)}
{disabled && (
<p className="mt-1.5 text-[0.6875rem] text-ink-500">
Disabled by <code className="font-mono">ENABLED = False</code>.
</p>
)}
{!broken && plugin.hooks.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{plugin.hooks.map((hook) => (
<span key={hook} className="chip chip-neutral font-mono !text-[0.625rem]">
{hook}
</span>
))}
</div>
)}
</div>
)
}
function InfoRow({
label,
value,
mono,
tone,
}: {
label: string
value: string
mono?: boolean
tone?: 'ok' | 'bad'
}) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-ink-500">{label}</dt>
<dd
className={[
'min-w-0 truncate text-right',
mono ? 'font-mono text-[0.75rem]' : '',
tone === 'ok' ? 'text-mint-400' : tone === 'bad' ? 'text-rose-400' : 'text-ink-200',
].join(' ')}
title={value}
>
{value}
</dd>
</div>
)
}
function PathRow({ label, path }: { label: string; path: string }) {
return (
<div>
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
{label}
</div>
<div className="mt-0.5 font-mono text-[0.6875rem] break-all text-ink-300" title={path}>
{path}
</div>
</div>
)
}
+441
View File
@@ -0,0 +1,441 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Copy,
Info,
ListChecks,
Plus,
RotateCcw,
Save,
Trash2,
} from 'lucide-react'
import {
Card,
ConfirmDialog,
EmptyState,
ErrorNote,
PageHeader,
SkeletonRows,
Spinner,
useToast,
} from '../components/ui'
import { api, ApiError } from '../lib/api'
import { cx, deriveTitle } from '../lib/format'
import { useRunFeed } from '../hooks/useRunFeed'
interface Draft {
key: string
prompt: string
filename: string | null
}
let keySeed = 1
const newKey = () => `q${keySeed++}`
export function SuitePage() {
const toast = useToast()
const { isLive } = useRunFeed()
const [drafts, setDrafts] = useState<Draft[]>([])
const [baseline, setBaseline] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const [focusKey, setFocusKey] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
setLoadError(null)
api
.tests()
.then((suite) => {
setDrafts(
suite.tests.map((test) => ({
key: newKey(),
prompt: test.prompt,
filename: test.filename,
})),
)
setBaseline(suite.tests.map((test) => test.prompt))
})
.catch((cause: unknown) =>
setLoadError(cause instanceof Error ? cause.message : String(cause)),
)
.finally(() => setLoading(false))
}, [])
useEffect(load, [load])
const current = drafts.map((draft) => draft.prompt)
const dirty =
current.length !== baseline.length ||
current.some((prompt, index) => prompt.trim() !== baseline[index]?.trim())
// Guard against losing edits on reload / close.
useEffect(() => {
if (!dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty])
const update = (key: string, prompt: string) =>
setDrafts((list) => list.map((d) => (d.key === key ? { ...d, prompt } : d)))
const addQuestion = () => {
const key = newKey()
setDrafts((list) => [...list, { key, prompt: '', filename: null }])
setFocusKey(key)
}
const duplicate = (key: string) =>
setDrafts((list) => {
const index = list.findIndex((d) => d.key === key)
if (index < 0) return list
const copy = { key: newKey(), prompt: list[index].prompt, filename: null }
return [...list.slice(0, index + 1), copy, ...list.slice(index + 1)]
})
const remove = (key: string) => setDrafts((list) => list.filter((d) => d.key !== key))
const move = (key: string, direction: -1 | 1) =>
setDrafts((list) => {
const index = list.findIndex((d) => d.key === key)
const target = index + direction
if (index < 0 || target < 0 || target >= list.length) return list
const next = [...list]
;[next[index], next[target]] = [next[target], next[index]]
return next
})
const save = async () => {
const blank = drafts.findIndex((d) => !d.prompt.trim())
if (blank >= 0) {
toast(`Question ${blank + 1} is empty. Add a prompt or remove it.`, 'error')
return
}
setSaving(true)
try {
const suite = await api.saveTests(drafts.map((d) => d.prompt))
setDrafts(
suite.tests.map((test) => ({
key: newKey(),
prompt: test.prompt,
filename: test.filename,
})),
)
setBaseline(suite.tests.map((test) => test.prompt))
toast(
`Saved ${suite.tests.length} question${suite.tests.length === 1 ? '' : 's'} to the suite.`,
'success',
)
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setSaving(false)
}
}
const deleting = drafts.find((d) => d.key === pendingDelete)
return (
<>
<PageHeader
title="Suite Builder"
subtitle="Each box is one question. They are sent to the model one at a time, in this order."
actions={
<>
{dirty && (
<button
className="btn btn-ghost"
onClick={() => setConfirmDiscard(true)}
disabled={saving}
>
<RotateCcw size={14} />
Discard
</button>
)}
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving || isLive}>
{saving ? <Spinner /> : <Save size={14} />}
{saving ? 'Saving…' : dirty ? 'Save suite' : 'Saved'}
</button>
</>
}
/>
{isLive && (
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-ember-500/35 bg-ember-500/8 px-4 py-3">
<Info size={15} className="mt-0.5 shrink-0 text-ember-400" />
<p className="text-[0.8125rem] text-ember-300">
A run is in progress. You can edit questions, but saving is blocked until it finishes.
</p>
</div>
)}
{loadError && <ErrorNote message={loadError} onRetry={load} />}
{loading ? (
<Card>
<SkeletonRows rows={4} />
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-3 xl:col-span-9">
{drafts.length === 0 ? (
<Card>
<EmptyState
icon={<ListChecks size={20} />}
title="The suite is empty"
description="Add your first question to start benchmarking."
action={
<button className="btn btn-primary" onClick={addQuestion}>
<Plus size={14} />
Add question
</button>
}
/>
</Card>
) : (
drafts.map((draft, index) => (
<QuestionCard
key={draft.key}
draft={draft}
index={index}
total={drafts.length}
autoFocus={focusKey === draft.key}
onFocused={() => setFocusKey(null)}
onChange={(prompt) => update(draft.key, prompt)}
onMoveUp={() => move(draft.key, -1)}
onMoveDown={() => move(draft.key, 1)}
onDuplicate={() => duplicate(draft.key)}
onDelete={() =>
draft.prompt.trim() ? setPendingDelete(draft.key) : remove(draft.key)
}
/>
))
)}
{drafts.length > 0 && (
<button
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-navy-600 py-3.5 text-[0.8125rem] font-medium text-ink-400 transition-colors hover:border-ember-500/60 hover:bg-ember-500/5 hover:text-ember-400"
onClick={addQuestion}
>
<Plus size={15} />
Add question
</button>
)}
</div>
<aside className="xl:col-span-3">
<div className="sticky top-6 space-y-3">
<Card>
<div className="p-4">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
Suite
</div>
<div className="num mt-1 text-[1.6rem] font-semibold text-gold-400">
{drafts.length}
</div>
<div className="text-[0.75rem] text-ink-500">
question{drafts.length === 1 ? '' : 's'}
{dirty && <span className="ml-1.5 text-ember-400">· unsaved</span>}
</div>
</div>
</Card>
<Card>
<div className="space-y-2.5 p-4 text-[0.75rem] leading-relaxed text-ink-400">
<p className="flex items-center gap-1.5 font-semibold text-ink-300">
<Info size={13} className="text-gold-500" />
How saving works
</p>
<p>
The whole prompt is sent to the model. Its{' '}
<span className="text-ink-200">first line</span> doubles as the title in reports,
so lead with the task.
</p>
<p>
Saving rewrites the suite as{' '}
<code className="rounded bg-navy-750 px-1 py-0.5 font-mono text-[0.6875rem] text-gold-300">
test1.txt testN.txt
</code>{' '}
in this order, so the CLI (
<code className="font-mono text-[0.6875rem]">auto-test.py</code>) sees exactly
what you see here.
</p>
</div>
</Card>
</div>
</aside>
</div>
)}
<ConfirmDialog
open={confirmDiscard}
title="Discard changes?"
body="Your unsaved edits will be replaced with the questions currently on disk."
confirmLabel="Discard"
destructive
onConfirm={() => {
setConfirmDiscard(false)
load()
}}
onCancel={() => setConfirmDiscard(false)}
/>
<ConfirmDialog
open={!!deleting}
title="Delete this question?"
body={deriveTitle(deleting?.prompt ?? '', 'This question')}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (pendingDelete) remove(pendingDelete)
setPendingDelete(null)
}}
onCancel={() => setPendingDelete(null)}
/>
</>
)
}
/* ------------------------------------------------------------------- card */
function QuestionCard({
draft,
index,
total,
autoFocus,
onFocused,
onChange,
onMoveUp,
onMoveDown,
onDuplicate,
onDelete,
}: {
draft: Draft
index: number
total: number
autoFocus: boolean
onFocused: () => void
onChange: (prompt: string) => void
onMoveUp: () => void
onMoveDown: () => void
onDuplicate: () => void
onDelete: () => void
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const title = deriveTitle(draft.prompt, 'Untitled question')
const empty = !draft.prompt.trim()
const words = draft.prompt.trim() ? draft.prompt.trim().split(/\s+/).length : 0
// Grow the box to fit its content so long prompts stay readable.
useLayoutEffect(() => {
const node = textareaRef.current
if (!node) return
node.style.height = 'auto'
node.style.height = `${Math.max(node.scrollHeight, 92)}px`
}, [draft.prompt])
useEffect(() => {
if (autoFocus) {
textareaRef.current?.focus()
onFocused()
}
}, [autoFocus, onFocused])
return (
<Card
className={cx('animate-fade-up overflow-hidden', empty && 'border-ember-500/40')}
>
<div className="flex items-center gap-3 border-b border-navy-700 px-3.5 py-2.5">
<span
className={cx(
'num flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[0.6875rem] font-semibold',
empty
? 'bg-ember-500/15 text-ember-400'
: 'bg-gold-500/12 text-gold-400',
)}
>
{index + 1}
</span>
<span
className={cx(
'min-w-0 flex-1 truncate text-[0.8125rem] font-medium',
empty ? 'text-ink-500 italic' : 'text-ink-100',
)}
title={title}
>
{empty ? 'Empty question — add a prompt' : title}
</span>
<span className="hidden shrink-0 items-center gap-2 text-[0.6875rem] text-ink-500 sm:flex">
{draft.filename && <span className="font-mono">{draft.filename}</span>}
<span className="num">{words}w</span>
</span>
<div className="flex shrink-0 items-center gap-0.5">
<IconButton label="Move up" disabled={index === 0} onClick={onMoveUp}>
<ArrowUp size={13} />
</IconButton>
<IconButton label="Move down" disabled={index === total - 1} onClick={onMoveDown}>
<ArrowDown size={13} />
</IconButton>
<IconButton label="Duplicate" onClick={onDuplicate}>
<Copy size={13} />
</IconButton>
<IconButton label="Delete" danger onClick={onDelete}>
<Trash2 size={13} />
</IconButton>
</div>
</div>
<textarea
ref={textareaRef}
value={draft.prompt}
onChange={(event) => onChange(event.target.value)}
placeholder={
'Write the question exactly as the model should receive it.\n\nExample: Write a Swift function that solves the FizzBuzz problem.'
}
spellCheck={false}
className="w-full resize-none border-0 bg-transparent px-4 py-3.5 font-mono text-[0.8125rem] leading-relaxed text-ink-200 placeholder:text-ink-500/70 focus:outline-none"
/>
</Card>
)
}
function IconButton({
children,
label,
onClick,
disabled,
danger,
}: {
children: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
danger?: boolean
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cx(
'flex h-6.5 w-6.5 items-center justify-center rounded-md p-1 transition-colors disabled:cursor-not-allowed disabled:opacity-30',
danger
? 'text-ink-500 hover:bg-rose-500/15 hover:text-rose-400'
: 'text-ink-500 hover:bg-navy-750 hover:text-ink-200',
)}
>
{children}
</button>
)
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// The Python server owns /api. In dev, Vite serves the UI on :5173 and proxies
// API + SSE calls through to uvicorn on :8765 so both share one origin.
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8765',
changeOrigin: true,
// Server-sent events must not be buffered by the proxy.
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
delete proxyRes.headers['content-length']
}
})
},
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 900,
},
})