feat(frontend): topology compare/Pareto UI
Add a Compare tab to /topologies: pick a task + roles + topology kinds, build a graph per kind, run POST /api/topologies/compare, and render a leaderboard table + a quality/cost Pareto scatter (SVG, no deps). Wrap Build + Compare in a tabbed TopologyWorkbench. e2e p8 extended to run a comparison end-to-end (build×N → compare → leaderboard + Pareto). Full suite 39 green; build + TS clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7648487a59
commit
654ec0f511
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
|
||||
|
||||
interface CompareResult {
|
||||
kind: string;
|
||||
quality: number;
|
||||
tokens: number;
|
||||
turns: number;
|
||||
on_pareto: boolean;
|
||||
}
|
||||
interface Comparison {
|
||||
results: CompareResult[];
|
||||
leaderboard: number[];
|
||||
best_quality: number | null;
|
||||
best_value: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT_KINDS = ["hierarchical", "pipeline", "swarm"];
|
||||
|
||||
/** Run one task across several topologies and show a leaderboard + Pareto. */
|
||||
export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
|
||||
const [task, setTask] = useState("Draft a go-to-market launch plan.");
|
||||
const [roles, setRoles] = useState("coordinator, researcher, analyst, writer");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(DEFAULT_KINDS));
|
||||
const [cmp, setCmp] = useState<Comparison | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggle(kind: string) {
|
||||
setSelected((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(kind)) next.delete(kind);
|
||||
else next.add(kind);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const roleList = roles.split(",").map((r) => r.trim()).filter(Boolean);
|
||||
const kinds = [...selected];
|
||||
if (kinds.length === 0) throw new Error("Pick at least one topology");
|
||||
const graphs: TopologyGraph[] = await Promise.all(
|
||||
kinds.map(async (kind) => {
|
||||
const res = await fetch("/api/topologies/build", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind, roles: roleList }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Build ${kind} failed`);
|
||||
return (await res.json()) as TopologyGraph;
|
||||
}),
|
||||
);
|
||||
const res = await fetch("/api/topologies/compare", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ task, graphs }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Compare failed (${res.status})`);
|
||||
setCmp((await res.json()) as Comparison);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Compare failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Task
|
||||
<input
|
||||
value={task}
|
||||
onChange={(e) => setTask(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Roles (comma-separated)
|
||||
<input
|
||||
value={roles}
|
||||
onChange={(e) => setRoles(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{catalog.map((c) => (
|
||||
<button
|
||||
key={c.kind}
|
||||
type="button"
|
||||
onClick={() => toggle(c.kind)}
|
||||
aria-pressed={selected.has(c.kind)}
|
||||
className={`rounded-full border px-3 py-1 text-xs capitalize transition-colors ${
|
||||
selected.has(c.kind)
|
||||
? "border-coral bg-coral/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-surface-warm/50"
|
||||
}`}
|
||||
>
|
||||
{c.name.replace(/_/g, " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={busy}
|
||||
className="rounded-lg bg-coral px-4 py-2 text-sm font-medium text-white transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Running…" : "Run comparison"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-coral">{error}</p>}
|
||||
|
||||
{cmp && <Results cmp={cmp} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Results({ cmp }: { cmp: Comparison }) {
|
||||
const fmt = (q: number) => `${Math.round(q * 100)}%`;
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-2 font-medium">Topology</th>
|
||||
<th className="py-2 text-right font-medium">Quality</th>
|
||||
<th className="py-2 text-right font-medium">Tokens</th>
|
||||
<th className="py-2 text-right font-medium">Turns</th>
|
||||
<th className="py-2 text-right font-medium">Pareto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cmp.leaderboard.map((i) => {
|
||||
const r = cmp.results[i];
|
||||
return (
|
||||
<tr key={r.kind} className="border-b border-border/50">
|
||||
<td className="py-2 capitalize text-foreground">{r.kind.replace(/_/g, " ")}</td>
|
||||
<td className="py-2 text-right text-foreground">{fmt(r.quality)}</td>
|
||||
<td className="py-2 text-right text-muted-foreground">{r.tokens.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-muted-foreground">{r.turns}</td>
|
||||
<td className="py-2 text-right">{r.on_pareto ? "★" : ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ParetoChart results={cmp.results} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParetoChart({ results }: { results: CompareResult[] }) {
|
||||
const W = 560;
|
||||
const H = 240;
|
||||
const pad = 44;
|
||||
const maxTokens = Math.max(...results.map((r) => r.tokens), 1);
|
||||
const x = (t: number) => pad + (t / maxTokens) * (W - 2 * pad);
|
||||
const y = (q: number) => H - pad - q * (H - 2 * pad);
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-4">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label="quality vs cost pareto">
|
||||
{/* axes */}
|
||||
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#475569" strokeWidth={1} />
|
||||
<line x1={pad} y1={pad} x2={pad} y2={H - pad} stroke="#475569" strokeWidth={1} />
|
||||
<text x={W / 2} y={H - 10} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
||||
tokens →
|
||||
</text>
|
||||
<text x={14} y={H / 2} textAnchor="middle" fontSize={11} fill="#94a3b8" transform={`rotate(-90 14 ${H / 2})`}>
|
||||
quality ↑
|
||||
</text>
|
||||
{results.map((r) => (
|
||||
<g key={r.kind}>
|
||||
<circle
|
||||
cx={x(r.tokens)}
|
||||
cy={y(r.quality)}
|
||||
r={r.on_pareto ? 7 : 5}
|
||||
fill={r.on_pareto ? "#f96565" : "#64748b"}
|
||||
/>
|
||||
<text x={x(r.tokens) + 9} y={y(r.quality) + 3} fontSize={10} fill="#94a3b8" className="capitalize">
|
||||
{r.kind.replace(/_/g, " ")}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CatalogEntry } from "@/lib/api/topology";
|
||||
|
||||
import { TopologyCompare } from "./TopologyCompare";
|
||||
import { TopologyExplorer } from "./TopologyExplorer";
|
||||
|
||||
const TABS = ["build", "compare"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
/** Tabbed topology workbench: Build (catalog + visualizer) and Compare
|
||||
* (run a task across topologies → leaderboard + Pareto). */
|
||||
export function TopologyWorkbench({ catalog }: { catalog: CatalogEntry[] }) {
|
||||
const [tab, setTab] = useState<Tab>("build");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div role="tablist" className="flex gap-1 border-b border-border">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm capitalize transition-colors ${
|
||||
tab === t
|
||||
? "border-coral text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "build" ? (
|
||||
<TopologyExplorer catalog={catalog} />
|
||||
) : (
|
||||
<TopologyCompare catalog={catalog} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user