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