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
@@ -1,5 +1,5 @@
|
|||||||
import { PageChrome } from "@/components/global/PageChrome";
|
import { PageChrome } from "@/components/global/PageChrome";
|
||||||
import { TopologyExplorer } from "@/components/topology/TopologyExplorer";
|
import { TopologyWorkbench } from "@/components/topology/TopologyWorkbench";
|
||||||
import { fetchTopologyCatalog } from "@/lib/api/topology";
|
import { fetchTopologyCatalog } from "@/lib/api/topology";
|
||||||
|
|
||||||
// Topologies page: browse the catalog and build/visualize a topology.
|
// Topologies page: browse the catalog and build/visualize a topology.
|
||||||
@@ -10,7 +10,7 @@ export default async function TopologiesPage() {
|
|||||||
title="Topologies"
|
title="Topologies"
|
||||||
description={`${catalog.length} organizational patterns to run your agents in`}
|
description={`${catalog.length} organizational patterns to run your agents in`}
|
||||||
>
|
>
|
||||||
<TopologyExplorer catalog={catalog} />
|
<TopologyWorkbench catalog={catalog} />
|
||||||
</PageChrome>
|
</PageChrome>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,3 +31,15 @@ test("browse the topology catalog and build a graph", async ({ page }) => {
|
|||||||
await page.getByRole("button", { name: "Build" }).click();
|
await page.getByRole("button", { name: "Build" }).click();
|
||||||
await expect(page.locator("svg[aria-label*='topology']")).toBeVisible();
|
await expect(page.locator("svg[aria-label*='topology']")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compare topologies shows a leaderboard and Pareto chart", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await page.getByRole("link", { name: "Topologies" }).click();
|
||||||
|
await page.getByRole("tab", { name: /compare/i }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Run comparison" }).click();
|
||||||
|
|
||||||
|
// Leaderboard table + Pareto chart render from the comparison result.
|
||||||
|
await expect(page.getByRole("cell", { name: /pipeline/i })).toBeVisible();
|
||||||
|
await expect(page.locator("svg[aria-label*='pareto']")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user