profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

Commit

ui and styling

commit 835d4ec

55 changed files with +3547 and −0

Jump to a changed file
  1. src/app/debate/[id]/page.tsx +33 −0
  2. src/app/debate/page.tsx +11 −0
  3. src/app/demo/[id]/page.tsx +28 −0
  4. src/app/demo/page.tsx +70 −0
  5. src/app/globals.css +108 −0
  6. src/app/history/page.tsx +67 −0
  7. src/app/layout.tsx +34 −0
  8. src/app/page.tsx +80 −0
  9. src/app/r/[token]/page.tsx +31 −0
  10. src/app/signin/page.tsx +50 −0
  11. src/components/api-key-dialog.tsx +199 −0
  12. src/components/debate/cost-breakdown.tsx +79 −0
  13. src/components/debate/cost-meter.tsx +44 −0
  14. src/components/debate/critique-matrix.tsx +146 −0
  15. src/components/debate/debate-actions.tsx +36 −0
  16. src/components/debate/debate-console.tsx +217 −0
  17. src/components/debate/debate-player.tsx +55 −0
  18. src/components/debate/debate-replay.tsx +10 −0
  19. src/components/debate/disagreements.tsx +53 −0
  20. src/components/debate/final-answer.tsx +92 −0
  21. src/components/debate/model-combobox.tsx +78 −0
  22. src/components/debate/model-panel.tsx +99 −0
  23. src/components/debate/model-picker.tsx +91 −0
  24. src/components/debate/new-debate.tsx +307 −0
  25. src/components/debate/revision-diff.tsx +90 −0
  26. src/components/debate/rich-text.tsx +38 −0
  27. src/components/debate/stage-timeline.tsx +127 −0
  28. src/components/history-list.tsx +105 −0
  29. src/components/key-button.tsx +38 −0
  30. src/components/landing-hero.tsx +82 −0
  31. src/components/site-header.tsx +78 −0
  32. src/components/theme-provider.tsx +8 −0
  33. src/components/theme-toggle.tsx +23 −0
  34. src/components/ui/badge.tsx +30 −0
  35. src/components/ui/button.tsx +43 −0
  36. src/components/ui/card.tsx +48 −0
  37. src/components/ui/collapsible.tsx +9 −0
  38. src/components/ui/dialog.tsx +86 −0
  39. src/components/ui/input.tsx +19 −0
  40. src/components/ui/label.tsx +19 −0
  41. src/components/ui/popover.tsx +30 −0
  42. src/components/ui/progress.tsx +24 −0
  43. src/components/ui/scroll-area.tsx +26 −0
  44. src/components/ui/select.tsx +79 −0
  45. src/components/ui/separator.tsx +25 −0
  46. src/components/ui/skeleton.tsx +7 −0
  47. src/components/ui/slider.tsx +24 −0
  48. src/components/ui/sonner.tsx +25 −0
  49. src/components/ui/switch.tsx +24 −0
  50. src/components/ui/tabs.tsx +51 −0
  51. src/components/ui/textarea.tsx +18 −0
  52. src/components/ui/tooltip.tsx +29 −0
  53. src/hooks/use-debate-playback.ts +163 −0
  54. src/hooks/use-debate-stream.ts +116 −0
  55. src/hooks/use-models.ts +45 −0
added src/app/debate/[id]/page.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import { ArrowLeft } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { currentUserId } from '@/auth';
5 +import { DebateReplay } from '@/components/debate/debate-replay';
6 +import { getDebateOwnership, loadDebateResult } from '@/db/repositories';
7 +
8 +export const dynamic = 'force-dynamic';
9 +
10 +export default async function DebateReplayPage({ params }: { params: Promise<{ id: string }> }) {
11 + const { id } = await params;
12 + const ownership = await getDebateOwnership(id);
13 + if (!ownership) notFound();
14 +
15 + const userId = await currentUserId();
16 + const canRead = ownership.isDemo || ownership.userId === null || ownership.userId === userId;
17 + if (!canRead) notFound();
18 +
19 + const result = await loadDebateResult(id);
20 + if (!result) notFound();
21 +
22 + return (
23 + <div className="container py-8">
24 + <Link
25 + href="/history"
26 + className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
27 + >
28 + <ArrowLeft className="h-4 w-4" /> Back to history
29 + </Link>
30 + <DebateReplay result={result} />
31 + </div>
32 + );
33 +}
added src/app/debate/page.tsx +11 −0
@@ -0,0 +1,11 @@
1 +import { NewDebate } from '@/components/debate/new-debate';
2 +
3 +export const dynamic = 'force-dynamic';
4 +
5 +export default function DebatePage() {
6 + return (
7 + <div className="container py-8">
8 + <NewDebate />
9 + </div>
10 + );
11 +}
added src/app/demo/[id]/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import { ArrowLeft } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { DebatePlayer } from '@/components/debate/debate-player';
5 +import { getDebateOwnership, loadDebateResult } from '@/db/repositories';
6 +
7 +export const dynamic = 'force-dynamic';
8 +
9 +export default async function DemoReplayPage({ params }: { params: Promise<{ id: string }> }) {
10 + const { id } = await params;
11 + const ownership = await getDebateOwnership(id);
12 + if (!ownership || !ownership.isDemo) notFound();
13 +
14 + const result = await loadDebateResult(id);
15 + if (!result) notFound();
16 +
17 + return (
18 + <div className="container py-8">
19 + <Link
20 + href="/demo"
21 + className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
22 + >
23 + <ArrowLeft className="h-4 w-4" /> All demos
24 + </Link>
25 + <DebatePlayer result={result} />
26 + </div>
27 + );
28 +}
added src/app/demo/page.tsx +70 −0
@@ -0,0 +1,70 @@
1 +import { ArrowRight, Play, Sparkles } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { Badge } from '@/components/ui/badge';
4 +import { Button } from '@/components/ui/button';
5 +import { displayNameForModel } from '@/core/types';
6 +import { listDemoDebates } from '@/db/repositories';
7 +import { formatUsd } from '@/lib/utils';
8 +
9 +export const dynamic = 'force-dynamic';
10 +
11 +export default async function DemoPage() {
12 + let demos: Awaited<ReturnType<typeof listDemoDebates>> = [];
13 + try {
14 + demos = await listDemoDebates();
15 + } catch {
16 + demos = [];
17 + }
18 +
19 + return (
20 + <div className="container max-w-4xl py-10">
21 + <div className="mb-8 space-y-2 text-center">
22 + <Badge variant="outline" className="gap-1.5">
23 + <Play className="h-3.5 w-3.5 text-primary" /> Recorded debates
24 + </Badge>
25 + <h1 className="text-3xl font-semibold">Watch a real debate replay</h1>
26 + <p className="mx-auto max-w-xl text-sm text-muted-foreground">
27 + These are real multi-model debates, recorded and replayed through the full UI - streaming, critique matrix,
28 + revision diffs, and all. No account or API key needed.
29 + </p>
30 + </div>
31 +
32 + {demos.length === 0 ? (
33 + <div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
34 + No demo debates seeded yet. Run <code className="font-mono">pnpm db:seed</code> to add them.
35 + </div>
36 + ) : (
37 + <div className="grid gap-4 sm:grid-cols-2">
38 + {demos.map((d) => (
39 + <Link
40 + key={d.id}
41 + href={`/demo/${d.id}`}
42 + className="group flex flex-col justify-between rounded-xl border bg-card p-5 transition-colors hover:border-primary/50"
43 + >
44 + <div>
45 + <div className="mb-2 flex items-center gap-2">
46 + <Sparkles className="h-4 w-4 text-primary" />
47 + <Badge variant="secondary">{d.roundsCompleted} rounds</Badge>
48 + <Badge variant="secondary">{formatUsd(d.totalCostUsd)}</Badge>
49 + </div>
50 + <h2 className="font-medium leading-snug">{d.question}</h2>
51 + <p className="mt-2 text-xs text-muted-foreground">
52 + {d.models.map(displayNameForModel).join(' ยท ')}
53 + </p>
54 + </div>
55 + <div className="mt-4 flex items-center gap-1 text-sm font-medium text-primary">
56 + Replay <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
57 + </div>
58 + </Link>
59 + ))}
60 + </div>
61 + )}
62 +
63 + <div className="mt-10 text-center">
64 + <Button asChild>
65 + <Link href="/debate">Run your own debate</Link>
66 + </Button>
67 + </div>
68 + </div>
69 + );
70 +}
added src/app/globals.css +108 −0
@@ -0,0 +1,108 @@
1 +@tailwind base;
2 +@tailwind components;
3 +@tailwind utilities;
4 +
5 +@layer base {
6 + :root {
7 + --background: 0 0% 100%;
8 + --foreground: 222 47% 11%;
9 +
10 + --card: 0 0% 100%;
11 + --card-foreground: 222 47% 11%;
12 + --popover: 0 0% 100%;
13 + --popover-foreground: 222 47% 11%;
14 +
15 + --primary: 243 75% 59%;
16 + --primary-foreground: 0 0% 100%;
17 + --secondary: 220 14% 96%;
18 + --secondary-foreground: 222 47% 11%;
19 + --muted: 220 14% 96%;
20 + --muted-foreground: 220 9% 46%;
21 + --accent: 243 75% 96%;
22 + --accent-foreground: 243 75% 40%;
23 + --destructive: 0 72% 51%;
24 + --destructive-foreground: 0 0% 100%;
25 +
26 + --border: 220 13% 91%;
27 + --input: 220 13% 88%;
28 + --ring: 243 75% 59%;
29 + --radius: 0.75rem;
30 +
31 + --stage-answer: 217 91% 60%;
32 + --stage-critique: 32 95% 54%;
33 + --stage-revision: 262 83% 63%;
34 + --stage-synthesis: 158 64% 42%;
35 + }
36 +
37 + .dark {
38 + --background: 224 32% 8%;
39 + --foreground: 210 40% 96%;
40 +
41 + --card: 224 28% 11%;
42 + --card-foreground: 210 40% 96%;
43 + --popover: 224 28% 10%;
44 + --popover-foreground: 210 40% 96%;
45 +
46 + --primary: 243 80% 67%;
47 + --primary-foreground: 224 32% 8%;
48 + --secondary: 222 20% 18%;
49 + --secondary-foreground: 210 40% 96%;
50 + --muted: 222 20% 16%;
51 + --muted-foreground: 217 15% 62%;
52 + --accent: 243 40% 22%;
53 + --accent-foreground: 243 90% 85%;
54 + --destructive: 0 63% 50%;
55 + --destructive-foreground: 0 0% 100%;
56 +
57 + --border: 222 20% 20%;
58 + --input: 222 20% 22%;
59 + --ring: 243 80% 67%;
60 +
61 + --stage-answer: 217 91% 65%;
62 + --stage-critique: 32 95% 60%;
63 + --stage-revision: 262 83% 70%;
64 + --stage-synthesis: 158 64% 50%;
65 + }
66 +}
67 +
68 +@layer base {
69 + * {
70 + @apply border-border;
71 + }
72 + body {
73 + @apply bg-background text-foreground;
74 + font-feature-settings: 'rlig' 1, 'calt' 1;
75 + -webkit-font-smoothing: antialiased;
76 + }
77 + /* Thin, unobtrusive scrollbars for the dense debate panels. */
78 + .scrollbar-thin {
79 + scrollbar-width: thin;
80 + scrollbar-color: hsl(var(--border)) transparent;
81 + }
82 + .scrollbar-thin::-webkit-scrollbar {
83 + width: 8px;
84 + height: 8px;
85 + }
86 + .scrollbar-thin::-webkit-scrollbar-thumb {
87 + background-color: hsl(var(--border));
88 + border-radius: 9999px;
89 + }
90 +}
91 +
92 +@layer utilities {
93 + /* Subtle streaming caret shown while a model is typing. */
94 + .streaming-caret::after {
95 + content: 'โ–';
96 + @apply ml-0.5 inline-block animate-pulse-subtle text-primary;
97 + }
98 + .prose-debate {
99 + @apply text-sm leading-relaxed text-foreground/90;
100 + }
101 + .prose-debate p {
102 + @apply mb-2;
103 + }
104 + .bg-grid {
105 + background-image: radial-gradient(hsl(var(--border)) 1px, transparent 1px);
106 + background-size: 22px 22px;
107 + }
108 +}
added src/app/history/page.tsx +67 −0
@@ -0,0 +1,67 @@
1 +import { History, Wallet } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { auth, signIn } from '@/auth';
4 +import { HistoryList } from '@/components/history-list';
5 +import { Button } from '@/components/ui/button';
6 +import { listDebates, monthlySpendUsd } from '@/db/repositories';
7 +import { isGithubAuthConfigured } from '@/lib/env';
8 +import { formatUsd } from '@/lib/utils';
9 +
10 +export const dynamic = 'force-dynamic';
11 +
12 +export default async function HistoryPage() {
13 + const session = await auth();
14 + const userId = session?.user?.id;
15 +
16 + if (!userId) {
17 + return (
18 + <div className="container flex flex-col items-center gap-4 py-24 text-center">
19 + <History className="h-10 w-10 text-muted-foreground" />
20 + <h1 className="text-2xl font-semibold">Your debate history</h1>
21 + <p className="max-w-md text-sm text-muted-foreground">
22 + Sign in to keep a searchable, replayable history of every debate you run.
23 + </p>
24 + {isGithubAuthConfigured ? (
25 + <form
26 + action={async () => {
27 + 'use server';
28 + await signIn('github', { redirectTo: '/history' });
29 + }}
30 + >
31 + <Button type="submit">Sign in with GitHub</Button>
32 + </form>
33 + ) : (
34 + <p className="text-xs text-muted-foreground">
35 + GitHub auth is not configured on this deployment. Debates you run are still available via their direct
36 + links.
37 + </p>
38 + )}
39 + <Button variant="outline" asChild>
40 + <Link href="/debate">Start a debate anyway</Link>
41 + </Button>
42 + </div>
43 + );
44 + }
45 +
46 + const [debates, spend] = await Promise.all([listDebates(userId), monthlySpendUsd(userId)]);
47 +
48 + return (
49 + <div className="container max-w-4xl py-8">
50 + <div className="mb-6 flex flex-wrap items-end justify-between gap-3">
51 + <div>
52 + <h1 className="flex items-center gap-2 text-2xl font-semibold">
53 + <History className="h-6 w-6 text-primary" /> History
54 + </h1>
55 + <p className="text-sm text-muted-foreground">{debates.length} debates</p>
56 + </div>
57 + <div className="rounded-lg border bg-card px-4 py-2 text-right">
58 + <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
59 + <Wallet className="h-3.5 w-3.5" /> This month
60 + </div>
61 + <div className="text-lg font-semibold">{formatUsd(spend)}</div>
62 + </div>
63 + </div>
64 + <HistoryList debates={debates} />
65 + </div>
66 + );
67 +}
added src/app/layout.tsx +34 −0
@@ -0,0 +1,34 @@
1 +import type { Metadata } from 'next';
2 +import { Inter, JetBrains_Mono } from 'next/font/google';
3 +import { SiteHeader } from '@/components/site-header';
4 +import { ThemeProvider } from '@/components/theme-provider';
5 +import { Toaster } from '@/components/ui/sonner';
6 +import { TooltipProvider } from '@/components/ui/tooltip';
7 +import './globals.css';
8 +
9 +const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
10 +const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' });
11 +
12 +export const metadata: Metadata = {
13 + title: 'Roundtable - multi-model LLM debate',
14 + description:
15 + 'A council of LLMs that debate over several rounds - critiquing and revising each other - before a chairman synthesizes a final answer and a dissent report.',
16 +};
17 +
18 +export default function RootLayout({ children }: { children: React.ReactNode }) {
19 + return (
20 + <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>
21 + <body className="min-h-screen font-sans antialiased">
22 + <ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
23 + <TooltipProvider delayDuration={200}>
24 + <div className="relative flex min-h-screen flex-col">
25 + <SiteHeader />
26 + <main className="flex-1">{children}</main>
27 + </div>
28 + <Toaster />
29 + </TooltipProvider>
30 + </ThemeProvider>
31 + </body>
32 + </html>
33 + );
34 +}
added src/app/page.tsx +80 −0
@@ -0,0 +1,80 @@
1 +import { ChevronRight, Gavel, GitCompareArrows, MessagesSquare, PencilLine, Scale, Users } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { LandingHero } from '@/components/landing-hero';
4 +import { Button } from '@/components/ui/button';
5 +import type { DebateResult } from '@/core/types';
6 +import { listDemoDebates, loadDebateResult } from '@/db/repositories';
7 +
8 +export const dynamic = 'force-dynamic';
9 +
10 +const STEPS = [
11 + { icon: MessagesSquare, label: 'Answer', color: 'var(--stage-answer)' },
12 + { icon: Scale, label: 'Critique, blind', color: 'var(--stage-critique)' },
13 + { icon: PencilLine, label: 'Revise or defend', color: 'var(--stage-revision)' },
14 + { icon: Users, label: 'Check agreement', color: 'var(--stage-answer)' },
15 + { icon: Gavel, label: 'Synthesize', color: 'var(--stage-synthesis)' },
16 +];
17 +
18 +export default async function HomePage() {
19 + let demo: DebateResult | null = null;
20 + try {
21 + const demos = await listDemoDebates();
22 + if (demos[0]) demo = await loadDebateResult(demos[0].id);
23 + } catch {
24 + demo = null;
25 + }
26 +
27 + return (
28 + <div>
29 + {demo ? <LandingHero result={demo} /> : <MinimalHero />}
30 +
31 + {/* Thin explainer strip - the five beats of a debate, inline, no boxes. */}
32 + <section className="container border-t py-10">
33 + <div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-3 text-sm">
34 + {STEPS.map((step, i) => (
35 + <div key={step.label} className="flex items-center gap-2">
36 + <span className="flex items-center gap-1.5 text-muted-foreground">
37 + <step.icon className="h-4 w-4" style={{ color: `hsl(${step.color})` }} strokeWidth={1.75} />
38 + {step.label}
39 + </span>
40 + {i < STEPS.length - 1 && <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40" />}
41 + </div>
42 + ))}
43 + </div>
44 + </section>
45 +
46 + <footer className="container flex items-center justify-between border-t py-8 text-sm text-muted-foreground">
47 + <span className="flex items-center gap-2">
48 + <GitCompareArrows className="h-4 w-4 text-primary" /> Roundtable
49 + </span>
50 + <span>Next.js ยท OpenRouter ยท Vercel AI SDK</span>
51 + </footer>
52 + </div>
53 + );
54 +}
55 +
56 +/** Shown only when no demo debate has been seeded yet. */
57 +function MinimalHero() {
58 + return (
59 + <section className="container max-w-2xl py-28 text-center">
60 + <h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
61 + Convene a <span className="text-primary">roundtable</span>.
62 + </h1>
63 + <p className="mx-auto mt-5 max-w-xl text-lg text-muted-foreground">
64 + A council of models answers your question, critiques each other blind, and revises across rounds. A chairman
65 + writes the final answer and an honest account of where they still disagree.
66 + </p>
67 + <div className="mt-8 flex justify-center gap-3">
68 + <Button size="lg" asChild>
69 + <Link href="/debate">Start a debate</Link>
70 + </Button>
71 + <Button size="lg" variant="ghost" asChild>
72 + <Link href="/demo">Watch a recorded one</Link>
73 + </Button>
74 + </div>
75 + <p className="mt-5 text-sm text-muted-foreground">
76 + No demo is seeded on this deployment yet. Run <code className="font-mono">pnpm db:seed</code> to add one.
77 + </p>
78 + </section>
79 + );
80 +}
added src/app/r/[token]/page.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import { GitCompareArrows } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import type { Metadata } from 'next';
5 +import { DebateReplay } from '@/components/debate/debate-replay';
6 +import { loadDebateByShareToken } from '@/db/repositories';
7 +
8 +export const dynamic = 'force-dynamic';
9 +
10 +export const metadata: Metadata = {
11 + title: 'Shared deliberation ยท Roundtable',
12 + robots: { index: false, follow: false },
13 +};
14 +
15 +export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
16 + const { token } = await params;
17 + const result = await loadDebateByShareToken(token);
18 + if (!result) notFound();
19 +
20 + return (
21 + <div className="container max-w-5xl py-8">
22 + <div className="mb-4 flex items-center justify-between">
23 + <Link href="/" className="flex items-center gap-2 text-sm font-medium">
24 + <GitCompareArrows className="h-4 w-4 text-primary" /> Roundtable
25 + </Link>
26 + <span className="text-xs text-muted-foreground">Shared, unlisted deliberation report</span>
27 + </div>
28 + <DebateReplay result={result} showActions={false} />
29 + </div>
30 + );
31 +}
added src/app/signin/page.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import { Github, GitCompareArrows } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { redirect } from 'next/navigation';
4 +import { auth, signIn } from '@/auth';
5 +import { Button } from '@/components/ui/button';
6 +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
7 +import { isGithubAuthConfigured } from '@/lib/env';
8 +
9 +export const dynamic = 'force-dynamic';
10 +
11 +export default async function SignInPage() {
12 + const session = await auth();
13 + if (session?.user) redirect('/history');
14 +
15 + return (
16 + <div className="container flex min-h-[70vh] items-center justify-center py-12">
17 + <Card className="w-full max-w-sm">
18 + <CardHeader className="items-center text-center">
19 + <GitCompareArrows className="mb-2 h-8 w-8 text-primary" />
20 + <CardTitle>Sign in to Roundtable</CardTitle>
21 + </CardHeader>
22 + <CardContent className="space-y-4">
23 + {isGithubAuthConfigured ? (
24 + <form
25 + action={async () => {
26 + 'use server';
27 + await signIn('github', { redirectTo: '/history' });
28 + }}
29 + >
30 + <Button type="submit" className="w-full">
31 + <Github className="h-4 w-4" /> Continue with GitHub
32 + </Button>
33 + </form>
34 + ) : (
35 + <p className="text-center text-sm text-muted-foreground">
36 + GitHub authentication isn't configured on this deployment. You can still run debates without an account.
37 + </p>
38 + )}
39 + <p className="text-center text-xs text-muted-foreground">
40 + Prefer no account?{' '}
41 + <Link href="/debate" className="text-primary hover:underline">
42 + Run a debate with a session-only key
43 + </Link>
44 + .
45 + </p>
46 + </CardContent>
47 + </Card>
48 + </div>
49 + );
50 +}
added src/components/api-key-dialog.tsx +199 −0
@@ -0,0 +1,199 @@
1 +'use client';
2 +
3 +import { KeyRound, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
4 +import { useCallback, useEffect, useState } from 'react';
5 +import { Badge } from '@/components/ui/badge';
6 +import { Button } from '@/components/ui/button';
7 +import {
8 + Dialog,
9 + DialogContent,
10 + DialogDescription,
11 + DialogHeader,
12 + DialogTitle,
13 +} from '@/components/ui/dialog';
14 +import { Input } from '@/components/ui/input';
15 +import { Label } from '@/components/ui/label';
16 +import { toast } from '@/components/ui/sonner';
17 +import { formatUsd } from '@/lib/utils';
18 +
19 +interface KeyStatus {
20 + mockMode: boolean;
21 + hasKey: boolean;
22 + source: 'mock' | 'saved' | 'session' | null;
23 + saved: { keyMask: string; label: string | null } | null;
24 + authenticated: boolean;
25 +}
26 +
27 +export function ApiKeyDialog({
28 + open,
29 + onOpenChange,
30 + onChanged,
31 +}: {
32 + open: boolean;
33 + onOpenChange: (v: boolean) => void;
34 + onChanged?: () => void;
35 +}) {
36 + const [status, setStatus] = useState<KeyStatus | null>(null);
37 + const [apiKey, setApiKey] = useState('');
38 + const [mode, setMode] = useState<'session' | 'save'>('session');
39 + const [busy, setBusy] = useState(false);
40 +
41 + const refresh = useCallback(async () => {
42 + const res = await fetch('/api/keys');
43 + if (res.ok) {
44 + const s = (await res.json()) as KeyStatus;
45 + setStatus(s);
46 + setMode(s.authenticated ? 'save' : 'session');
47 + }
48 + }, []);
49 +
50 + useEffect(() => {
51 + if (open) void refresh();
52 + }, [open, refresh]);
53 +
54 + const submit = async () => {
55 + if (!apiKey.trim()) return;
56 + setBusy(true);
57 + try {
58 + const res = await fetch('/api/keys', {
59 + method: 'POST',
60 + headers: { 'Content-Type': 'application/json' },
61 + body: JSON.stringify({ apiKey: apiKey.trim(), mode }),
62 + });
63 + const data = (await res.json()) as {
64 + error?: string;
65 + credits?: { remaining: number | null; limit: number | null };
66 + };
67 + if (!res.ok) {
68 + toast.error(data.error ?? 'Key rejected');
69 + return;
70 + }
71 + const remaining = data.credits?.remaining;
72 + toast.success(
73 + remaining != null ? `Key verified - ${formatUsd(remaining)} credits remaining` : 'Key verified and stored',
74 + );
75 + setApiKey('');
76 + await refresh();
77 + onChanged?.();
78 + } finally {
79 + setBusy(false);
80 + }
81 + };
82 +
83 + const remove = async () => {
84 + setBusy(true);
85 + try {
86 + await fetch('/api/keys', { method: 'DELETE' });
87 + toast.success('Key removed');
88 + await refresh();
89 + onChanged?.();
90 + } finally {
91 + setBusy(false);
92 + }
93 + };
94 +
95 + return (
96 + <Dialog open={open} onOpenChange={onOpenChange}>
97 + <DialogContent>
98 + <DialogHeader>
99 + <DialogTitle className="flex items-center gap-2">
100 + <KeyRound className="h-5 w-5 text-primary" /> OpenRouter API key
101 + </DialogTitle>
102 + <DialogDescription>
103 + Roundtable is bring-your-own-key - inference is billed to your OpenRouter account, never ours.
104 + </DialogDescription>
105 + </DialogHeader>
106 +
107 + {status?.mockMode ? (
108 + <div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
109 + <Badge variant="warning" className="mb-2">
110 + Mock mode
111 + </Badge>
112 + <p>
113 + This deployment runs with <code className="font-mono">MOCK_LLM=1</code>. Debates use a deterministic
114 + offline model - no key required.
115 + </p>
116 + </div>
117 + ) : (
118 + <div className="space-y-4">
119 + {status?.saved && (
120 + <div className="flex items-center justify-between rounded-lg border bg-secondary/40 px-3 py-2 text-sm">
121 + <span className="flex items-center gap-2">
122 + <ShieldCheck className="h-4 w-4 text-emerald-500" />
123 + Saved key <code className="font-mono text-xs">{status.saved.keyMask}</code>
124 + </span>
125 + <Button size="sm" variant="ghost" onClick={remove} disabled={busy}>
126 + <Trash2 className="h-3.5 w-3.5" /> Remove
127 + </Button>
128 + </div>
129 + )}
130 +
131 + <div className="space-y-2">
132 + <Label htmlFor="rt-key">Paste your key</Label>
133 + <Input
134 + id="rt-key"
135 + type="password"
136 + placeholder="sk-or-v1-..."
137 + value={apiKey}
138 + onChange={(e) => setApiKey(e.target.value)}
139 + autoComplete="off"
140 + />
141 + <p className="text-xs text-muted-foreground">
142 + Validated against OpenRouter before it is stored.
143 + </p>
144 + </div>
145 +
146 + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
147 + <StorageOption
148 + active={mode === 'session'}
149 + title="Session only"
150 + desc="Kept in an encrypted HttpOnly cookie for 12h. Nothing saved server-side."
151 + onClick={() => setMode('session')}
152 + />
153 + <StorageOption
154 + active={mode === 'save'}
155 + disabled={!status?.authenticated}
156 + title="Save to account"
157 + desc={status?.authenticated ? 'Encrypted at rest (AES-256-GCM).' : 'Sign in to save a key.'}
158 + onClick={() => status?.authenticated && setMode('save')}
159 + />
160 + </div>
161 +
162 + <Button onClick={submit} disabled={busy || !apiKey.trim()} className="w-full">
163 + {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
164 + Validate & use key
165 + </Button>
166 + </div>
167 + )}
168 + </DialogContent>
169 + </Dialog>
170 + );
171 +}
172 +
173 +function StorageOption({
174 + active,
175 + disabled,
176 + title,
177 + desc,
178 + onClick,
179 +}: {
180 + active: boolean;
181 + disabled?: boolean;
182 + title: string;
183 + desc: string;
184 + onClick: () => void;
185 +}) {
186 + return (
187 + <button
188 + type="button"
189 + onClick={onClick}
190 + disabled={disabled}
191 + className={`rounded-lg border p-3 text-left text-sm transition-colors disabled:opacity-50 ${
192 + active ? 'border-primary bg-accent/50' : 'hover:border-primary/40'
193 + }`}
194 + >
195 + <div className="font-medium">{title}</div>
196 + <div className="mt-0.5 text-xs text-muted-foreground">{desc}</div>
197 + </button>
198 + );
199 +}
added src/components/debate/cost-breakdown.tsx +79 −0
@@ -0,0 +1,79 @@
1 +'use client';
2 +
3 +import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
4 +import type { DebateView } from '@/lib/debate-view';
5 +import { displayNameForModel } from '@/core/types';
6 +import { participantColor } from '@/lib/model-visuals';
7 +import { formatUsd } from '@/lib/utils';
8 +
9 +/** Post-debate cost breakdown per model (in micro-dollars for readability). */
10 +export function CostBreakdown({ view }: { view: DebateView }) {
11 + const modelIndex = new Map(view.participants.map((p, i) => [p.model, i]));
12 + const data = Object.entries(view.totals.costByModel)
13 + .map(([model, cost]) => ({
14 + model,
15 + name: displayNameForModel(model),
16 + cost,
17 + index: modelIndex.get(model) ?? 5,
18 + }))
19 + .sort((a, b) => b.cost - a.cost);
20 +
21 + if (data.length === 0) {
22 + return <p className="text-sm text-muted-foreground">No cost recorded yet.</p>;
23 + }
24 +
25 + return (
26 + <div className="space-y-4">
27 + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
28 + <Stat label="Total cost" value={formatUsd(view.totals.costUsd)} />
29 + <Stat label="Prompt tokens" value={view.totals.promptTokens.toLocaleString()} />
30 + <Stat label="Completion tokens" value={view.totals.completionTokens.toLocaleString()} />
31 + <Stat label="Models" value={String(data.length)} />
32 + </div>
33 +
34 + <div className="h-56 w-full">
35 + <ResponsiveContainer width="100%" height="100%">
36 + <BarChart data={data} layout="vertical" margin={{ left: 8, right: 16 }}>
37 + <XAxis
38 + type="number"
39 + tickFormatter={(v: number) => formatUsd(v)}
40 + tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
41 + stroke="hsl(var(--border))"
42 + />
43 + <YAxis
44 + type="category"
45 + dataKey="name"
46 + width={110}
47 + tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
48 + stroke="hsl(var(--border))"
49 + />
50 + <Tooltip
51 + cursor={{ fill: 'hsl(var(--muted) / 0.4)' }}
52 + contentStyle={{
53 + background: 'hsl(var(--popover))',
54 + border: '1px solid hsl(var(--border))',
55 + borderRadius: 8,
56 + fontSize: 12,
57 + }}
58 + formatter={(v: number) => [formatUsd(v), 'Cost']}
59 + />
60 + <Bar dataKey="cost" radius={[0, 4, 4, 0]}>
61 + {data.map((d) => (
62 + <Cell key={d.model} fill={participantColor(d.index)} />
63 + ))}
64 + </Bar>
65 + </BarChart>
66 + </ResponsiveContainer>
67 + </div>
68 + </div>
69 + );
70 +}
71 +
72 +function Stat({ label, value }: { label: string; value: string }) {
73 + return (
74 + <div className="rounded-lg border bg-card p-3">
75 + <div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
76 + <div className="mt-0.5 text-lg font-semibold">{value}</div>
77 + </div>
78 + );
79 +}
added src/components/debate/cost-meter.tsx +44 −0
@@ -0,0 +1,44 @@
1 +'use client';
2 +
3 +import { Coins } from 'lucide-react';
4 +import type { DebateView } from '@/lib/debate-view';
5 +import { participantColor } from '@/lib/model-visuals';
6 +import { formatTokens, formatUsd } from '@/lib/utils';
7 +
8 +/** Compact running cost meter for the debate header. */
9 +export function CostMeter({ view }: { view: DebateView }) {
10 + const total = view.totals.costUsd;
11 + const modelIndex = new Map(view.participants.map((p, i) => [p.model, i]));
12 + const segments = Object.entries(view.totals.costByModel)
13 + .filter(([, c]) => c > 0)
14 + .sort((a, b) => b[1] - a[1]);
15 + const max = total || 1;
16 +
17 + return (
18 + <div className="rounded-lg border bg-card px-3 py-2">
19 + <div className="flex items-center justify-between gap-4">
20 + <div className="flex items-center gap-1.5 text-sm font-semibold">
21 + <Coins className="h-4 w-4 text-primary" />
22 + {formatUsd(total)}
23 + </div>
24 + <div className="text-[11px] text-muted-foreground">
25 + {formatTokens(view.totals.promptTokens + view.totals.completionTokens)} tokens
26 + </div>
27 + </div>
28 + {segments.length > 0 && (
29 + <div className="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
30 + {segments.map(([model, cost]) => (
31 + <div
32 + key={model}
33 + title={`${model}: ${formatUsd(cost)}`}
34 + style={{
35 + width: `${(cost / max) * 100}%`,
36 + backgroundColor: participantColor(modelIndex.get(model) ?? 5),
37 + }}
38 + />
39 + ))}
40 + </div>
41 + )}
42 + </div>
43 + );
44 +}
added src/components/debate/critique-matrix.tsx +146 −0
@@ -0,0 +1,146 @@
1 +'use client';
2 +
3 +import { Eye, EyeOff } from 'lucide-react';
4 +import { useMemo, useState } from 'react';
5 +import { Button } from '@/components/ui/button';
6 +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
7 +import { buildScoreMatrix } from '@/core/scoring';
8 +import type { CritiqueRecord, Participant, PeerReview } from '@/core/types';
9 +import { participantColor, participantTag, scoreColor } from '@/lib/model-visuals';
10 +
11 +/**
12 + * Nร—N critique grid: rows are reviewers, columns are the answers scored.
13 + * Anonymized by default (identities hidden as the models saw them); a toggle
14 + * de-anonymizes to real model names. Hovering a cell reveals the full critique.
15 + */
16 +export function CritiqueMatrix({
17 + participants,
18 + critiques,
19 +}: {
20 + participants: Participant[];
21 + critiques: CritiqueRecord[];
22 +}) {
23 + const [deanon, setDeanon] = useState(false);
24 + const matrix = useMemo(() => buildScoreMatrix(participants, critiques), [participants, critiques]);
25 + const byId = useMemo(() => new Map(participants.map((p, i) => [p.id, { p, i }])), [participants]);
26 +
27 + const reviewLookup = useMemo(() => {
28 + const map = new Map<string, PeerReview>();
29 + for (const c of critiques) {
30 + for (const r of c.reviews) map.set(`${c.reviewerParticipantId}:${r.targetParticipantId}`, r);
31 + }
32 + return map;
33 + }, [critiques]);
34 +
35 + const nameFor = (id: string) => {
36 + const entry = byId.get(id);
37 + if (!entry) return id;
38 + return deanon ? entry.p.displayName : `Model ${participantTag(entry.i)}`;
39 + };
40 +
41 + if (critiques.length === 0) {
42 + return <p className="text-sm text-muted-foreground">No critiques recorded for this round.</p>;
43 + }
44 +
45 + return (
46 + <div className="space-y-3">
47 + <div className="flex items-center justify-between">
48 + <p className="text-xs text-muted-foreground">
49 + Rows critique columns ยท scores 1-10 ยท hover a cell for the full review
50 + </p>
51 + <Button variant="outline" size="sm" className="gap-1.5" onClick={() => setDeanon((v) => !v)}>
52 + {deanon ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
53 + {deanon ? 'Anonymize' : 'De-anonymize'}
54 + </Button>
55 + </div>
56 +
57 + <div className="scrollbar-thin overflow-x-auto">
58 + <table className="w-full min-w-max border-separate border-spacing-1 text-sm">
59 + <thead>
60 + <tr>
61 + <th className="p-1 text-left text-xs font-medium text-muted-foreground">reviewer โ†“ / target โ†’</th>
62 + {matrix.order.map((id) => (
63 + <th key={id} className="p-1">
64 + <ColHeader index={byId.get(id)?.i ?? 0} name={nameFor(id)} />
65 + </th>
66 + ))}
67 + <th className="p-1 text-xs font-medium text-muted-foreground">avg&nbsp;recv</th>
68 + </tr>
69 + </thead>
70 + <tbody>
71 + {matrix.order.map((reviewerId) => (
72 + <tr key={reviewerId}>
73 + <td className="whitespace-nowrap p-1">
74 + <ColHeader index={byId.get(reviewerId)?.i ?? 0} name={nameFor(reviewerId)} />
75 + </td>
76 + {matrix.order.map((targetId) => {
77 + const score = matrix.cells[reviewerId]?.[targetId] ?? null;
78 + const review = reviewLookup.get(`${reviewerId}:${targetId}`);
79 + return (
80 + <td key={targetId} className="p-0.5 text-center">
81 + {score === null ? (
82 + <div className="flex h-11 w-14 items-center justify-center rounded-md bg-muted/40 text-muted-foreground">
83 + {reviewerId === targetId ? '-' : 'ยท'}
84 + </div>
85 + ) : (
86 + <Tooltip>
87 + <TooltipTrigger asChild>
88 + <div
89 + className="flex h-11 w-14 cursor-help items-center justify-center rounded-md font-semibold text-white transition-transform hover:scale-105"
90 + style={{ backgroundColor: scoreColor(score) }}
91 + >
92 + {score}
93 + </div>
94 + </TooltipTrigger>
95 + {review && (
96 + <TooltipContent side="top" className="max-w-sm space-y-1.5 text-left">
97 + <div className="text-xs font-medium">
98 + {nameFor(reviewerId)} โ†’ {nameFor(targetId)} ยท seen as "{review.label}"
99 + </div>
100 + {review.strengths.length > 0 && (
101 + <div className="text-[11px]">
102 + <span className="text-emerald-400">Strengths:</span> {review.strengths.join('; ')}
103 + </div>
104 + )}
105 + {review.weaknesses.length > 0 && (
106 + <div className="text-[11px]">
107 + <span className="text-amber-400">Weaknesses:</span> {review.weaknesses.join('; ')}
108 + </div>
109 + )}
110 + <div className="text-[11px] text-muted-foreground">{review.justification}</div>
111 + </TooltipContent>
112 + )}
113 + </Tooltip>
114 + )}
115 + </td>
116 + );
117 + })}
118 + <td className="p-1 text-center text-xs font-medium">
119 + {fmtAvg(matrix.averagesReceived[reviewerId])}
120 + </td>
121 + </tr>
122 + ))}
123 + </tbody>
124 + </table>
125 + </div>
126 + </div>
127 + );
128 +}
129 +
130 +function ColHeader({ index, name }: { index: number; name: string }) {
131 + return (
132 + <div className="flex items-center gap-1.5">
133 + <span
134 + className="flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold text-white"
135 + style={{ backgroundColor: participantColor(index) }}
136 + >
137 + {participantTag(index)}
138 + </span>
139 + <span className="max-w-[120px] truncate text-xs">{name}</span>
140 + </div>
141 + );
142 +}
143 +
144 +function fmtAvg(v: number | null | undefined): string {
145 + return typeof v === 'number' ? v.toFixed(1) : '-';
146 +}
added src/components/debate/debate-actions.tsx +36 −0
@@ -0,0 +1,36 @@
1 +'use client';
2 +
3 +import { Check, Download, Share2 } from 'lucide-react';
4 +import { useState } from 'react';
5 +import { Button } from '@/components/ui/button';
6 +import { toast } from '@/components/ui/sonner';
7 +
8 +export function DebateActions({ debateId }: { debateId: string }) {
9 + const [shared, setShared] = useState(false);
10 +
11 + const share = async () => {
12 + try {
13 + const res = await fetch(`/api/debates/${debateId}/share`, { method: 'POST' });
14 + if (!res.ok) throw new Error('share failed');
15 + const { url } = (await res.json()) as { url: string };
16 + await navigator.clipboard.writeText(url).catch(() => {});
17 + setShared(true);
18 + toast.success('Share link copied to clipboard', { description: url });
19 + } catch {
20 + toast.error('Could not create share link');
21 + }
22 + };
23 +
24 + return (
25 + <div className="flex items-center gap-2">
26 + <Button variant="outline" size="sm" asChild>
27 + <a href={`/api/debates/${debateId}/export?format=md`} download>
28 + <Download className="h-3.5 w-3.5" /> Export
29 + </a>
30 + </Button>
31 + <Button variant="outline" size="sm" onClick={share}>
32 + {shared ? <Check className="h-3.5 w-3.5" /> : <Share2 className="h-3.5 w-3.5" />} Share
33 + </Button>
34 + </div>
35 + );
36 +}
added src/components/debate/debate-console.tsx +217 −0
@@ -0,0 +1,217 @@
1 +'use client';
2 +
3 +import { AlertTriangle, CircleCheckBig, Scale } from 'lucide-react';
4 +import { CostBreakdown } from '@/components/debate/cost-breakdown';
5 +import { CostMeter } from '@/components/debate/cost-meter';
6 +import { CritiqueMatrix } from '@/components/debate/critique-matrix';
7 +import { DebateActions } from '@/components/debate/debate-actions';
8 +import { Disagreements } from '@/components/debate/disagreements';
9 +import { FinalAnswer } from '@/components/debate/final-answer';
10 +import { ModelPanel } from '@/components/debate/model-panel';
11 +import { RevisionDiff } from '@/components/debate/revision-diff';
12 +import { StageTimeline } from '@/components/debate/stage-timeline';
13 +import { Badge } from '@/components/ui/badge';
14 +import { Progress } from '@/components/ui/progress';
15 +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
16 +import type { AnswerRecord } from '@/core/types';
17 +import { currentAnswers, type DebateView } from '@/lib/debate-view';
18 +
19 +export function DebateConsole({ view, showActions = true }: { view: DebateView; showActions?: boolean }) {
20 + const answers = currentAnswers(view);
21 + const dropped = new Set(view.droppedParticipants);
22 + const lastConvergence = [...view.rounds].reverse().find((r) => r.convergence)?.convergence ?? null;
23 + const finished = view.status === 'completed' || view.status === 'failed' || view.status === 'aborted';
24 +
25 + return (
26 + <div className="space-y-6">
27 + {/* Header */}
28 + <div className="space-y-4">
29 + <div className="flex flex-wrap items-start justify-between gap-3">
30 + <div className="min-w-0 space-y-1">
31 + <div className="flex items-center gap-2">
32 + <StatusBadge status={view.status} />
33 + <span className="text-xs text-muted-foreground">
34 + {view.participants.length} models ยท up to {view.config?.maxRounds ?? '-'} rounds ยท converge โ‰ฅ
35 + {view.config?.convergenceThreshold ?? '-'}
36 + </span>
37 + </div>
38 + <h1 className="text-balance text-xl font-semibold leading-snug">{view.question}</h1>
39 + </div>
40 + <div className="flex items-center gap-2">
41 + <CostMeter view={view} />
42 + {showActions && finished && view.debateId && <DebateActions debateId={view.debateId} />}
43 + </div>
44 + </div>
45 +
46 + {view.error && (
47 + <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
48 + <AlertTriangle className="h-4 w-4" /> {view.error}
49 + </div>
50 + )}
51 +
52 + <StageTimeline view={view} />
53 + </div>
54 +
55 + {/* Final answer + disagreements (surfaced up top once available) */}
56 + {view.synthesis && (
57 + <section id="section-final" className="grid gap-4 lg:grid-cols-[1fr_320px]">
58 + <FinalAnswer
59 + synthesis={view.synthesis}
60 + participants={view.participants}
61 + chairmanProviderConflict={view.chairmanProviderConflict}
62 + />
63 + {lastConvergence && lastConvergence.disagreements.length > 0 ? (
64 + <Disagreements disagreements={lastConvergence.disagreements} participants={view.participants} />
65 + ) : (
66 + <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm">
67 + <div className="mb-1 flex items-center gap-2 font-medium text-emerald-600 dark:text-emerald-400">
68 + <CircleCheckBig className="h-4 w-4" /> Converged
69 + </div>
70 + <p className="text-muted-foreground">
71 + The council reached substantial agreement
72 + {lastConvergence ? ` (score ${lastConvergence.score}/100)` : ''}.
73 + </p>
74 + </div>
75 + )}
76 + </section>
77 + )}
78 +
79 + {/* Council panels */}
80 + <section id="section-council" className="space-y-3">
81 + <h2 className="text-sm font-semibold text-muted-foreground">Council answers</h2>
82 + <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
83 + {view.participants.map((p, i) => (
84 + <ModelPanel
85 + key={p.id}
86 + index={i}
87 + displayName={p.displayName}
88 + model={p.model}
89 + answer={answers[p.id]}
90 + streamingText={view.streaming[p.id]}
91 + workingStage={view.working[p.id]}
92 + dropped={dropped.has(p.id)}
93 + />
94 + ))}
95 + </div>
96 + </section>
97 +
98 + {/* Rounds */}
99 + {view.rounds.map((round) => (
100 + <RoundSection
101 + key={round.round}
102 + round={round.round}
103 + view={view}
104 + answersBefore={answersBeforeRound(view, round.round)}
105 + />
106 + ))}
107 +
108 + {/* Cost breakdown */}
109 + {finished && (
110 + <section id="section-cost" className="space-y-3">
111 + <h2 className="text-sm font-semibold text-muted-foreground">Cost breakdown</h2>
112 + <div className="rounded-xl border bg-card p-4">
113 + <CostBreakdown view={view} />
114 + </div>
115 + </section>
116 + )}
117 + </div>
118 + );
119 +}
120 +
121 +function RoundSection({
122 + round,
123 + view,
124 + answersBefore,
125 +}: {
126 + round: number;
127 + view: DebateView;
128 + answersBefore: Record<string, AnswerRecord>;
129 +}) {
130 + const record = view.rounds.find((r) => r.round === round)!;
131 + return (
132 + <section id={`section-round-${round}`} className="rounded-xl border bg-card">
133 + <div className="flex flex-wrap items-center gap-2 border-b p-4">
134 + <span
135 + className="flex h-7 w-7 items-center justify-center rounded-md text-white"
136 + style={{ backgroundColor: 'hsl(var(--stage-critique))' }}
137 + >
138 + <Scale className="h-4 w-4" />
139 + </span>
140 + <h2 className="text-sm font-semibold">Round {round}</h2>
141 + {record.convergence && (
142 + <div className="ml-auto flex items-center gap-2">
143 + <span className="text-xs text-muted-foreground">convergence</span>
144 + <div className="w-28">
145 + <Progress value={record.convergence.score} />
146 + </div>
147 + <Badge variant={record.convergence.converged ? 'success' : 'secondary'}>
148 + {record.convergence.score}/100
149 + </Badge>
150 + </div>
151 + )}
152 + </div>
153 +
154 + <div className="p-4">
155 + <Tabs defaultValue="matrix">
156 + <TabsList>
157 + <TabsTrigger value="matrix">Critique matrix</TabsTrigger>
158 + <TabsTrigger value="revisions">Revisions & diffs</TabsTrigger>
159 + </TabsList>
160 + <TabsContent value="matrix" className="pt-2">
161 + <CritiqueMatrix participants={view.participants} critiques={record.critiques} />
162 + </TabsContent>
163 + <TabsContent value="revisions" className="space-y-4 pt-2">
164 + {record.revisions.length === 0 && (
165 + <p className="text-sm text-muted-foreground">No revisions recorded yet.</p>
166 + )}
167 + {record.revisions.map((rev) => {
168 + const before = answersBefore[rev.participantId];
169 + const idx = view.participants.findIndex((p) => p.id === rev.participantId);
170 + return (
171 + <RevisionDiff
172 + key={rev.participantId}
173 + index={idx < 0 ? 0 : idx}
174 + displayName={view.participants[idx]?.displayName ?? rev.model}
175 + previous={before?.content ?? ''}
176 + next={rev.content}
177 + changelog={rev.changelog}
178 + />
179 + );
180 + })}
181 + </TabsContent>
182 + </Tabs>
183 + </div>
184 + </section>
185 + );
186 +}
187 +
188 +/** Answer each participant held entering a given round (for the diff baseline). */
189 +function answersBeforeRound(view: DebateView, round: number): Record<string, AnswerRecord> {
190 + const out: Record<string, AnswerRecord> = { ...view.initialAnswers };
191 + for (const r of view.rounds) {
192 + if (r.round >= round) break;
193 + for (const rev of r.revisions) {
194 + out[rev.participantId] = {
195 + participantId: rev.participantId,
196 + model: rev.model,
197 + round: rev.round,
198 + content: rev.content,
199 + usage: rev.usage,
200 + latencyMs: rev.latencyMs,
201 + };
202 + }
203 + }
204 + return out;
205 +}
206 +
207 +function StatusBadge({ status }: { status: DebateView['status'] }) {
208 + const map: Record<DebateView['status'], { variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning'; label: string }> = {
209 + pending: { variant: 'secondary', label: 'Pending' },
210 + running: { variant: 'warning', label: 'Deliberating' },
211 + completed: { variant: 'success', label: 'Completed' },
212 + failed: { variant: 'destructive', label: 'Failed' },
213 + aborted: { variant: 'secondary', label: 'Aborted' },
214 + };
215 + const s = map[status];
216 + return <Badge variant={s.variant}>{s.label}</Badge>;
217 +}
added src/components/debate/debate-player.tsx +55 −0
@@ -0,0 +1,55 @@
1 +'use client';
2 +
3 +import { Pause, Play, RotateCcw, SkipForward } from 'lucide-react';
4 +import { DebateConsole } from '@/components/debate/debate-console';
5 +import { Button } from '@/components/ui/button';
6 +import { Progress } from '@/components/ui/progress';
7 +import type { DebateResult } from '@/core/types';
8 +import { useDebatePlayback } from '@/hooks/use-debate-playback';
9 +
10 +const SPEEDS = [1, 2, 4] as const;
11 +
12 +/** Animated replay of a recorded debate - the demo experience. */
13 +export function DebatePlayer({ result }: { result: DebateResult }) {
14 + const { view, state, progress, speed, setSpeed, play, pause, restart, skipToEnd } = useDebatePlayback(result);
15 +
16 + return (
17 + <div className="space-y-4">
18 + <div className="sticky top-14 z-30 flex flex-wrap items-center gap-2 rounded-xl border bg-card/90 p-2 backdrop-blur">
19 + {state === 'playing' ? (
20 + <Button size="sm" onClick={pause}>
21 + <Pause className="h-3.5 w-3.5" /> Pause
22 + </Button>
23 + ) : (
24 + <Button size="sm" onClick={play}>
25 + <Play className="h-3.5 w-3.5" /> {state === 'finished' ? 'Replay' : 'Play'}
26 + </Button>
27 + )}
28 + <Button size="sm" variant="outline" onClick={restart}>
29 + <RotateCcw className="h-3.5 w-3.5" /> Restart
30 + </Button>
31 + <Button size="sm" variant="outline" onClick={skipToEnd}>
32 + <SkipForward className="h-3.5 w-3.5" /> Skip to end
33 + </Button>
34 + <div className="ml-1 flex items-center gap-1">
35 + {SPEEDS.map((s) => (
36 + <Button
37 + key={s}
38 + size="sm"
39 + variant={speed === s ? 'default' : 'ghost'}
40 + className="h-8 w-9 px-0"
41 + onClick={() => setSpeed(s)}
42 + >
43 + {s}ร—
44 + </Button>
45 + ))}
46 + </div>
47 + <div className="ml-auto w-40">
48 + <Progress value={progress} />
49 + </div>
50 + </div>
51 +
52 + <DebateConsole view={view} showActions={false} />
53 + </div>
54 + );
55 +}
added src/components/debate/debate-replay.tsx +10 −0
@@ -0,0 +1,10 @@
1 +'use client';
2 +
3 +import { DebateConsole } from '@/components/debate/debate-console';
4 +import type { DebateResult } from '@/core/types';
5 +import { fromResult } from '@/lib/debate-view';
6 +
7 +/** Static replay of a persisted debate (history + share page). */
8 +export function DebateReplay({ result, showActions = true }: { result: DebateResult; showActions?: boolean }) {
9 + return <DebateConsole view={fromResult(result)} showActions={showActions} />;
10 +}
added src/components/debate/disagreements.tsx +53 −0
@@ -0,0 +1,53 @@
1 +'use client';
2 +
3 +import { GitFork } from 'lucide-react';
4 +import type { Disagreement, Participant } from '@/core/types';
5 +import { participantColor, participantTag } from '@/lib/model-visuals';
6 +
7 +export function Disagreements({
8 + disagreements,
9 + participants,
10 +}: {
11 + disagreements: Disagreement[];
12 + participants: Participant[];
13 +}) {
14 + const byId = new Map(participants.map((p, i) => [p.id, { p, i }]));
15 + if (disagreements.length === 0) return null;
16 +
17 + return (
18 + <div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
19 + <div className="mb-3 flex items-center gap-2 text-sm font-semibold text-amber-600 dark:text-amber-400">
20 + <GitFork className="h-4 w-4" /> Remaining points of disagreement
21 + </div>
22 + <div className="space-y-3">
23 + {disagreements.map((d, i) => (
24 + <div key={i} className="rounded-lg border bg-card p-3">
25 + <div className="text-sm font-medium">{d.topic}</div>
26 + {d.summary && <p className="mt-0.5 text-xs text-muted-foreground">{d.summary}</p>}
27 + {d.positions.length > 0 && (
28 + <ul className="mt-2 space-y-1.5">
29 + {d.positions.map((pos, j) => {
30 + const entry = pos.participantId ? byId.get(pos.participantId) : undefined;
31 + return (
32 + <li key={j} className="flex items-start gap-2 text-xs">
33 + <span
34 + className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-[9px] font-bold text-white"
35 + style={{ backgroundColor: entry ? participantColor(entry.i) : 'hsl(var(--muted-foreground))' }}
36 + >
37 + {entry ? participantTag(entry.i) : pos.label}
38 + </span>
39 + <span>
40 + <span className="font-medium">{entry ? entry.p.displayName : `Response ${pos.label}`}:</span>{' '}
41 + <span className="text-muted-foreground">{pos.stance}</span>
42 + </span>
43 + </li>
44 + );
45 + })}
46 + </ul>
47 + )}
48 + </div>
49 + ))}
50 + </div>
51 + </div>
52 + );
53 +}
added src/components/debate/final-answer.tsx +92 −0
@@ -0,0 +1,92 @@
1 +'use client';
2 +
3 +import { AlertTriangle, Gavel } from 'lucide-react';
4 +import { RichText } from '@/components/debate/rich-text';
5 +import { Badge } from '@/components/ui/badge';
6 +import {
7 + Tooltip,
8 + TooltipContent,
9 + TooltipTrigger,
10 +} from '@/components/ui/tooltip';
11 +import { displayNameForModel, type Participant, type SynthesisRecord } from '@/core/types';
12 +import { participantColor, participantTag } from '@/lib/model-visuals';
13 +
14 +export function FinalAnswer({
15 + synthesis,
16 + participants,
17 + chairmanProviderConflict,
18 +}: {
19 + synthesis: SynthesisRecord;
20 + participants: Participant[];
21 + chairmanProviderConflict: boolean;
22 +}) {
23 + const byId = new Map(participants.map((p, i) => [p.id, { p, i }]));
24 +
25 + return (
26 + <div className="overflow-hidden rounded-xl border-2 border-emerald-500/30 bg-gradient-to-b from-emerald-500/[0.06] to-transparent">
27 + <div className="flex flex-wrap items-center gap-2 border-b border-emerald-500/20 p-4">
28 + <span
29 + className="flex h-8 w-8 items-center justify-center rounded-lg text-white"
30 + style={{ backgroundColor: 'hsl(var(--stage-synthesis))' }}
31 + >
32 + <Gavel className="h-4 w-4" />
33 + </span>
34 + <div>
35 + <div className="text-sm font-semibold">Chairman synthesis</div>
36 + <div className="font-mono text-[11px] text-muted-foreground">{synthesis.model}</div>
37 + </div>
38 + {chairmanProviderConflict && (
39 + <Tooltip>
40 + <TooltipTrigger asChild>
41 + <Badge variant="warning" className="ml-auto cursor-help gap-1">
42 + <AlertTriangle className="h-3 w-3" /> provider overlap
43 + </Badge>
44 + </TooltipTrigger>
45 + <TooltipContent className="max-w-xs">
46 + The chairman shares a provider family with a council member, a self-preference risk. Answers are shown
47 + to the chairman anonymized to mitigate it, but consider a chairman from an independent provider.
48 + </TooltipContent>
49 + </Tooltip>
50 + )}
51 + </div>
52 +
53 + <div className="p-5">
54 + <RichText text={synthesis.finalAnswer} className="text-[15px]" />
55 +
56 + {synthesis.dissent.length > 0 && (
57 + <div className="mt-5 rounded-lg border bg-card p-4">
58 + <div className="mb-2 text-sm font-semibold">Dissent report</div>
59 + <div className="space-y-2.5">
60 + {synthesis.dissent.map((d, i) => (
61 + <div key={i}>
62 + <div className="text-sm font-medium">{d.topic}</div>
63 + <ul className="mt-1 space-y-1">
64 + {d.positions.map((pos, j) => {
65 + const entry = byId.get(pos.participantId);
66 + const idx = entry?.i ?? 0;
67 + const name = entry?.p.displayName ?? displayNameForModel(pos.model);
68 + return (
69 + <li key={j} className="flex items-start gap-2 text-xs">
70 + <span
71 + className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-[9px] font-bold text-white"
72 + style={{ backgroundColor: participantColor(idx) }}
73 + >
74 + {participantTag(idx)}
75 + </span>
76 + <span>
77 + <span className="font-medium">{name}:</span>{' '}
78 + <span className="text-muted-foreground">{pos.position}</span>
79 + </span>
80 + </li>
81 + );
82 + })}
83 + </ul>
84 + </div>
85 + ))}
86 + </div>
87 + </div>
88 + )}
89 + </div>
90 + </div>
91 + );
92 +}
added src/components/debate/model-combobox.tsx +78 −0
@@ -0,0 +1,78 @@
1 +'use client';
2 +
3 +import { Check, ChevronsUpDown, Search } from 'lucide-react';
4 +import { useMemo, useState } from 'react';
5 +import { Button } from '@/components/ui/button';
6 +import { Input } from '@/components/ui/input';
7 +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
8 +import { useModels, pricePerMillion, type ModelInfo } from '@/hooks/use-models';
9 +import { cn } from '@/lib/utils';
10 +
11 +/** Searchable single-model selector (used for chairman + convergence model). */
12 +export function ModelCombobox({
13 + value,
14 + onChange,
15 + placeholder = 'Select a model',
16 + exclude,
17 +}: {
18 + value: string;
19 + onChange: (id: string) => void;
20 + placeholder?: string;
21 + exclude?: string[];
22 +}) {
23 + const { models } = useModels();
24 + const [open, setOpen] = useState(false);
25 + const [query, setQuery] = useState('');
26 +
27 + const selected = models.find((m) => m.id === value);
28 + const filtered = useMemo(() => filterModels(models, query, exclude), [models, query, exclude]);
29 +
30 + return (
31 + <Popover open={open} onOpenChange={setOpen}>
32 + <PopoverTrigger asChild>
33 + <Button variant="outline" role="combobox" className="w-full justify-between font-normal">
34 + <span className="truncate">{selected ? selected.name : value || placeholder}</span>
35 + <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
36 + </Button>
37 + </PopoverTrigger>
38 + <PopoverContent className="w-[--radix-popover-trigger-width] p-0">
39 + <div className="flex items-center gap-2 border-b px-2">
40 + <Search className="h-4 w-4 text-muted-foreground" />
41 + <Input
42 + value={query}
43 + onChange={(e) => setQuery(e.target.value)}
44 + placeholder="Search models..."
45 + className="h-9 border-0 shadow-none focus-visible:ring-0"
46 + />
47 + </div>
48 + <div className="scrollbar-thin max-h-64 overflow-y-auto p-1">
49 + {filtered.length === 0 && <div className="p-3 text-sm text-muted-foreground">No models found</div>}
50 + {filtered.map((m) => (
51 + <button
52 + key={m.id}
53 + onClick={() => {
54 + onChange(m.id);
55 + setOpen(false);
56 + setQuery('');
57 + }}
58 + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
59 + >
60 + <Check className={cn('h-4 w-4', value === m.id ? 'opacity-100' : 'opacity-0')} />
61 + <span className="flex-1 truncate">{m.name}</span>
62 + <span className="text-[11px] text-muted-foreground">{pricePerMillion(m.completionPrice)}</span>
63 + </button>
64 + ))}
65 + </div>
66 + </PopoverContent>
67 + </Popover>
68 + );
69 +}
70 +
71 +export function filterModels(models: ModelInfo[], query: string, exclude?: string[]): ModelInfo[] {
72 + const q = query.trim().toLowerCase();
73 + const ex = new Set(exclude ?? []);
74 + return models
75 + .filter((m) => !ex.has(m.id))
76 + .filter((m) => !q || m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
77 + .slice(0, 100);
78 +}
added src/components/debate/model-panel.tsx +99 −0
@@ -0,0 +1,99 @@
1 +'use client';
2 +
3 +import { AlertTriangle, ChevronDown, Clock, Coins } from 'lucide-react';
4 +import { Badge } from '@/components/ui/badge';
5 +import {
6 + Collapsible,
7 + CollapsibleContent,
8 + CollapsibleTrigger,
9 +} from '@/components/ui/collapsible';
10 +import { RichText } from '@/components/debate/rich-text';
11 +import type { AnswerRecord, StageType } from '@/core/types';
12 +import { participantColor, participantTag } from '@/lib/model-visuals';
13 +import { cn, formatLatency, formatUsd } from '@/lib/utils';
14 +
15 +interface ModelPanelProps {
16 + index: number;
17 + displayName: string;
18 + model: string;
19 + answer?: AnswerRecord;
20 + streamingText?: string;
21 + workingStage?: StageType;
22 + dropped?: boolean;
23 +}
24 +
25 +const STAGE_LABEL: Record<StageType, string> = {
26 + answer: 'Answering',
27 + critique: 'Critiquing peers',
28 + revision: 'Revising',
29 + convergence: 'Assessing',
30 + synthesis: 'Synthesizing',
31 +};
32 +
33 +export function ModelPanel({
34 + index,
35 + displayName,
36 + model,
37 + answer,
38 + streamingText,
39 + workingStage,
40 + dropped,
41 +}: ModelPanelProps) {
42 + const color = participantColor(index);
43 + const text = answer?.content ?? streamingText ?? '';
44 + const isStreaming = workingStage === 'answer' && !answer;
45 +
46 + return (
47 + <Collapsible defaultOpen className="flex flex-col rounded-xl border bg-card">
48 + <CollapsibleTrigger className="group flex items-center gap-2 p-3 text-left">
49 + <span
50 + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-xs font-bold text-white"
51 + style={{ backgroundColor: color }}
52 + >
53 + {participantTag(index)}
54 + </span>
55 + <div className="min-w-0 flex-1">
56 + <div className="truncate text-sm font-semibold">{displayName}</div>
57 + <div className="truncate font-mono text-[11px] text-muted-foreground">{model}</div>
58 + </div>
59 + {dropped ? (
60 + <Badge variant="destructive" className="gap-1">
61 + <AlertTriangle className="h-3 w-3" /> dropped
62 + </Badge>
63 + ) : workingStage ? (
64 + <Badge variant="warning" className="animate-pulse-subtle">
65 + {STAGE_LABEL[workingStage]}
66 + </Badge>
67 + ) : answer ? (
68 + <Badge variant="secondary">round {answer.round}</Badge>
69 + ) : null}
70 + <ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" />
71 + </CollapsibleTrigger>
72 +
73 + <CollapsibleContent>
74 + <div className="scrollbar-thin max-h-[420px] overflow-y-auto border-t px-4 py-3">
75 + {text ? (
76 + <RichText text={text} className={cn(isStreaming && 'streaming-caret')} />
77 + ) : dropped ? (
78 + <p className="text-sm text-muted-foreground">This model dropped out of the debate.</p>
79 + ) : (
80 + <p className="text-sm text-muted-foreground">
81 + {workingStage ? `${STAGE_LABEL[workingStage]}...` : 'Waiting...'}
82 + </p>
83 + )}
84 + </div>
85 + {answer && (
86 + <div className="flex items-center gap-4 border-t px-4 py-2 text-[11px] text-muted-foreground">
87 + <span className="flex items-center gap-1">
88 + <Clock className="h-3 w-3" /> {formatLatency(answer.latencyMs)}
89 + </span>
90 + <span className="flex items-center gap-1">
91 + <Coins className="h-3 w-3" /> {formatUsd(answer.usage.costUsd)}
92 + </span>
93 + <span>{answer.usage.totalTokens} tok</span>
94 + </div>
95 + )}
96 + </CollapsibleContent>
97 + </Collapsible>
98 + );
99 +}
added src/components/debate/model-picker.tsx +91 −0
@@ -0,0 +1,91 @@
1 +'use client';
2 +
3 +import { Plus, Search, X } from 'lucide-react';
4 +import { useMemo, useState } from 'react';
5 +import { filterModels } from '@/components/debate/model-combobox';
6 +import { Badge } from '@/components/ui/badge';
7 +import { Input } from '@/components/ui/input';
8 +import { displayNameForModel } from '@/core/types';
9 +import { useModels, pricePerMillion } from '@/hooks/use-models';
10 +import { participantColor, participantTag } from '@/lib/model-visuals';
11 +
12 +/** Multi-select council picker (3-6 models), with search and live pricing. */
13 +export function ModelPicker({
14 + council,
15 + onChange,
16 + max = 6,
17 +}: {
18 + council: string[];
19 + onChange: (ids: string[]) => void;
20 + max?: number;
21 +}) {
22 + const { models, loading } = useModels();
23 + const [query, setQuery] = useState('');
24 +
25 + const filtered = useMemo(() => filterModels(models, query, council), [models, query, council]);
26 + const atMax = council.length >= max;
27 +
28 + const add = (id: string) => {
29 + if (!atMax && !council.includes(id)) onChange([...council, id]);
30 + };
31 + const remove = (id: string) => onChange(council.filter((m) => m !== id));
32 +
33 + return (
34 + <div className="space-y-3">
35 + <div className="flex flex-wrap gap-2">
36 + {council.length === 0 && (
37 + <p className="text-sm text-muted-foreground">Pick 3-6 models to form the council.</p>
38 + )}
39 + {council.map((id, i) => (
40 + <Badge key={id} variant="outline" className="gap-1.5 py-1 pl-1.5 pr-1">
41 + <span
42 + className="flex h-4 w-4 items-center justify-center rounded text-[9px] font-bold text-white"
43 + style={{ backgroundColor: participantColor(i) }}
44 + >
45 + {participantTag(i)}
46 + </span>
47 + {displayNameForModel(id)}
48 + <button onClick={() => remove(id)} className="rounded p-0.5 hover:bg-muted" aria-label="Remove">
49 + <X className="h-3 w-3" />
50 + </button>
51 + </Badge>
52 + ))}
53 + <Badge variant="secondary">
54 + {council.length}/{max}
55 + </Badge>
56 + </div>
57 +
58 + <div className="rounded-lg border">
59 + <div className="flex items-center gap-2 border-b px-2">
60 + <Search className="h-4 w-4 text-muted-foreground" />
61 + <Input
62 + value={query}
63 + onChange={(e) => setQuery(e.target.value)}
64 + placeholder={loading ? 'Loading models...' : 'Search 300+ models...'}
65 + className="h-9 border-0 shadow-none focus-visible:ring-0"
66 + />
67 + </div>
68 + <div className="scrollbar-thin max-h-56 overflow-y-auto p-1">
69 + {filtered.map((m) => (
70 + <button
71 + key={m.id}
72 + onClick={() => add(m.id)}
73 + disabled={atMax}
74 + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-40"
75 + >
76 + <Plus className="h-3.5 w-3.5 text-muted-foreground" />
77 + <span className="min-w-0 flex-1 truncate">{m.name}</span>
78 + <span className="hidden font-mono text-[10px] text-muted-foreground sm:inline">{m.id}</span>
79 + <span className="text-[11px] text-muted-foreground">
80 + in {pricePerMillion(m.promptPrice)} ยท out {pricePerMillion(m.completionPrice)}
81 + </span>
82 + </button>
83 + ))}
84 + {!loading && filtered.length === 0 && (
85 + <div className="p-3 text-sm text-muted-foreground">No models match.</div>
86 + )}
87 + </div>
88 + </div>
89 + </div>
90 + );
91 +}
added src/components/debate/new-debate.tsx +307 −0
@@ -0,0 +1,307 @@
1 +'use client';
2 +
3 +import { AlertTriangle, Loader2, Play, RotateCcw, Save, Sparkles, Square } from 'lucide-react';
4 +import { useEffect, useState } from 'react';
5 +import { DebateConsole } from '@/components/debate/debate-console';
6 +import { ModelCombobox } from '@/components/debate/model-combobox';
7 +import { ModelPicker } from '@/components/debate/model-picker';
8 +import { Badge } from '@/components/ui/badge';
9 +import { Button } from '@/components/ui/button';
10 +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
11 +import { Input } from '@/components/ui/input';
12 +import { Label } from '@/components/ui/label';
13 +import { Slider } from '@/components/ui/slider';
14 +import { toast } from '@/components/ui/sonner';
15 +import { chairmanSharesProvider, DEFAULT_CONVERGENCE_MODEL } from '@/core/models';
16 +import { useDebateStream } from '@/hooks/use-debate-stream';
17 +import { useModels } from '@/hooks/use-models';
18 +
19 +interface Preset {
20 + id: string;
21 + name: string;
22 + models: string[];
23 + chairmanModel: string;
24 + convergenceModel: string | null;
25 + maxRounds: number;
26 + convergenceThreshold: number;
27 + temperature: number;
28 +}
29 +
30 +const EXAMPLE = 'Should a small startup build on a monolith or microservices? Give a decisive recommendation.';
31 +
32 +export function NewDebate() {
33 + const { view, phase, errorMsg, start, cancel, reset } = useDebateStream();
34 + const { models } = useModels();
35 +
36 + const [question, setQuestion] = useState('');
37 + const [council, setCouncil] = useState<string[]>([]);
38 + const [chairman, setChairman] = useState('');
39 + const [convergenceModel, setConvergenceModel] = useState(DEFAULT_CONVERGENCE_MODEL);
40 + const [maxRounds, setMaxRounds] = useState(3);
41 + const [threshold, setThreshold] = useState(85);
42 + const [temperature, setTemperature] = useState(0.7);
43 + const [presets, setPresets] = useState<Preset[]>([]);
44 + const [presetName, setPresetName] = useState('');
45 +
46 + // Seed a sensible default council + chairman once the catalog arrives.
47 + useEffect(() => {
48 + if (models.length === 0 || council.length > 0) return;
49 + const want = [
50 + 'openai/gpt-4o',
51 + 'anthropic/claude-3.5-sonnet',
52 + 'google/gemini-pro-1.5',
53 + ].filter((id) => models.some((m) => m.id === id));
54 + const seed = want.length >= 3 ? want : models.slice(0, 3).map((m) => m.id);
55 + setCouncil(seed);
56 + if (!chairman) {
57 + const chair = models.find((m) => m.id === 'x-ai/grok-2-1212') ?? models.find((m) => !seed.includes(m.id));
58 + if (chair) setChairman(chair.id);
59 + }
60 + // eslint-disable-next-line react-hooks/exhaustive-deps
61 + }, [models]);
62 +
63 + useEffect(() => {
64 + fetch('/api/presets')
65 + .then((r) => r.json())
66 + .then((d: { presets: Preset[] }) => setPresets(d.presets ?? []))
67 + .catch(() => {});
68 + }, []);
69 +
70 + const providerConflict = chairman && council.length > 0 && chairmanSharesProvider(chairman, council);
71 + const canStart = question.trim().length >= 3 && council.length >= 3 && council.length <= 6 && Boolean(chairman);
72 + const running = phase === 'connecting' || phase === 'streaming';
73 +
74 + const applyPreset = (p: Preset) => {
75 + setCouncil(p.models);
76 + setChairman(p.chairmanModel);
77 + setConvergenceModel(p.convergenceModel ?? DEFAULT_CONVERGENCE_MODEL);
78 + setMaxRounds(p.maxRounds);
79 + setThreshold(p.convergenceThreshold);
80 + setTemperature(p.temperature);
81 + toast.success(`Loaded preset "${p.name}"`);
82 + };
83 +
84 + const savePreset = async () => {
85 + if (!presetName.trim()) return;
86 + const res = await fetch('/api/presets', {
87 + method: 'POST',
88 + headers: { 'Content-Type': 'application/json' },
89 + body: JSON.stringify({
90 + name: presetName.trim(),
91 + models: council,
92 + chairmanModel: chairman,
93 + convergenceModel,
94 + maxRounds,
95 + convergenceThreshold: threshold,
96 + temperature,
97 + }),
98 + });
99 + if (res.ok) {
100 + const { preset } = (await res.json()) as { preset: Preset };
101 + setPresets((p) => [preset, ...p.filter((x) => x.id !== preset.id)]);
102 + setPresetName('');
103 + toast.success('Preset saved');
104 + } else if (res.status === 401) {
105 + toast.error('Sign in to save presets');
106 + } else {
107 + toast.error('Could not save preset');
108 + }
109 + };
110 +
111 + const launch = () =>
112 + start({
113 + question: question.trim(),
114 + models: council,
115 + chairmanModel: chairman,
116 + convergenceModel,
117 + maxRounds,
118 + convergenceThreshold: threshold,
119 + temperature,
120 + perModelTimeoutMs: 90_000,
121 + });
122 +
123 + if (phase === 'idle') {
124 + return (
125 + <div className="mx-auto max-w-3xl space-y-5">
126 + <div className="space-y-1">
127 + <h1 className="flex items-center gap-2 text-2xl font-semibold">
128 + <Sparkles className="h-6 w-6 text-primary" /> New debate
129 + </h1>
130 + <p className="text-sm text-muted-foreground">
131 + Convene a council of models to critique and revise each other, then a chairman synthesizes the answer.
132 + </p>
133 + </div>
134 +
135 + <Card>
136 + <CardHeader className="pb-3">
137 + <CardTitle className="text-base">Question</CardTitle>
138 + </CardHeader>
139 + <CardContent className="space-y-2">
140 + <textarea
141 + value={question}
142 + onChange={(e) => setQuestion(e.target.value)}
143 + placeholder={EXAMPLE}
144 + rows={3}
145 + className="flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
146 + />
147 + <button className="text-xs text-primary hover:underline" onClick={() => setQuestion(EXAMPLE)}>
148 + Use an example question
149 + </button>
150 + </CardContent>
151 + </Card>
152 +
153 + <Card>
154 + <CardHeader className="pb-3">
155 + <CardTitle className="text-base">Council</CardTitle>
156 + </CardHeader>
157 + <CardContent>
158 + <ModelPicker council={council} onChange={setCouncil} />
159 + </CardContent>
160 + </Card>
161 +
162 + <Card>
163 + <CardHeader className="pb-3">
164 + <CardTitle className="text-base">Configuration</CardTitle>
165 + </CardHeader>
166 + <CardContent className="space-y-5">
167 + <div className="grid gap-4 sm:grid-cols-2">
168 + <div className="space-y-1.5">
169 + <Label>Chairman (synthesis)</Label>
170 + <ModelCombobox value={chairman} onChange={setChairman} placeholder="Independent model preferred" />
171 + {providerConflict && (
172 + <p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
173 + <AlertTriangle className="h-3.5 w-3.5" /> Shares a provider family with a council member.
174 + </p>
175 + )}
176 + </div>
177 + <div className="space-y-1.5">
178 + <Label>Convergence assessor</Label>
179 + <ModelCombobox value={convergenceModel} onChange={setConvergenceModel} placeholder="A fast, cheap model" />
180 + </div>
181 + </div>
182 +
183 + <SliderRow label="Max rounds" value={maxRounds} min={1} max={5} step={1} onChange={setMaxRounds} display={String(maxRounds)} />
184 + <SliderRow
185 + label="Convergence threshold"
186 + value={threshold}
187 + min={50}
188 + max={100}
189 + step={1}
190 + onChange={setThreshold}
191 + display={`${threshold}/100`}
192 + />
193 + <SliderRow
194 + label="Temperature"
195 + value={temperature}
196 + min={0}
197 + max={1.5}
198 + step={0.1}
199 + onChange={setTemperature}
200 + display={temperature.toFixed(1)}
201 + />
202 +
203 + {presets.length > 0 && (
204 + <div className="space-y-1.5">
205 + <Label>Presets</Label>
206 + <div className="flex flex-wrap gap-2">
207 + {presets.map((p) => (
208 + <Button key={p.id} variant="secondary" size="sm" onClick={() => applyPreset(p)}>
209 + {p.name}
210 + </Button>
211 + ))}
212 + </div>
213 + </div>
214 + )}
215 +
216 + <div className="flex items-center gap-2">
217 + <Input
218 + value={presetName}
219 + onChange={(e) => setPresetName(e.target.value)}
220 + placeholder="Save this council as..."
221 + className="h-8 max-w-xs"
222 + />
223 + <Button variant="outline" size="sm" onClick={savePreset} disabled={!presetName.trim()}>
224 + <Save className="h-3.5 w-3.5" /> Save
225 + </Button>
226 + </div>
227 + </CardContent>
228 + </Card>
229 +
230 + {errorMsg && (
231 + <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
232 + <AlertTriangle className="h-4 w-4" /> {errorMsg}
233 + </div>
234 + )}
235 +
236 + <div className="sticky bottom-4 flex justify-end">
237 + <Button size="lg" disabled={!canStart} onClick={launch} className="shadow-lg">
238 + <Play className="h-4 w-4" /> Start debate
239 + </Button>
240 + </div>
241 + </div>
242 + );
243 + }
244 +
245 + return (
246 + <div className="space-y-4">
247 + <div className="flex items-center justify-between">
248 + <div className="flex items-center gap-2 text-sm text-muted-foreground">
249 + {running ? (
250 + <>
251 + <Loader2 className="h-4 w-4 animate-spin text-primary" /> Debate in progress - you can leave; it
252 + continues server-side.
253 + </>
254 + ) : (
255 + <Badge variant="secondary">Finished</Badge>
256 + )}
257 + </div>
258 + <div className="flex gap-2">
259 + {running && (
260 + <Button variant="outline" size="sm" onClick={cancel}>
261 + <Square className="h-3.5 w-3.5" /> Stop watching
262 + </Button>
263 + )}
264 + <Button variant="outline" size="sm" onClick={reset}>
265 + <RotateCcw className="h-3.5 w-3.5" /> New debate
266 + </Button>
267 + </div>
268 + </div>
269 +
270 + {errorMsg && (
271 + <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
272 + <AlertTriangle className="h-4 w-4" /> {errorMsg}
273 + </div>
274 + )}
275 +
276 + <DebateConsole view={view} />
277 + </div>
278 + );
279 +}
280 +
281 +function SliderRow({
282 + label,
283 + value,
284 + min,
285 + max,
286 + step,
287 + onChange,
288 + display,
289 +}: {
290 + label: string;
291 + value: number;
292 + min: number;
293 + max: number;
294 + step: number;
295 + onChange: (v: number) => void;
296 + display: string;
297 +}) {
298 + return (
299 + <div className="space-y-2">
300 + <div className="flex items-center justify-between">
301 + <Label>{label}</Label>
302 + <span className="font-mono text-xs text-muted-foreground">{display}</span>
303 + </div>
304 + <Slider value={[value]} min={min} max={max} step={step} onValueChange={(v) => onChange(v[0]!)} />
305 + </div>
306 + );
307 +}
added src/components/debate/revision-diff.tsx +90 −0
@@ -0,0 +1,90 @@
1 +'use client';
2 +
3 +import { diffWords } from 'diff';
4 +import { ArrowRight, GitCommitHorizontal, ShieldCheck } from 'lucide-react';
5 +import { useMemo, useState } from 'react';
6 +import { RichText } from '@/components/debate/rich-text';
7 +import { Badge } from '@/components/ui/badge';
8 +import { Button } from '@/components/ui/button';
9 +import type { RevisionChangelog } from '@/core/types';
10 +import { participantColor, participantTag } from '@/lib/model-visuals';
11 +
12 +interface RevisionDiffProps {
13 + index: number;
14 + displayName: string;
15 + previous: string;
16 + next: string;
17 + changelog: RevisionChangelog;
18 +}
19 +
20 +export function RevisionDiff({ index, displayName, previous, next, changelog }: RevisionDiffProps) {
21 + const [showDiff, setShowDiff] = useState(true);
22 + const parts = useMemo(() => diffWords(previous, next), [previous, next]);
23 + const hasTextChange = parts.some((p) => p.added || p.removed);
24 +
25 + return (
26 + <div className="rounded-xl border bg-card">
27 + <div className="flex flex-wrap items-center gap-2 border-b p-3">
28 + <span
29 + className="flex h-6 w-6 items-center justify-center rounded-md text-xs font-bold text-white"
30 + style={{ backgroundColor: participantColor(index) }}
31 + >
32 + {participantTag(index)}
33 + </span>
34 + <span className="text-sm font-semibold">{displayName}</span>
35 + {changelog.changed ? (
36 + <Badge variant="default" className="gap-1">
37 + <GitCommitHorizontal className="h-3 w-3" /> Revised
38 + </Badge>
39 + ) : (
40 + <Badge variant="secondary" className="gap-1">
41 + <ShieldCheck className="h-3 w-3" /> Defended original
42 + </Badge>
43 + )}
44 + {hasTextChange && (
45 + <Button variant="ghost" size="sm" className="ml-auto" onClick={() => setShowDiff((v) => !v)}>
46 + {showDiff ? 'Show final text' : 'Show diff'}
47 + </Button>
48 + )}
49 + </div>
50 +
51 + <div className="space-y-3 p-4">
52 + <div className="rounded-lg bg-muted/50 p-3 text-sm">
53 + <div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
54 + <ArrowRight className="h-3.5 w-3.5" /> What changed & why
55 + </div>
56 + <p className="text-foreground/90">{changelog.summary || '-'}</p>
57 + {changelog.bullets.length > 0 && (
58 + <ul className="mt-2 list-inside list-disc space-y-0.5 text-xs text-muted-foreground">
59 + {changelog.bullets.map((b, i) => (
60 + <li key={i}>{b}</li>
61 + ))}
62 + </ul>
63 + )}
64 + </div>
65 +
66 + {showDiff && hasTextChange ? (
67 + <div className="prose-debate whitespace-pre-wrap rounded-lg border p-3 text-sm leading-relaxed">
68 + {parts.map((part, i) =>
69 + part.added ? (
70 + <span key={i} className="rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-300">
71 + {part.value}
72 + </span>
73 + ) : part.removed ? (
74 + <span key={i} className="rounded bg-red-500/15 text-red-600 line-through dark:text-red-400">
75 + {part.value}
76 + </span>
77 + ) : (
78 + <span key={i} className="text-foreground/80">
79 + {part.value}
80 + </span>
81 + ),
82 + )}
83 + </div>
84 + ) : (
85 + <RichText text={next} />
86 + )}
87 + </div>
88 + </div>
89 + );
90 +}
added src/components/debate/rich-text.tsx +38 −0
@@ -0,0 +1,38 @@
1 +import { Fragment } from 'react';
2 +import { cn } from '@/lib/utils';
3 +
4 +/**
5 + * Minimal, XSS-safe renderer for model output.
6 + *
7 + * Model text is untrusted, so we never set innerHTML - we build React nodes.
8 + * We support just enough Markdown to read well: paragraphs, `**bold**`, and
9 + * `inline code`.
10 + */
11 +export function RichText({ text, className }: { text: string; className?: string }) {
12 + const paragraphs = text.split(/\n{2,}/);
13 + return (
14 + <div className={cn('prose-debate', className)}>
15 + {paragraphs.map((para, i) => (
16 + <p key={i}>{renderInline(para)}</p>
17 + ))}
18 + </div>
19 + );
20 +}
21 +
22 +function renderInline(text: string): React.ReactNode {
23 + // Split on **bold** and `code`, keeping delimiters.
24 + const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
25 + return parts.map((part, i) => {
26 + if (part.startsWith('**') && part.endsWith('**')) {
27 + return <strong key={i}>{part.slice(2, -2)}</strong>;
28 + }
29 + if (part.startsWith('`') && part.endsWith('`')) {
30 + return (
31 + <code key={i} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
32 + {part.slice(1, -1)}
33 + </code>
34 + );
35 + }
36 + return <Fragment key={i}>{part}</Fragment>;
37 + });
38 +}
added src/components/debate/stage-timeline.tsx +127 −0
@@ -0,0 +1,127 @@
1 +'use client';
2 +
3 +import { Check, Gavel, Loader2, MessagesSquare, PencilLine, Scale } from 'lucide-react';
4 +import type { DebateView } from '@/lib/debate-view';
5 +import { cn } from '@/lib/utils';
6 +
7 +type NodeStatus = 'done' | 'active' | 'pending';
8 +
9 +interface TimelineNode {
10 + key: string;
11 + label: string;
12 + sub?: string;
13 + icon: React.ReactNode;
14 + status: NodeStatus;
15 + accent: string;
16 + anchor: string;
17 +}
18 +
19 +/**
20 + * Horizontal stepper: Round 0 โ†’ (Critique โ†’ Revision โ†’ Converge)ร—N โ†’ Synthesis.
21 + * Clicking a node scrolls to its section. Scrolls horizontally on mobile.
22 + */
23 +export function StageTimeline({ view }: { view: DebateView }) {
24 + const nodes = buildNodes(view);
25 +
26 + const scrollTo = (anchor: string) => {
27 + document.getElementById(anchor)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
28 + };
29 +
30 + return (
31 + <div className="scrollbar-thin overflow-x-auto pb-2">
32 + <ol className="flex min-w-max items-center gap-1">
33 + {nodes.map((node, i) => (
34 + <li key={node.key} className="flex items-center">
35 + <button
36 + onClick={() => scrollTo(node.anchor)}
37 + className={cn(
38 + 'group flex items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors',
39 + node.status === 'active' && 'border-primary/60 bg-accent/50',
40 + node.status === 'done' && 'hover:bg-accent/40',
41 + node.status === 'pending' && 'opacity-50',
42 + )}
43 + >
44 + <span
45 + className="flex h-7 w-7 items-center justify-center rounded-md text-white"
46 + style={{ backgroundColor: `hsl(${node.accent})` }}
47 + >
48 + {node.status === 'active' ? (
49 + <Loader2 className="h-4 w-4 animate-spin" />
50 + ) : node.status === 'done' ? (
51 + <Check className="h-4 w-4" />
52 + ) : (
53 + node.icon
54 + )}
55 + </span>
56 + <span className="leading-tight">
57 + <span className="block text-xs font-medium">{node.label}</span>
58 + {node.sub && <span className="block text-[11px] text-muted-foreground">{node.sub}</span>}
59 + </span>
60 + </button>
61 + {i < nodes.length - 1 && <span className="mx-1 h-px w-5 bg-border" />}
62 + </li>
63 + ))}
64 + </ol>
65 + </div>
66 + );
67 +}
68 +
69 +function buildNodes(view: DebateView): TimelineNode[] {
70 + const nodes: TimelineNode[] = [];
71 + const answersDone = Object.keys(view.initialAnswers).length > 0 && !view.working['answer'];
72 + const answersActive = view.activeStage?.stage === 'answer';
73 +
74 + nodes.push({
75 + key: 'answers',
76 + label: 'Round 0',
77 + sub: 'Answers',
78 + icon: <MessagesSquare className="h-4 w-4" />,
79 + status: answersActive ? 'active' : answersDone ? 'done' : 'pending',
80 + accent: 'var(--stage-answer)',
81 + anchor: 'section-council',
82 + });
83 +
84 + const totalRounds = Math.max(view.rounds.length, view.config?.maxRounds ?? 0);
85 + for (let r = 1; r <= totalRounds; r++) {
86 + const round = view.rounds.find((x) => x.round === r);
87 + const isActiveRound = view.activeStage?.round === r;
88 + nodes.push({
89 + key: `crit-${r}`,
90 + label: `Round ${r}`,
91 + sub: 'Critique',
92 + icon: <Scale className="h-4 w-4" />,
93 + status: round?.critiques.length
94 + ? 'done'
95 + : isActiveRound && view.activeStage?.stage === 'critique'
96 + ? 'active'
97 + : 'pending',
98 + accent: 'var(--stage-critique)',
99 + anchor: `section-round-${r}`,
100 + });
101 + nodes.push({
102 + key: `rev-${r}`,
103 + label: `Round ${r}`,
104 + sub: 'Revision',
105 + icon: <PencilLine className="h-4 w-4" />,
106 + status: round?.revisions.length
107 + ? 'done'
108 + : isActiveRound && view.activeStage?.stage === 'revision'
109 + ? 'active'
110 + : 'pending',
111 + accent: 'var(--stage-revision)',
112 + anchor: `section-round-${r}`,
113 + });
114 + }
115 +
116 + nodes.push({
117 + key: 'synthesis',
118 + label: 'Synthesis',
119 + sub: 'Chairman',
120 + icon: <Gavel className="h-4 w-4" />,
121 + status: view.synthesis ? 'done' : view.activeStage?.stage === 'synthesis' ? 'active' : 'pending',
122 + accent: 'var(--stage-synthesis)',
123 + anchor: 'section-final',
124 + });
125 +
126 + return nodes;
127 +}
added src/components/history-list.tsx +105 −0
@@ -0,0 +1,105 @@
1 +'use client';
2 +
3 +import { Coins, MessageSquare, Search, Trash2 } from 'lucide-react';
4 +import Link from 'next/link';
5 +import { useRouter } from 'next/navigation';
6 +import { useMemo, useState } from 'react';
7 +import { Badge } from '@/components/ui/badge';
8 +import { Button } from '@/components/ui/button';
9 +import { Input } from '@/components/ui/input';
10 +import { toast } from '@/components/ui/sonner';
11 +import type { DebateSummary } from '@/db/repositories';
12 +import { displayNameForModel } from '@/core/types';
13 +import { formatRelativeTime, formatUsd, truncate } from '@/lib/utils';
14 +
15 +export function HistoryList({ debates }: { debates: DebateSummary[] }) {
16 + const router = useRouter();
17 + const [query, setQuery] = useState('');
18 + const [deleting, setDeleting] = useState<string | null>(null);
19 +
20 + const filtered = useMemo(() => {
21 + const q = query.trim().toLowerCase();
22 + return q ? debates.filter((d) => d.question.toLowerCase().includes(q)) : debates;
23 + }, [debates, query]);
24 +
25 + const remove = async (id: string) => {
26 + setDeleting(id);
27 + const res = await fetch(`/api/debates/${id}`, { method: 'DELETE' });
28 + if (res.ok) {
29 + toast.success('Debate deleted');
30 + router.refresh();
31 + } else {
32 + toast.error('Could not delete');
33 + }
34 + setDeleting(null);
35 + };
36 +
37 + return (
38 + <div className="space-y-4">
39 + <div className="flex items-center gap-2 rounded-lg border px-3">
40 + <Search className="h-4 w-4 text-muted-foreground" />
41 + <Input
42 + value={query}
43 + onChange={(e) => setQuery(e.target.value)}
44 + placeholder="Search your debates..."
45 + className="h-10 border-0 shadow-none focus-visible:ring-0"
46 + />
47 + </div>
48 +
49 + {filtered.length === 0 ? (
50 + <div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
51 + {debates.length === 0 ? 'No debates yet - start one to see it here.' : 'No debates match your search.'}
52 + </div>
53 + ) : (
54 + <ul className="space-y-2">
55 + {filtered.map((d) => (
56 + <li
57 + key={d.id}
58 + className="group flex items-center gap-3 rounded-xl border bg-card p-4 transition-colors hover:border-primary/40"
59 + >
60 + <Link href={`/debate/${d.id}`} className="min-w-0 flex-1">
61 + <div className="flex items-center gap-2">
62 + <StatusDot status={d.status} />
63 + <span className="truncate font-medium">{truncate(d.question, 110)}</span>
64 + </div>
65 + <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
66 + <span className="flex items-center gap-1">
67 + <MessageSquare className="h-3 w-3" /> {d.models.length} models ยท {d.roundsCompleted} rounds
68 + </span>
69 + <span className="flex items-center gap-1">
70 + <Coins className="h-3 w-3" /> {formatUsd(d.totalCostUsd)}
71 + </span>
72 + <span>{formatRelativeTime(d.createdAt)}</span>
73 + <span className="hidden truncate sm:inline">{d.models.map(displayNameForModel).join(' ยท ')}</span>
74 + </div>
75 + </Link>
76 + {d.shareToken && <Badge variant="secondary">shared</Badge>}
77 + <Button
78 + variant="ghost"
79 + size="icon"
80 + className="opacity-0 transition-opacity group-hover:opacity-100"
81 + disabled={deleting === d.id}
82 + onClick={() => remove(d.id)}
83 + aria-label="Delete debate"
84 + >
85 + <Trash2 className="h-4 w-4" />
86 + </Button>
87 + </li>
88 + ))}
89 + </ul>
90 + )}
91 + </div>
92 + );
93 +}
94 +
95 +function StatusDot({ status }: { status: DebateSummary['status'] }) {
96 + const color =
97 + status === 'completed'
98 + ? 'bg-emerald-500'
99 + : status === 'running'
100 + ? 'bg-amber-500'
101 + : status === 'failed'
102 + ? 'bg-red-500'
103 + : 'bg-muted-foreground';
104 + return <span className={`h-2 w-2 shrink-0 rounded-full ${color}`} />;
105 +}
added src/components/key-button.tsx +38 −0
@@ -0,0 +1,38 @@
1 +'use client';
2 +
3 +import { KeyRound } from 'lucide-react';
4 +import { useEffect, useState } from 'react';
5 +import { ApiKeyDialog } from '@/components/api-key-dialog';
6 +import { Button } from '@/components/ui/button';
7 +import { cn } from '@/lib/utils';
8 +
9 +export function KeyButton() {
10 + const [open, setOpen] = useState(false);
11 + const [state, setState] = useState<{ hasKey: boolean; mock: boolean }>({ hasKey: false, mock: false });
12 +
13 + const load = () =>
14 + fetch('/api/keys')
15 + .then((r) => r.json())
16 + .then((s: { hasKey: boolean; mockMode: boolean }) => setState({ hasKey: s.hasKey, mock: s.mockMode }))
17 + .catch(() => {});
18 +
19 + useEffect(() => {
20 + void load();
21 + }, []);
22 +
23 + return (
24 + <>
25 + <Button variant="ghost" size="sm" className="gap-2" onClick={() => setOpen(true)}>
26 + <span
27 + className={cn(
28 + 'h-2 w-2 rounded-full',
29 + state.mock ? 'bg-amber-500' : state.hasKey ? 'bg-emerald-500' : 'bg-muted-foreground/40',
30 + )}
31 + />
32 + <KeyRound className="h-4 w-4" />
33 + <span className="hidden sm:inline">{state.mock ? 'Mock' : state.hasKey ? 'Key set' : 'Add key'}</span>
34 + </Button>
35 + <ApiKeyDialog open={open} onOpenChange={setOpen} onChanged={load} />
36 + </>
37 + );
38 +}
added src/components/landing-hero.tsx +82 −0
@@ -0,0 +1,82 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useEffect } from 'react';
5 +import { DebateConsole } from '@/components/debate/debate-console';
6 +import { Button } from '@/components/ui/button';
7 +import type { DebateResult } from '@/core/types';
8 +import { useDebatePlayback } from '@/hooks/use-debate-playback';
9 +
10 +/**
11 + * The landing hero: a real recorded debate auto-plays and loops, front and
12 + * center. Almost no marketing copy - the product sells itself in motion. The
13 + * live console is clipped to a viewport-height frame with a soft fade, so the
14 + * page stays tight while the debate animates.
15 + */
16 +export function LandingHero({ result }: { result: DebateResult }) {
17 + const { view, state, play, restart, setSpeed } = useDebatePlayback(result);
18 +
19 + // Autoplay a little faster than a real debate so the loop stays lively.
20 + useEffect(() => {
21 + setSpeed(2);
22 + play();
23 + // eslint-disable-next-line react-hooks/exhaustive-deps
24 + }, []);
25 +
26 + // Loop: once it finishes, hold on the final answer briefly, then replay.
27 + useEffect(() => {
28 + if (state !== 'finished') return;
29 + const t = setTimeout(() => restart(), 4000);
30 + return () => clearTimeout(t);
31 + }, [state, restart]);
32 +
33 + return (
34 + <section className="container py-10 md:py-14">
35 + <div className="mx-auto mb-8 max-w-2xl text-center">
36 + <h1 className="text-3xl font-bold tracking-tight sm:text-5xl">
37 + Watch models argue their way to a better answer.
38 + </h1>
39 + <p className="mx-auto mt-4 max-w-xl text-muted-foreground">
40 + A real debate, replaying live below. A council answers, critiques each other blind, and revises. A chairman
41 + calls it.
42 + </p>
43 + <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
44 + <Button size="lg" asChild>
45 + <Link href="/debate">Start your own</Link>
46 + </Button>
47 + <Button size="lg" variant="ghost" asChild>
48 + <Link href="/demo">Browse the recordings</Link>
49 + </Button>
50 + </div>
51 + </div>
52 +
53 + <div className="relative mx-auto max-w-5xl">
54 + <div className="pointer-events-none absolute -inset-x-4 -top-4 bottom-4 -z-10 rounded-[2.5rem] bg-primary/[0.07] blur-3xl" />
55 + <div className="relative overflow-hidden rounded-2xl border bg-card shadow-2xl">
56 + <div className="flex items-center gap-3 border-b px-4 py-2.5">
57 + <span className="flex gap-1.5">
58 + <span className="h-2.5 w-2.5 rounded-full bg-red-400/70" />
59 + <span className="h-2.5 w-2.5 rounded-full bg-amber-400/70" />
60 + <span className="h-2.5 w-2.5 rounded-full bg-emerald-400/70" />
61 + </span>
62 + <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
63 + <span className="h-2 w-2 animate-pulse-subtle rounded-full bg-emerald-500" />
64 + replaying a real debate
65 + </span>
66 + </div>
67 +
68 + <div className="relative max-h-[68vh] overflow-hidden p-4 md:p-6">
69 + <DebateConsole view={view} showActions={false} />
70 + <div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-card via-card/80 to-transparent" />
71 + </div>
72 +
73 + <div className="absolute inset-x-0 bottom-4 flex justify-center">
74 + <Button asChild size="sm" variant="secondary" className="shadow-lg">
75 + <Link href={`/demo/${result.debateId}`}>Open the full replay</Link>
76 + </Button>
77 + </div>
78 + </div>
79 + </div>
80 + </section>
81 + );
82 +}
added src/components/site-header.tsx +78 −0
@@ -0,0 +1,78 @@
1 +import { GitCompareArrows, History, LogOut, Play, Sparkles } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { auth, signIn, signOut } from '@/auth';
4 +import { KeyButton } from '@/components/key-button';
5 +import { ThemeToggle } from '@/components/theme-toggle';
6 +import { Button } from '@/components/ui/button';
7 +import { isGithubAuthConfigured } from '@/lib/env';
8 +
9 +export async function SiteHeader() {
10 + const session = await auth();
11 + const user = session?.user;
12 +
13 + return (
14 + <header className="sticky top-0 z-40 w-full border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
15 + <div className="container flex h-14 items-center gap-4">
16 + <Link href="/" className="flex items-center gap-2 font-semibold">
17 + <GitCompareArrows className="h-5 w-5 text-primary" />
18 + <span className="tracking-tight">Roundtable</span>
19 + </Link>
20 +
21 + <nav className="ml-2 hidden items-center gap-1 text-sm md:flex">
22 + <NavLink href="/debate" icon={<Sparkles className="h-4 w-4" />}>
23 + New debate
24 + </NavLink>
25 + <NavLink href="/demo" icon={<Play className="h-4 w-4" />}>
26 + Demo
27 + </NavLink>
28 + {user && (
29 + <NavLink href="/history" icon={<History className="h-4 w-4" />}>
30 + History
31 + </NavLink>
32 + )}
33 + </nav>
34 +
35 + <div className="ml-auto flex items-center gap-1">
36 + <KeyButton />
37 + <ThemeToggle />
38 + {user ? (
39 + <form
40 + action={async () => {
41 + 'use server';
42 + await signOut({ redirectTo: '/' });
43 + }}
44 + >
45 + <Button variant="ghost" size="sm" className="gap-2" type="submit">
46 + <LogOut className="h-4 w-4" />
47 + <span className="hidden sm:inline">{user.name ?? 'Sign out'}</span>
48 + </Button>
49 + </form>
50 + ) : isGithubAuthConfigured ? (
51 + <form
52 + action={async () => {
53 + 'use server';
54 + await signIn('github', { redirectTo: '/history' });
55 + }}
56 + >
57 + <Button variant="outline" size="sm" type="submit">
58 + Sign in
59 + </Button>
60 + </form>
61 + ) : null}
62 + </div>
63 + </div>
64 + </header>
65 + );
66 +}
67 +
68 +function NavLink({ href, icon, children }: { href: string; icon: React.ReactNode; children: React.ReactNode }) {
69 + return (
70 + <Link
71 + href={href}
72 + className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
73 + >
74 + {icon}
75 + {children}
76 + </Link>
77 + );
78 +}
added src/components/theme-provider.tsx +8 −0
@@ -0,0 +1,8 @@
1 +'use client';
2 +
3 +import { ThemeProvider as NextThemesProvider } from 'next-themes';
4 +import type * as React from 'react';
5 +
6 +export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
7 + return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
8 +}
added src/components/theme-toggle.tsx +23 −0
@@ -0,0 +1,23 @@
1 +'use client';
2 +
3 +import { Moon, Sun } from 'lucide-react';
4 +import { useTheme } from 'next-themes';
5 +import { useEffect, useState } from 'react';
6 +import { Button } from '@/components/ui/button';
7 +
8 +export function ThemeToggle() {
9 + const { setTheme, resolvedTheme } = useTheme();
10 + const [mounted, setMounted] = useState(false);
11 + useEffect(() => setMounted(true), []);
12 +
13 + return (
14 + <Button
15 + variant="ghost"
16 + size="icon"
17 + aria-label="Toggle theme"
18 + onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
19 + >
20 + {mounted && resolvedTheme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
21 + </Button>
22 + );
23 +}
added src/components/ui/badge.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import { cva, type VariantProps } from 'class-variance-authority';
2 +import * as React from 'react';
3 +import { cn } from '@/lib/utils';
4 +
5 +const badgeVariants = cva(
6 + 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none',
7 + {
8 + variants: {
9 + variant: {
10 + default: 'border-transparent bg-primary/10 text-primary',
11 + secondary: 'border-transparent bg-secondary text-secondary-foreground',
12 + destructive: 'border-transparent bg-destructive/10 text-destructive',
13 + success: 'border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
14 + warning: 'border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400',
15 + outline: 'text-foreground',
16 + },
17 + },
18 + defaultVariants: { variant: 'default' },
19 + },
20 +);
21 +
22 +export interface BadgeProps
23 + extends React.HTMLAttributes<HTMLDivElement>,
24 + VariantProps<typeof badgeVariants> {}
25 +
26 +function Badge({ className, variant, ...props }: BadgeProps) {
27 + return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
28 +}
29 +
30 +export { Badge, badgeVariants };
added src/components/ui/button.tsx +43 −0
@@ -0,0 +1,43 @@
1 +import { Slot } from '@radix-ui/react-slot';
2 +import { cva, type VariantProps } from 'class-variance-authority';
3 +import * as React from 'react';
4 +import { cn } from '@/lib/utils';
5 +
6 +const buttonVariants = cva(
7 + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
8 + {
9 + variants: {
10 + variant: {
11 + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
12 + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
13 + outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
14 + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
15 + ghost: 'hover:bg-accent hover:text-accent-foreground',
16 + link: 'text-primary underline-offset-4 hover:underline',
17 + },
18 + size: {
19 + default: 'h-9 px-4 py-2',
20 + sm: 'h-8 rounded-md px-3 text-xs',
21 + lg: 'h-11 rounded-md px-6',
22 + icon: 'h-9 w-9',
23 + },
24 + },
25 + defaultVariants: { variant: 'default', size: 'default' },
26 + },
27 +);
28 +
29 +export interface ButtonProps
30 + extends React.ButtonHTMLAttributes<HTMLButtonElement>,
31 + VariantProps<typeof buttonVariants> {
32 + asChild?: boolean;
33 +}
34 +
35 +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
36 + ({ className, variant, size, asChild = false, ...props }, ref) => {
37 + const Comp = asChild ? Slot : 'button';
38 + return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
39 + },
40 +);
41 +Button.displayName = 'Button';
42 +
43 +export { Button, buttonVariants };
added src/components/ui/card.tsx +48 −0
@@ -0,0 +1,48 @@
1 +import * as React from 'react';
2 +import { cn } from '@/lib/utils';
3 +
4 +const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
5 + ({ className, ...props }, ref) => (
6 + <div
7 + ref={ref}
8 + className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)}
9 + {...props}
10 + />
11 + ),
12 +);
13 +Card.displayName = 'Card';
14 +
15 +const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
16 + ({ className, ...props }, ref) => (
17 + <div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
18 + ),
19 +);
20 +CardHeader.displayName = 'CardHeader';
21 +
22 +const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
23 + ({ className, ...props }, ref) => (
24 + <div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
25 + ),
26 +);
27 +CardTitle.displayName = 'CardTitle';
28 +
29 +const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
30 + ({ className, ...props }, ref) => (
31 + <div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
32 + ),
33 +);
34 +CardDescription.displayName = 'CardDescription';
35 +
36 +const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
37 + ({ className, ...props }, ref) => <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />,
38 +);
39 +CardContent.displayName = 'CardContent';
40 +
41 +const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
42 + ({ className, ...props }, ref) => (
43 + <div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
44 + ),
45 +);
46 +CardFooter.displayName = 'CardFooter';
47 +
48 +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
added src/components/ui/collapsible.tsx +9 −0
@@ -0,0 +1,9 @@
1 +'use client';
2 +
3 +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
4 +
5 +const Collapsible = CollapsiblePrimitive.Root;
6 +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
7 +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
8 +
9 +export { Collapsible, CollapsibleTrigger, CollapsibleContent };
added src/components/ui/dialog.tsx +86 −0
@@ -0,0 +1,86 @@
1 +'use client';
2 +
3 +import * as DialogPrimitive from '@radix-ui/react-dialog';
4 +import { X } from 'lucide-react';
5 +import * as React from 'react';
6 +import { cn } from '@/lib/utils';
7 +
8 +const Dialog = DialogPrimitive.Root;
9 +const DialogTrigger = DialogPrimitive.Trigger;
10 +const DialogPortal = DialogPrimitive.Portal;
11 +const DialogClose = DialogPrimitive.Close;
12 +
13 +const DialogOverlay = React.forwardRef<
14 + React.ElementRef<typeof DialogPrimitive.Overlay>,
15 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
16 +>(({ className, ...props }, ref) => (
17 + <DialogPrimitive.Overlay
18 + ref={ref}
19 + className={cn(
20 + 'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
21 + className,
22 + )}
23 + {...props}
24 + />
25 +));
26 +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
27 +
28 +const DialogContent = React.forwardRef<
29 + React.ElementRef<typeof DialogPrimitive.Content>,
30 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
31 +>(({ className, children, ...props }, ref) => (
32 + <DialogPortal>
33 + <DialogOverlay />
34 + <DialogPrimitive.Content
35 + ref={ref}
36 + className={cn(
37 + 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl',
38 + className,
39 + )}
40 + {...props}
41 + >
42 + {children}
43 + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
44 + <X className="h-4 w-4" />
45 + <span className="sr-only">Close</span>
46 + </DialogPrimitive.Close>
47 + </DialogPrimitive.Content>
48 + </DialogPortal>
49 +));
50 +DialogContent.displayName = DialogPrimitive.Content.displayName;
51 +
52 +function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
53 + return <div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />;
54 +}
55 +function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
56 + return <div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
57 +}
58 +
59 +const DialogTitle = React.forwardRef<
60 + React.ElementRef<typeof DialogPrimitive.Title>,
61 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
62 +>(({ className, ...props }, ref) => (
63 + <DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
64 +));
65 +DialogTitle.displayName = DialogPrimitive.Title.displayName;
66 +
67 +const DialogDescription = React.forwardRef<
68 + React.ElementRef<typeof DialogPrimitive.Description>,
69 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
70 +>(({ className, ...props }, ref) => (
71 + <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
72 +));
73 +DialogDescription.displayName = DialogPrimitive.Description.displayName;
74 +
75 +export {
76 + Dialog,
77 + DialogPortal,
78 + DialogOverlay,
79 + DialogTrigger,
80 + DialogClose,
81 + DialogContent,
82 + DialogHeader,
83 + DialogFooter,
84 + DialogTitle,
85 + DialogDescription,
86 +};
added src/components/ui/input.tsx +19 −0
@@ -0,0 +1,19 @@
1 +import * as React from 'react';
2 +import { cn } from '@/lib/utils';
3 +
4 +const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
5 + ({ className, type, ...props }, ref) => (
6 + <input
7 + type={type}
8 + className={cn(
9 + 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
10 + className,
11 + )}
12 + ref={ref}
13 + {...props}
14 + />
15 + ),
16 +);
17 +Input.displayName = 'Input';
18 +
19 +export { Input };
added src/components/ui/label.tsx +19 −0
@@ -0,0 +1,19 @@
1 +'use client';
2 +
3 +import * as LabelPrimitive from '@radix-ui/react-label';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const Label = React.forwardRef<
8 + React.ElementRef<typeof LabelPrimitive.Root>,
9 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
10 +>(({ className, ...props }, ref) => (
11 + <LabelPrimitive.Root
12 + ref={ref}
13 + className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
14 + {...props}
15 + />
16 +));
17 +Label.displayName = LabelPrimitive.Root.displayName;
18 +
19 +export { Label };
added src/components/ui/popover.tsx +30 −0
@@ -0,0 +1,30 @@
1 +'use client';
2 +
3 +import * as PopoverPrimitive from '@radix-ui/react-popover';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const Popover = PopoverPrimitive.Root;
8 +const PopoverTrigger = PopoverPrimitive.Trigger;
9 +const PopoverAnchor = PopoverPrimitive.Anchor;
10 +
11 +const PopoverContent = React.forwardRef<
12 + React.ElementRef<typeof PopoverPrimitive.Content>,
13 + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
14 +>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (
15 + <PopoverPrimitive.Portal>
16 + <PopoverPrimitive.Content
17 + ref={ref}
18 + align={align}
19 + sideOffset={sideOffset}
20 + className={cn(
21 + 'z-50 w-72 rounded-lg border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
22 + className,
23 + )}
24 + {...props}
25 + />
26 + </PopoverPrimitive.Portal>
27 +));
28 +PopoverContent.displayName = PopoverPrimitive.Content.displayName;
29 +
30 +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
added src/components/ui/progress.tsx +24 −0
@@ -0,0 +1,24 @@
1 +'use client';
2 +
3 +import * as React from 'react';
4 +import { cn } from '@/lib/utils';
5 +
6 +/** Minimal determinate progress bar (0-100). */
7 +export function Progress({
8 + value = 0,
9 + className,
10 + indicatorClassName,
11 +}: {
12 + value?: number;
13 + className?: string;
14 + indicatorClassName?: string;
15 +}) {
16 + return (
17 + <div className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}>
18 + <div
19 + className={cn('h-full rounded-full bg-primary transition-all duration-500', indicatorClassName)}
20 + style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
21 + />
22 + </div>
23 + );
24 +}
added src/components/ui/scroll-area.tsx +26 −0
@@ -0,0 +1,26 @@
1 +'use client';
2 +
3 +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const ScrollArea = React.forwardRef<
8 + React.ElementRef<typeof ScrollAreaPrimitive.Root>,
9 + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
10 +>(({ className, children, ...props }, ref) => (
11 + <ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
12 + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
13 + {children}
14 + </ScrollAreaPrimitive.Viewport>
15 + <ScrollAreaPrimitive.Scrollbar
16 + orientation="vertical"
17 + className="flex touch-none select-none transition-colors h-full w-2 border-l border-l-transparent p-[1px]"
18 + >
19 + <ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border" />
20 + </ScrollAreaPrimitive.Scrollbar>
21 + <ScrollAreaPrimitive.Corner />
22 + </ScrollAreaPrimitive.Root>
23 +));
24 +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
25 +
26 +export { ScrollArea };
added src/components/ui/select.tsx +79 −0
@@ -0,0 +1,79 @@
1 +'use client';
2 +
3 +import * as SelectPrimitive from '@radix-ui/react-select';
4 +import { Check, ChevronDown } from 'lucide-react';
5 +import * as React from 'react';
6 +import { cn } from '@/lib/utils';
7 +
8 +const Select = SelectPrimitive.Root;
9 +const SelectGroup = SelectPrimitive.Group;
10 +const SelectValue = SelectPrimitive.Value;
11 +
12 +const SelectTrigger = React.forwardRef<
13 + React.ElementRef<typeof SelectPrimitive.Trigger>,
14 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
15 +>(({ className, children, ...props }, ref) => (
16 + <SelectPrimitive.Trigger
17 + ref={ref}
18 + className={cn(
19 + 'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
20 + className,
21 + )}
22 + {...props}
23 + >
24 + {children}
25 + <SelectPrimitive.Icon asChild>
26 + <ChevronDown className="h-4 w-4 opacity-50" />
27 + </SelectPrimitive.Icon>
28 + </SelectPrimitive.Trigger>
29 +));
30 +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
31 +
32 +const SelectContent = React.forwardRef<
33 + React.ElementRef<typeof SelectPrimitive.Content>,
34 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
35 +>(({ className, children, position = 'popper', ...props }, ref) => (
36 + <SelectPrimitive.Portal>
37 + <SelectPrimitive.Content
38 + ref={ref}
39 + className={cn(
40 + 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
41 + position === 'popper' && 'data-[side=bottom]:translate-y-1',
42 + className,
43 + )}
44 + position={position}
45 + {...props}
46 + >
47 + <SelectPrimitive.Viewport
48 + className={cn('p-1', position === 'popper' && 'w-full min-w-[var(--radix-select-trigger-width)]')}
49 + >
50 + {children}
51 + </SelectPrimitive.Viewport>
52 + </SelectPrimitive.Content>
53 + </SelectPrimitive.Portal>
54 +));
55 +SelectContent.displayName = SelectPrimitive.Content.displayName;
56 +
57 +const SelectItem = React.forwardRef<
58 + React.ElementRef<typeof SelectPrimitive.Item>,
59 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
60 +>(({ className, children, ...props }, ref) => (
61 + <SelectPrimitive.Item
62 + ref={ref}
63 + className={cn(
64 + 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
65 + className,
66 + )}
67 + {...props}
68 + >
69 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
70 + <SelectPrimitive.ItemIndicator>
71 + <Check className="h-4 w-4" />
72 + </SelectPrimitive.ItemIndicator>
73 + </span>
74 + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
75 + </SelectPrimitive.Item>
76 +));
77 +SelectItem.displayName = SelectPrimitive.Item.displayName;
78 +
79 +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
added src/components/ui/separator.tsx +25 −0
@@ -0,0 +1,25 @@
1 +'use client';
2 +
3 +import * as SeparatorPrimitive from '@radix-ui/react-separator';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const Separator = React.forwardRef<
8 + React.ElementRef<typeof SeparatorPrimitive.Root>,
9 + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
10 +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
11 + <SeparatorPrimitive.Root
12 + ref={ref}
13 + decorative={decorative}
14 + orientation={orientation}
15 + className={cn(
16 + 'shrink-0 bg-border',
17 + orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
18 + className,
19 + )}
20 + {...props}
21 + />
22 +));
23 +Separator.displayName = SeparatorPrimitive.Root.displayName;
24 +
25 +export { Separator };
added src/components/ui/skeleton.tsx +7 −0
@@ -0,0 +1,7 @@
1 +import { cn } from '@/lib/utils';
2 +
3 +function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
4 + return <div className={cn('animate-pulse-subtle rounded-md bg-muted', className)} {...props} />;
5 +}
6 +
7 +export { Skeleton };
added src/components/ui/slider.tsx +24 −0
@@ -0,0 +1,24 @@
1 +'use client';
2 +
3 +import * as SliderPrimitive from '@radix-ui/react-slider';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const Slider = React.forwardRef<
8 + React.ElementRef<typeof SliderPrimitive.Root>,
9 + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
10 +>(({ className, ...props }, ref) => (
11 + <SliderPrimitive.Root
12 + ref={ref}
13 + className={cn('relative flex w-full touch-none select-none items-center', className)}
14 + {...props}
15 + >
16 + <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary">
17 + <SliderPrimitive.Range className="absolute h-full bg-primary" />
18 + </SliderPrimitive.Track>
19 + <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
20 + </SliderPrimitive.Root>
21 +));
22 +Slider.displayName = SliderPrimitive.Root.displayName;
23 +
24 +export { Slider };
added src/components/ui/sonner.tsx +25 −0
@@ -0,0 +1,25 @@
1 +'use client';
2 +
3 +import { useTheme } from 'next-themes';
4 +import { Toaster as Sonner } from 'sonner';
5 +
6 +export function Toaster() {
7 + const { theme = 'system' } = useTheme();
8 + return (
9 + <Sonner
10 + theme={theme as 'light' | 'dark' | 'system'}
11 + className="toaster group"
12 + toastOptions={{
13 + classNames: {
14 + toast:
15 + 'group toast group-[.toaster]:bg-card group-[.toaster]:text-card-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
16 + description: 'group-[.toast]:text-muted-foreground',
17 + actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
18 + cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
19 + },
20 + }}
21 + />
22 + );
23 +}
24 +
25 +export { toast } from 'sonner';
added src/components/ui/switch.tsx +24 −0
@@ -0,0 +1,24 @@
1 +'use client';
2 +
3 +import * as SwitchPrimitives from '@radix-ui/react-switch';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const Switch = React.forwardRef<
8 + React.ElementRef<typeof SwitchPrimitives.Root>,
9 + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
10 +>(({ className, ...props }, ref) => (
11 + <SwitchPrimitives.Root
12 + className={cn(
13 + 'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
14 + className,
15 + )}
16 + {...props}
17 + ref={ref}
18 + >
19 + <SwitchPrimitives.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
20 + </SwitchPrimitives.Root>
21 +));
22 +Switch.displayName = SwitchPrimitives.Root.displayName;
23 +
24 +export { Switch };
added src/components/ui/tabs.tsx +51 −0
@@ -0,0 +1,51 @@
1 +'use client';
2 +
3 +import * as TabsPrimitive from '@radix-ui/react-tabs';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const Tabs = TabsPrimitive.Root;
8 +
9 +const TabsList = React.forwardRef<
10 + React.ElementRef<typeof TabsPrimitive.List>,
11 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
12 +>(({ className, ...props }, ref) => (
13 + <TabsPrimitive.List
14 + ref={ref}
15 + className={cn(
16 + 'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
17 + className,
18 + )}
19 + {...props}
20 + />
21 +));
22 +TabsList.displayName = TabsPrimitive.List.displayName;
23 +
24 +const TabsTrigger = React.forwardRef<
25 + React.ElementRef<typeof TabsPrimitive.Trigger>,
26 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
27 +>(({ className, ...props }, ref) => (
28 + <TabsPrimitive.Trigger
29 + ref={ref}
30 + className={cn(
31 + 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
32 + className,
33 + )}
34 + {...props}
35 + />
36 +));
37 +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
38 +
39 +const TabsContent = React.forwardRef<
40 + React.ElementRef<typeof TabsPrimitive.Content>,
41 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
42 +>(({ className, ...props }, ref) => (
43 + <TabsPrimitive.Content
44 + ref={ref}
45 + className={cn('mt-2 ring-offset-background focus-visible:outline-none', className)}
46 + {...props}
47 + />
48 +));
49 +TabsContent.displayName = TabsPrimitive.Content.displayName;
50 +
51 +export { Tabs, TabsList, TabsTrigger, TabsContent };
added src/components/ui/textarea.tsx +18 −0
@@ -0,0 +1,18 @@
1 +import * as React from 'react';
2 +import { cn } from '@/lib/utils';
3 +
4 +const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
5 + ({ className, ...props }, ref) => (
6 + <textarea
7 + className={cn(
8 + 'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
9 + className,
10 + )}
11 + ref={ref}
12 + {...props}
13 + />
14 + ),
15 +);
16 +Textarea.displayName = 'Textarea';
17 +
18 +export { Textarea };
added src/components/ui/tooltip.tsx +29 −0
@@ -0,0 +1,29 @@
1 +'use client';
2 +
3 +import * as TooltipPrimitive from '@radix-ui/react-tooltip';
4 +import * as React from 'react';
5 +import { cn } from '@/lib/utils';
6 +
7 +const TooltipProvider = TooltipPrimitive.Provider;
8 +const Tooltip = TooltipPrimitive.Root;
9 +const TooltipTrigger = TooltipPrimitive.Trigger;
10 +
11 +const TooltipContent = React.forwardRef<
12 + React.ElementRef<typeof TooltipPrimitive.Content>,
13 + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
14 +>(({ className, sideOffset = 4, ...props }, ref) => (
15 + <TooltipPrimitive.Portal>
16 + <TooltipPrimitive.Content
17 + ref={ref}
18 + sideOffset={sideOffset}
19 + className={cn(
20 + 'z-50 max-w-xs overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
21 + className,
22 + )}
23 + {...props}
24 + />
25 + </TooltipPrimitive.Portal>
26 +));
27 +TooltipContent.displayName = TooltipPrimitive.Content.displayName;
28 +
29 +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
added src/hooks/use-debate-playback.ts +163 −0
@@ -0,0 +1,163 @@
1 +'use client';
2 +
3 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4 +import type { DebateEvent } from '@/core/events';
5 +import { chairmanSharesProvider } from '@/core/models';
6 +import type { DebateResult } from '@/core/types';
7 +import { applyEvent, initialDebateView, type DebateView } from '@/lib/debate-view';
8 +
9 +interface TimedEvent {
10 + event: DebateEvent;
11 + /** Delay before this event fires, in "playback units" (scaled by speed). */
12 + delay: number;
13 +}
14 +
15 +/** Reconstruct an animated event timeline from a finished debate. */
16 +function buildTimeline(result: DebateResult): TimedEvent[] {
17 + const timeline: TimedEvent[] = [];
18 + const emit = (event: DebateEvent, delay = 120) => timeline.push({ event, delay });
19 +
20 + emit(
21 + {
22 + type: 'debate_started',
23 + debateId: result.debateId,
24 + config: result.config,
25 + participants: result.participants,
26 + chairmanModel: result.config.chairmanModel,
27 + chairmanProviderConflict: chairmanSharesProvider(result.config.chairmanModel, result.config.models),
28 + },
29 + 0,
30 + );
31 +
32 + emit({ type: 'round_started', round: 0, kind: 'answers' }, 200);
33 + for (const a of result.initialAnswers) {
34 + emit({ type: 'stage_started', round: 0, stage: 'answer', participantId: a.participantId, model: a.model }, 60);
35 + for (const chunk of chunkText(a.content, 14)) {
36 + emit({ type: 'token_delta', round: 0, stage: 'answer', participantId: a.participantId, delta: chunk }, 45);
37 + }
38 + emit({ type: 'answer_completed', round: 0, record: a }, 80);
39 + }
40 + emitCost(result, emit, 0);
41 +
42 + for (const round of result.rounds) {
43 + emit({ type: 'round_started', round: round.round, kind: 'cycle' }, 250);
44 + for (const c of round.critiques) {
45 + emit({ type: 'stage_started', round: round.round, stage: 'critique', participantId: c.reviewerParticipantId, model: c.reviewerModel }, 90);
46 + emit({ type: 'critique_completed', round: round.round, record: c }, 260);
47 + }
48 + for (const rev of round.revisions) {
49 + emit({ type: 'stage_started', round: round.round, stage: 'revision', participantId: rev.participantId, model: rev.model }, 90);
50 + emit({ type: 'revision_completed', round: round.round, record: rev }, 260);
51 + }
52 + if (round.convergence) {
53 + emit({ type: 'stage_started', round: round.round, stage: 'convergence', model: round.convergence.model }, 90);
54 + emit({ type: 'convergence_result', round: round.round, record: round.convergence }, 220);
55 + }
56 + }
57 +
58 + if (result.synthesis) {
59 + emit({ type: 'stage_started', round: result.rounds.length, stage: 'synthesis', model: result.synthesis.model }, 300);
60 + emit({ type: 'synthesis_completed', record: result.synthesis }, 400);
61 + }
62 + emit(
63 + {
64 + type: 'debate_completed',
65 + debateId: result.debateId,
66 + status: result.status,
67 + totalCostUsd: result.totals.costUsd,
68 + rounds: result.totals.rounds,
69 + durationMs: result.totals.durationMs,
70 + },
71 + 200,
72 + );
73 + return timeline;
74 +}
75 +
76 +function emitCost(result: DebateResult, emit: (e: DebateEvent, d?: number) => void, phase: number) {
77 + void phase;
78 + emit(
79 + {
80 + type: 'cost_update',
81 + totalCostUsd: result.totals.costUsd,
82 + promptTokens: result.totals.promptTokens,
83 + completionTokens: result.totals.completionTokens,
84 + costByModel: result.totals.costByModel,
85 + },
86 + 40,
87 + );
88 +}
89 +
90 +function chunkText(text: string, parts: number): string[] {
91 + const size = Math.ceil(text.length / parts) || 1;
92 + const out: string[] = [];
93 + for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
94 + return out.length ? out : [''];
95 +}
96 +
97 +export type PlaybackState = 'idle' | 'playing' | 'paused' | 'finished';
98 +
99 +/**
100 + * Replays a recorded debate through the same reducer the live stream uses, so
101 + * the demo animates exactly like a real debate - including simulated token
102 + * streaming - with play/pause/speed/skip controls.
103 + */
104 +export function useDebatePlayback(result: DebateResult) {
105 + const timeline = useMemo(() => buildTimeline(result), [result]);
106 + const [view, setView] = useState<DebateView>(() => initialDebateView(result.config.question));
107 + const [state, setState] = useState<PlaybackState>('idle');
108 + const [index, setIndex] = useState(0);
109 + const [speed, setSpeed] = useState(1);
110 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
111 +
112 + const clearTimer = () => {
113 + if (timer.current) clearTimeout(timer.current);
114 + timer.current = null;
115 + };
116 +
117 + const play = useCallback(() => {
118 + setState((s) => (s === 'finished' ? 'playing' : 'playing'));
119 + if (state === 'finished') {
120 + setView(initialDebateView(result.config.question));
121 + setIndex(0);
122 + }
123 + }, [state, result.config.question]);
124 +
125 + const pause = useCallback(() => setState('paused'), []);
126 +
127 + const restart = useCallback(() => {
128 + clearTimer();
129 + setView(initialDebateView(result.config.question));
130 + setIndex(0);
131 + setState('playing');
132 + }, [result.config.question]);
133 +
134 + const skipToEnd = useCallback(() => {
135 + clearTimer();
136 + let v = initialDebateView(result.config.question);
137 + for (const t of timeline) v = applyEvent(v, t.event);
138 + setView(v);
139 + setIndex(timeline.length);
140 + setState('finished');
141 + }, [timeline, result.config.question]);
142 +
143 + useEffect(() => {
144 + if (state !== 'playing') return;
145 + if (index >= timeline.length) {
146 + setState('finished');
147 + return;
148 + }
149 + const next = timeline[index]!;
150 + timer.current = setTimeout(
151 + () => {
152 + setView((v) => applyEvent(v, next.event));
153 + setIndex((i) => i + 1);
154 + },
155 + Math.max(8, next.delay / speed),
156 + );
157 + return clearTimer;
158 + }, [state, index, timeline, speed]);
159 +
160 + const progress = timeline.length ? (index / timeline.length) * 100 : 0;
161 +
162 + return { view, state, progress, speed, setSpeed, play, pause, restart, skipToEnd };
163 +}
added src/hooks/use-debate-stream.ts +116 −0
@@ -0,0 +1,116 @@
1 +'use client';
2 +
3 +import { useCallback, useRef, useState } from 'react';
4 +import type { DebateEvent } from '@/core/events';
5 +import type { DebateConfigInput } from '@/core/schemas';
6 +import { applyEvent, initialDebateView, type DebateView } from '@/lib/debate-view';
7 +
8 +export type StreamPhase = 'idle' | 'connecting' | 'streaming' | 'done' | 'error';
9 +
10 +/**
11 + * Drives a live debate: POSTs the config, reads the SSE body, and folds every
12 + * frame through the shared reducer. The debate continues server-side even if
13 + * this component unmounts (see debate-runner) - the returned `debateId` can be
14 + * used to reload state on reconnect.
15 + */
16 +export function useDebateStream() {
17 + const [view, setView] = useState<DebateView>(() => initialDebateView());
18 + const [phase, setPhase] = useState<StreamPhase>('idle');
19 + const [errorMsg, setErrorMsg] = useState<string | null>(null);
20 + const abortRef = useRef<AbortController | null>(null);
21 +
22 + const handleFrame = useCallback((frame: string) => {
23 + let eventName = '';
24 + let dataLine = '';
25 + for (const line of frame.split('\n')) {
26 + if (line.startsWith('event:')) eventName = line.slice(6).trim();
27 + else if (line.startsWith('data:')) dataLine += line.slice(5).trim();
28 + }
29 + if (!dataLine) return;
30 +
31 + if (eventName === 'ready') {
32 + try {
33 + const d = JSON.parse(dataLine) as { debateId: string };
34 + setView((v) => ({ ...v, debateId: d.debateId }));
35 + } catch {
36 + /* ignore */
37 + }
38 + return;
39 + }
40 + if (eventName === 'error') {
41 + try {
42 + setErrorMsg((JSON.parse(dataLine) as { message?: string }).message ?? 'Debate failed');
43 + } catch {
44 + setErrorMsg('Debate failed');
45 + }
46 + setPhase('error');
47 + return;
48 + }
49 + try {
50 + const ev = JSON.parse(dataLine) as DebateEvent;
51 + setView((v) => applyEvent(v, ev));
52 + } catch {
53 + /* skip malformed frame */
54 + }
55 + }, []);
56 +
57 + const start = useCallback(
58 + async (config: DebateConfigInput) => {
59 + abortRef.current?.abort();
60 + const controller = new AbortController();
61 + abortRef.current = controller;
62 + setView(initialDebateView(config.question));
63 + setErrorMsg(null);
64 + setPhase('connecting');
65 +
66 + try {
67 + const res = await fetch('/api/debates/run', {
68 + method: 'POST',
69 + headers: { 'Content-Type': 'application/json' },
70 + body: JSON.stringify(config),
71 + signal: controller.signal,
72 + });
73 +
74 + if (!res.ok || !res.body) {
75 + const err = (await res.json().catch(() => null)) as { error?: string; code?: string } | null;
76 + setErrorMsg(err?.error ?? `Request failed (${res.status})`);
77 + setPhase('error');
78 + return;
79 + }
80 +
81 + setPhase('streaming');
82 + const reader = res.body.getReader();
83 + const decoder = new TextDecoder();
84 + let buffer = '';
85 + for (;;) {
86 + const { done, value } = await reader.read();
87 + if (done) break;
88 + buffer += decoder.decode(value, { stream: true });
89 + const frames = buffer.split('\n\n');
90 + buffer = frames.pop() ?? '';
91 + for (const frame of frames) if (frame.trim() && !frame.startsWith(':')) handleFrame(frame);
92 + }
93 + setPhase((p) => (p === 'error' ? p : 'done'));
94 + } catch (err) {
95 + if ((err as Error).name === 'AbortError') return;
96 + setErrorMsg((err as Error).message);
97 + setPhase('error');
98 + }
99 + },
100 + [handleFrame],
101 + );
102 +
103 + const cancel = useCallback(() => {
104 + abortRef.current?.abort();
105 + setPhase((p) => (p === 'streaming' || p === 'connecting' ? 'idle' : p));
106 + }, []);
107 +
108 + const reset = useCallback(() => {
109 + abortRef.current?.abort();
110 + setView(initialDebateView());
111 + setErrorMsg(null);
112 + setPhase('idle');
113 + }, []);
114 +
115 + return { view, phase, errorMsg, start, cancel, reset };
116 +}
added src/hooks/use-models.ts +45 −0
@@ -0,0 +1,45 @@
1 +'use client';
2 +
3 +import { useEffect, useState } from 'react';
4 +
5 +export interface ModelInfo {
6 + id: string;
7 + name: string;
8 + contextLength: number | null;
9 + promptPrice: number;
10 + completionPrice: number;
11 +}
12 +
13 +let cache: ModelInfo[] | null = null;
14 +
15 +/** Fetch the OpenRouter model catalog once and share it across the session. */
16 +export function useModels() {
17 + const [models, setModels] = useState<ModelInfo[]>(cache ?? []);
18 + const [loading, setLoading] = useState(!cache);
19 +
20 + useEffect(() => {
21 + if (cache) return;
22 + let alive = true;
23 + fetch('/api/models')
24 + .then((r) => r.json())
25 + .then((data: { models: ModelInfo[] }) => {
26 + if (!alive) return;
27 + cache = data.models;
28 + setModels(data.models);
29 + })
30 + .catch(() => {})
31 + .finally(() => alive && setLoading(false));
32 + return () => {
33 + alive = false;
34 + };
35 + }, []);
36 +
37 + return { models, loading };
38 +}
39 +
40 +export function pricePerMillion(perToken: number): string {
41 + const v = perToken * 1_000_000;
42 + if (v === 0) return 'free';
43 + if (v < 1) return `$${v.toFixed(2)}/M`;
44 + return `$${v.toFixed(2)}/M`;
45 +}