Commit
ui and styling
commit
835d4ec
55 changed files with +3547 and −0
Jump to a changed file
- src/app/debate/[id]/page.tsx +33 −0
- src/app/debate/page.tsx +11 −0
- src/app/demo/[id]/page.tsx +28 −0
- src/app/demo/page.tsx +70 −0
- src/app/globals.css +108 −0
- src/app/history/page.tsx +67 −0
- src/app/layout.tsx +34 −0
- src/app/page.tsx +80 −0
- src/app/r/[token]/page.tsx +31 −0
- src/app/signin/page.tsx +50 −0
- src/components/api-key-dialog.tsx +199 −0
- src/components/debate/cost-breakdown.tsx +79 −0
- src/components/debate/cost-meter.tsx +44 −0
- src/components/debate/critique-matrix.tsx +146 −0
- src/components/debate/debate-actions.tsx +36 −0
- src/components/debate/debate-console.tsx +217 −0
- src/components/debate/debate-player.tsx +55 −0
- src/components/debate/debate-replay.tsx +10 −0
- src/components/debate/disagreements.tsx +53 −0
- src/components/debate/final-answer.tsx +92 −0
- src/components/debate/model-combobox.tsx +78 −0
- src/components/debate/model-panel.tsx +99 −0
- src/components/debate/model-picker.tsx +91 −0
- src/components/debate/new-debate.tsx +307 −0
- src/components/debate/revision-diff.tsx +90 −0
- src/components/debate/rich-text.tsx +38 −0
- src/components/debate/stage-timeline.tsx +127 −0
- src/components/history-list.tsx +105 −0
- src/components/key-button.tsx +38 −0
- src/components/landing-hero.tsx +82 −0
- src/components/site-header.tsx +78 −0
- src/components/theme-provider.tsx +8 −0
- src/components/theme-toggle.tsx +23 −0
- src/components/ui/badge.tsx +30 −0
- src/components/ui/button.tsx +43 −0
- src/components/ui/card.tsx +48 −0
- src/components/ui/collapsible.tsx +9 −0
- src/components/ui/dialog.tsx +86 −0
- src/components/ui/input.tsx +19 −0
- src/components/ui/label.tsx +19 −0
- src/components/ui/popover.tsx +30 −0
- src/components/ui/progress.tsx +24 −0
- src/components/ui/scroll-area.tsx +26 −0
- src/components/ui/select.tsx +79 −0
- src/components/ui/separator.tsx +25 −0
- src/components/ui/skeleton.tsx +7 −0
- src/components/ui/slider.tsx +24 −0
- src/components/ui/sonner.tsx +25 −0
- src/components/ui/switch.tsx +24 −0
- src/components/ui/tabs.tsx +51 −0
- src/components/ui/textarea.tsx +18 −0
- src/components/ui/tooltip.tsx +29 −0
- src/hooks/use-debate-playback.ts +163 −0
- src/hooks/use-debate-stream.ts +116 −0
- 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 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 | +} |