Commit
Make Roundtable easier to use and share
commit
4a53203
26 changed files with +1485 and −769
Jump to a changed file
- src/app/debate/page.tsx +6 −4
- src/app/demo/page.tsx +44 −31
- src/app/globals.css +51 −41
- src/app/history/page.tsx +16 −10
- src/app/icon.svg +1 −1
- src/app/layout.tsx +14 −16
- src/app/manifest.ts +4 −3
- src/app/page.tsx +169 −81
- src/app/r/[token]/opengraph-image.tsx +182 −0
- src/app/r/[token]/page.tsx +86 −14
- src/app/signin/page.tsx +9 −4
- src/components/api-key-dialog.tsx +25 −14
- src/components/debate/debate-actions.tsx +49 −13
- src/components/debate/debate-console.tsx +48 −23
- src/components/debate/disagreements.tsx +9 −3
- src/components/debate/final-answer.tsx +107 −88
- src/components/debate/new-debate.tsx +344 −227
- src/components/debate/stage-timeline.tsx +16 −10
- src/components/decision-starter.tsx +71 −0
- src/components/history-list.tsx +11 −6
- src/components/key-button.tsx +15 −4
- src/components/landing-background.tsx +5 −34
- src/components/landing-hero.tsx +163 −132
- src/components/site-header.tsx +13 −9
- src/lib/question-examples.ts +26 −0
- tailwind.config.ts +1 −1
modified src/app/debate/page.tsx +6 −4
| @@ -5,12 +5,14 @@export const dynamic = 'force-dynamic'; | ||
| 5 | 5 | export default async function DebatePage({ |
| 6 | 6 | searchParams, |
| 7 | 7 | }: { |
| 8 | - searchParams: Promise<{ from?: string }>; | |
| 8 | + searchParams: Promise<{ from?: string | string[]; q?: string | string[] }>; | |
| 9 | 9 | }) { |
| 10 | - const { from } = await searchParams; | |
| 10 | + const { from, q } = await searchParams; | |
| 11 | + const fromDebateId = Array.isArray(from) ? from[0] : from; | |
| 12 | + const initialQuestion = Array.isArray(q) ? q[0] : q; | |
| 11 | 13 | return ( |
| 12 | - <div className="container py-8"> | |
| 13 | - <NewDebate fromDebateId={from} /> | |
| 14 | + <div className="container py-8 sm:py-12"> | |
| 15 | + <NewDebate fromDebateId={fromDebateId} initialQuestion={initialQuestion} /> | |
| 14 | 16 | </div> |
| 15 | 17 | ); |
| 16 | 18 | } |
modified src/app/demo/page.tsx +44 −31
| @@ -1,5 +1,6 @@ | ||
| 1 | -import { ArrowRight } from 'lucide-react'; | |
| 1 | +import { ArrowRight, PlayCircle, UsersRound } from 'lucide-react'; | |
| 2 | 2 | import Link from 'next/link'; |
| 3 | +import { Badge } from '@/components/ui/badge'; | |
| 3 | 4 | import { Button } from '@/components/ui/button'; |
| 4 | 5 | import { displayNameForModel } from '@/core/types'; |
| 5 | 6 | import { listDemoDebates } from '@/db/repositories'; |
| @@ -16,51 +17,63 @@export default async function DemoPage() { | ||
| 16 | 17 | } |
| 17 | 18 | |
| 18 | 19 | return ( |
| 19 | - <div className="container max-w-3xl py-12 sm:py-16"> | |
| 20 | - <div className="space-y-3 pb-10"> | |
| 21 | - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">recorded debates</p> | |
| 22 | - <h1 className="font-display text-3xl font-medium tracking-tight sm:text-4xl"> | |
| 23 | - Watch a council <em className="text-primary">argue</em>. | |
| 20 | + <div className="container max-w-5xl py-12 sm:py-16"> | |
| 21 | + <div className="mx-auto max-w-2xl pb-12 text-center"> | |
| 22 | + <Badge variant="secondary" className="mb-4 gap-1.5 rounded-full"> | |
| 23 | + <PlayCircle className="h-3.5 w-3.5 text-primary" /> | |
| 24 | + Example results | |
| 25 | + </Badge> | |
| 26 | + <h1 className="font-display text-4xl font-semibold tracking-[-0.04em] sm:text-5xl"> | |
| 27 | + See how a better answer takes shape. | |
| 24 | 28 | </h1> |
| 25 | - <p className="max-w-xl text-sm leading-relaxed text-muted-foreground"> | |
| 26 | - Real multi-model debates, recorded and replayed through the full UI - streaming, critique matrix, revision | |
| 27 | - diffs, and all. No account or API key needed. | |
| 29 | + <p className="mx-auto mt-4 max-w-xl leading-relaxed text-muted-foreground"> | |
| 30 | + Replay real roundtables from the first independent opinions through the final verdict. No | |
| 31 | + account or model connection is needed. | |
| 28 | 32 | </p> |
| 29 | 33 | </div> |
| 30 | 34 | |
| 31 | 35 | {demos.length === 0 ? ( |
| 32 | - <div className="border-t py-10 text-sm text-muted-foreground"> | |
| 33 | - No demo debates seeded yet. Run <code className="font-mono">pnpm db:seed</code> to add them. | |
| 36 | + <div className="rounded-2xl border border-dashed p-10 text-center text-sm text-muted-foreground"> | |
| 37 | + No examples are available on this deployment yet. | |
| 34 | 38 | </div> |
| 35 | 39 | ) : ( |
| 36 | - <ul className="divide-y border-t"> | |
| 37 | - {demos.map((d) => ( | |
| 38 | - <li key={d.id}> | |
| 39 | - <Link href={`/demo/${d.id}`} className="group flex items-center gap-6 py-7"> | |
| 40 | - <div className="min-w-0 flex-1"> | |
| 41 | - <h2 className="font-display text-xl font-medium leading-snug tracking-tight sm:text-2xl"> | |
| 42 | - {d.question} | |
| 43 | - </h2> | |
| 44 | - <p className="mt-2 font-mono text-xs text-muted-foreground"> | |
| 45 | - {d.models.map(displayNameForModel).join(' · ')} | |
| 46 | - <span className="text-muted-foreground/60"> | |
| 47 | - {' '} | |
| 48 | - · {d.roundsCompleted} rounds · {formatUsd(d.totalCostUsd)} | |
| 49 | - </span> | |
| 40 | + <ul className="grid gap-4 md:grid-cols-2"> | |
| 41 | + {demos.map((demo) => ( | |
| 42 | + <li key={demo.id}> | |
| 43 | + <Link | |
| 44 | + href={`/demo/${demo.id}`} | |
| 45 | + className="group flex h-full flex-col rounded-2xl border bg-card p-5 transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-lg hover:shadow-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:p-6" | |
| 46 | + > | |
| 47 | + <div className="flex items-center justify-between"> | |
| 48 | + <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> | |
| 49 | + <UsersRound className="h-3.5 w-3.5" /> | |
| 50 | + {demo.models.length} opinions | |
| 51 | + </span> | |
| 52 | + <span className="flex h-9 w-9 items-center justify-center rounded-full border text-muted-foreground transition-colors group-hover:border-primary group-hover:bg-primary group-hover:text-primary-foreground"> | |
| 53 | + <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" /> | |
| 54 | + </span> | |
| 55 | + </div> | |
| 56 | + <h2 className="mt-8 font-display text-xl font-semibold leading-snug tracking-[-0.025em] sm:text-2xl"> | |
| 57 | + {demo.question} | |
| 58 | + </h2> | |
| 59 | + <div className="mt-auto pt-8 text-xs text-muted-foreground"> | |
| 60 | + <p className="truncate">{demo.models.map(displayNameForModel).join(' · ')}</p> | |
| 61 | + <p className="mt-1"> | |
| 62 | + {demo.roundsCompleted} review rounds · {formatUsd(demo.totalCostUsd)} | |
| 50 | 63 | </p> |
| 51 | 64 | </div> |
| 52 | - <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border text-muted-foreground transition-colors group-hover:border-primary group-hover:text-primary"> | |
| 53 | - <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" /> | |
| 54 | - </span> | |
| 55 | 65 | </Link> |
| 56 | 66 | </li> |
| 57 | 67 | ))} |
| 58 | 68 | </ul> |
| 59 | 69 | )} |
| 60 | 70 | |
| 61 | - <div className="border-t pt-8"> | |
| 62 | - <Button asChild> | |
| 63 | - <Link href="/debate">Run your own debate</Link> | |
| 71 | + <div className="mt-10 flex justify-center"> | |
| 72 | + <Button size="lg" asChild className="rounded-xl"> | |
| 73 | + <Link href="/debate"> | |
| 74 | + Ask your own question | |
| 75 | + <ArrowRight className="h-4 w-4" /> | |
| 76 | + </Link> | |
| 64 | 77 | </Button> |
| 65 | 78 | </div> |
| 66 | 79 | </div> |
modified src/app/globals.css +51 −41
| @@ -4,64 +4,64 @@ | ||
| 4 | 4 | |
| 5 | 5 | @layer base { |
| 6 | 6 | :root { |
| 7 | - --background: 0 0% 100%; | |
| 8 | - --foreground: 222 47% 11%; | |
| 7 | + --background: 228 33% 98%; | |
| 8 | + --foreground: 231 28% 13%; | |
| 9 | 9 | |
| 10 | 10 | --card: 0 0% 100%; |
| 11 | - --card-foreground: 222 47% 11%; | |
| 11 | + --card-foreground: 231 28% 13%; | |
| 12 | 12 | --popover: 0 0% 100%; |
| 13 | - --popover-foreground: 222 47% 11%; | |
| 13 | + --popover-foreground: 231 28% 13%; | |
| 14 | 14 | |
| 15 | - --primary: 243 75% 59%; | |
| 15 | + --primary: 235 78% 58%; | |
| 16 | 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%; | |
| 17 | + --secondary: 230 24% 94%; | |
| 18 | + --secondary-foreground: 231 28% 16%; | |
| 19 | + --muted: 225 20% 95%; | |
| 20 | + --muted-foreground: 226 11% 43%; | |
| 21 | + --accent: 235 70% 95%; | |
| 22 | + --accent-foreground: 235 70% 38%; | |
| 23 | 23 | --destructive: 0 72% 51%; |
| 24 | 24 | --destructive-foreground: 0 0% 100%; |
| 25 | 25 | |
| 26 | - --border: 220 13% 91%; | |
| 27 | - --input: 220 13% 88%; | |
| 28 | - --ring: 243 75% 59%; | |
| 29 | - --radius: 0.75rem; | |
| 26 | + --border: 228 20% 88%; | |
| 27 | + --input: 228 18% 84%; | |
| 28 | + --ring: 235 78% 58%; | |
| 29 | + --radius: 0.9rem; | |
| 30 | 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%; | |
| 31 | + --stage-answer: 215 91% 58%; | |
| 32 | + --stage-critique: 23 91% 58%; | |
| 33 | + --stage-revision: 276 70% 57%; | |
| 34 | + --stage-synthesis: 157 64% 39%; | |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | .dark { |
| 38 | - --background: 224 32% 8%; | |
| 39 | - --foreground: 210 40% 96%; | |
| 38 | + --background: 230 28% 8%; | |
| 39 | + --foreground: 225 30% 96%; | |
| 40 | 40 | |
| 41 | - --card: 224 28% 11%; | |
| 42 | - --card-foreground: 210 40% 96%; | |
| 43 | - --popover: 224 28% 10%; | |
| 44 | - --popover-foreground: 210 40% 96%; | |
| 41 | + --card: 230 25% 11%; | |
| 42 | + --card-foreground: 225 30% 96%; | |
| 43 | + --popover: 230 25% 10%; | |
| 44 | + --popover-foreground: 225 30% 96%; | |
| 45 | 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%; | |
| 46 | + --primary: 234 86% 69%; | |
| 47 | + --primary-foreground: 230 28% 8%; | |
| 48 | + --secondary: 229 20% 18%; | |
| 49 | + --secondary-foreground: 225 30% 96%; | |
| 50 | + --muted: 229 20% 16%; | |
| 51 | + --muted-foreground: 225 14% 65%; | |
| 52 | + --accent: 234 38% 22%; | |
| 53 | + --accent-foreground: 234 90% 86%; | |
| 54 | 54 | --destructive: 0 63% 50%; |
| 55 | 55 | --destructive-foreground: 0 0% 100%; |
| 56 | 56 | |
| 57 | - --border: 222 20% 20%; | |
| 58 | - --input: 222 20% 22%; | |
| 59 | - --ring: 243 80% 67%; | |
| 57 | + --border: 229 19% 21%; | |
| 58 | + --input: 229 19% 24%; | |
| 59 | + --ring: 234 86% 69%; | |
| 60 | 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%; | |
| 61 | + --stage-answer: 215 91% 66%; | |
| 62 | + --stage-critique: 23 91% 64%; | |
| 63 | + --stage-revision: 276 74% 70%; | |
| 64 | + --stage-synthesis: 157 62% 49%; | |
| 65 | 65 | } |
| 66 | 66 | } |
| 67 | 67 | |
| @@ -71,9 +71,14 @@ | ||
| 71 | 71 | } |
| 72 | 72 | body { |
| 73 | 73 | @apply bg-background text-foreground; |
| 74 | - font-feature-settings: 'rlig' 1, 'calt' 1; | |
| 74 | + font-feature-settings: | |
| 75 | + 'rlig' 1, | |
| 76 | + 'calt' 1; | |
| 75 | 77 | -webkit-font-smoothing: antialiased; |
| 76 | 78 | } |
| 79 | + ::selection { | |
| 80 | + background: hsl(var(--primary) / 0.2); | |
| 81 | + } | |
| 77 | 82 | /* Thin, unobtrusive scrollbars for the dense debate panels. */ |
| 78 | 83 | .scrollbar-thin { |
| 79 | 84 | scrollbar-width: thin; |
| @@ -105,6 +110,11 @@ | ||
| 105 | 110 | background-image: radial-gradient(hsl(var(--border)) 1px, transparent 1px); |
| 106 | 111 | background-size: 22px 22px; |
| 107 | 112 | } |
| 113 | + .decision-grid { | |
| 114 | + background-image: linear-gradient(hsl(var(--border) / 0.5) 1px, transparent 1px), | |
| 115 | + linear-gradient(90deg, hsl(var(--border) / 0.5) 1px, transparent 1px); | |
| 116 | + background-size: 44px 44px; | |
| 117 | + } | |
| 108 | 118 | /* Fine film grain for atmospheric depth. */ |
| 109 | 119 | .bg-grain { |
| 110 | 120 | background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>"); |
modified src/app/history/page.tsx +16 −10
| @@ -15,10 +15,12 @@export default async function HistoryPage() { | ||
| 15 | 15 | if (!userId) { |
| 16 | 16 | return ( |
| 17 | 17 | <div className="container flex flex-col items-center gap-4 py-24 text-center"> |
| 18 | - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">history</p> | |
| 19 | - <h1 className="font-display text-3xl font-medium tracking-tight">Every debate, kept.</h1> | |
| 18 | + <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 19 | + past questions | |
| 20 | + </p> | |
| 21 | + <h1 className="font-display text-3xl font-semibold tracking-tight">Keep every verdict.</h1> | |
| 20 | 22 | <p className="max-w-md text-sm leading-relaxed text-muted-foreground"> |
| 21 | - Sign in to keep a searchable, replayable history of every debate you run. | |
| 23 | + Sign in to search, revisit, and share the questions you have put to the table. | |
| 22 | 24 | </p> |
| 23 | 25 | {isGithubAuthConfigured ? ( |
| 24 | 26 | <form |
| @@ -31,12 +33,12 @@export default async function HistoryPage() { | ||
| 31 | 33 | </form> |
| 32 | 34 | ) : ( |
| 33 | 35 | <p className="text-xs text-muted-foreground"> |
| 34 | - GitHub auth is not configured on this deployment. Debates you run are still available via their direct | |
| 35 | - links. | |
| 36 | + GitHub sign-in is not configured on this deployment. Your results are still available | |
| 37 | + from their direct links. | |
| 36 | 38 | </p> |
| 37 | 39 | )} |
| 38 | 40 | <Button variant="outline" asChild> |
| 39 | - <Link href="/debate">Start a debate anyway</Link> | |
| 41 | + <Link href="/debate">Ask a question anyway</Link> | |
| 40 | 42 | </Button> |
| 41 | 43 | </div> |
| 42 | 44 | ); |
| @@ -48,13 +50,17 @@export default async function HistoryPage() { | ||
| 48 | 50 | <div className="container max-w-4xl py-12"> |
| 49 | 51 | <div className="mb-8 flex flex-wrap items-end justify-between gap-3 border-b pb-6"> |
| 50 | 52 | <div className="space-y-1.5"> |
| 51 | - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">history</p> | |
| 52 | - <h1 className="font-display text-3xl font-medium tracking-tight"> | |
| 53 | - {debates.length} {debates.length === 1 ? 'debate' : 'debates'} | |
| 53 | + <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 54 | + past questions | |
| 55 | + </p> | |
| 56 | + <h1 className="font-display text-3xl font-semibold tracking-tight"> | |
| 57 | + {debates.length} {debates.length === 1 ? 'question' : 'questions'} | |
| 54 | 58 | </h1> |
| 55 | 59 | </div> |
| 56 | 60 | <div className="text-right"> |
| 57 | - <div className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">this month</div> | |
| 61 | + <div className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 62 | + this month | |
| 63 | + </div> | |
| 58 | 64 | <div className="mt-1 font-display text-2xl font-medium">{formatUsd(spend)}</div> |
| 59 | 65 | </div> |
| 60 | 66 | </div> |
modified src/app/icon.svg +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> |
| 2 | - <rect width="32" height="32" rx="7" fill="#6366f1" /> | |
| 2 | + <rect width="32" height="32" rx="8" fill="#4f5ee8" /> | |
| 3 | 3 | <circle cx="16" cy="9" r="3" fill="#ffffff" /> |
| 4 | 4 | <circle cx="9" cy="21" r="3" fill="#ffffff" /> |
| 5 | 5 | <circle cx="23" cy="21" r="3" fill="#ffffff" /> |
modified src/app/layout.tsx +14 −16
| @@ -1,50 +1,48 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | -import { Fraunces, Inter, JetBrains_Mono } from 'next/font/google'; | |
| 2 | +import { JetBrains_Mono, Manrope, Sora } from 'next/font/google'; | |
| 3 | 3 | import { SiteHeader } from '@/components/site-header'; |
| 4 | 4 | import { ThemeProvider } from '@/components/theme-provider'; |
| 5 | 5 | import { Toaster } from '@/components/ui/sonner'; |
| 6 | 6 | import { TooltipProvider } from '@/components/ui/tooltip'; |
| 7 | 7 | import './globals.css'; |
| 8 | 8 | |
| 9 | -const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' }); | |
| 9 | +const sans = Manrope({ subsets: ['latin'], variable: '--font-sans', display: 'swap' }); | |
| 10 | 10 | const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' }); |
| 11 | -// Distinctive editorial serif for display headings (landing page). | |
| 12 | -const display = Fraunces({ | |
| 13 | - subsets: ['latin'], | |
| 14 | - variable: '--font-display', | |
| 15 | - display: 'swap', | |
| 16 | - axes: ['opsz', 'SOFT'], | |
| 17 | -}); | |
| 11 | +const display = Sora({ subsets: ['latin'], variable: '--font-display', display: 'swap' }); | |
| 18 | 12 | |
| 19 | 13 | const DESCRIPTION = |
| 20 | - '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.'; | |
| 14 | + 'Ask several leading AI models one question. They challenge each other, improve their answers, and give you one clear verdict with any remaining disagreement.'; | |
| 21 | 15 | |
| 22 | 16 | export const metadata: Metadata = { |
| 23 | 17 | metadataBase: new URL(process.env.AUTH_URL || 'http://localhost:3000'), |
| 24 | 18 | title: { |
| 25 | - default: 'Roundtable - multi-model LLM debate', | |
| 26 | - template: '%s · Roundtable', | |
| 19 | + default: 'Roundtable | Several AI opinions, one clear verdict', | |
| 20 | + template: '%s | Roundtable', | |
| 27 | 21 | }, |
| 28 | 22 | description: DESCRIPTION, |
| 29 | 23 | applicationName: 'Roundtable', |
| 30 | 24 | openGraph: { |
| 31 | - title: 'Roundtable - multi-model LLM debate', | |
| 25 | + title: 'Roundtable | Several AI opinions, one clear verdict', | |
| 32 | 26 | description: DESCRIPTION, |
| 33 | 27 | type: 'website', |
| 34 | 28 | siteName: 'Roundtable', |
| 35 | 29 | }, |
| 36 | 30 | twitter: { |
| 37 | 31 | card: 'summary_large_image', |
| 38 | - title: 'Roundtable - multi-model LLM debate', | |
| 32 | + title: 'Roundtable | Several AI opinions, one clear verdict', | |
| 39 | 33 | description: DESCRIPTION, |
| 40 | 34 | }, |
| 41 | 35 | }; |
| 42 | 36 | |
| 43 | 37 | export default function RootLayout({ children }: { children: React.ReactNode }) { |
| 44 | 38 | return ( |
| 45 | - <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable} ${display.variable}`}> | |
| 39 | + <html | |
| 40 | + lang="en" | |
| 41 | + suppressHydrationWarning | |
| 42 | + className={`${sans.variable} ${mono.variable} ${display.variable}`} | |
| 43 | + > | |
| 46 | 44 | <body className="min-h-screen font-sans antialiased"> |
| 47 | - <ThemeProvider attribute="class" defaultTheme="dark" enableSystem> | |
| 45 | + <ThemeProvider attribute="class" defaultTheme="system" enableSystem> | |
| 48 | 46 | <TooltipProvider delayDuration={200}> |
| 49 | 47 | <div className="relative flex min-h-screen flex-col"> |
| 50 | 48 | <SiteHeader /> |
modified src/app/manifest.ts +4 −3
| @@ -4,11 +4,12 @@export default function manifest(): MetadataRoute.Manifest { | ||
| 4 | 4 | return { |
| 5 | 5 | name: 'Roundtable', |
| 6 | 6 | short_name: 'Roundtable', |
| 7 | - description: 'A council of LLMs that debate over several rounds before producing a synthesized answer.', | |
| 7 | + description: | |
| 8 | + 'Ask several AI models one question and get one checked verdict with any disagreement still visible.', | |
| 8 | 9 | start_url: '/', |
| 9 | 10 | display: 'standalone', |
| 10 | - background_color: '#0b1020', | |
| 11 | - theme_color: '#6366f1', | |
| 11 | + background_color: '#f8f9fc', | |
| 12 | + theme_color: '#4f5ee8', | |
| 12 | 13 | icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }], |
| 13 | 14 | }; |
| 14 | 15 | } |
modified src/app/page.tsx +169 −81
| @@ -1,20 +1,49 @@ | ||
| 1 | -import { ArrowRight, Gavel, GitCompareArrows, MessagesSquare, PencilLine, Scale, Users } from 'lucide-react'; | |
| 1 | +import { | |
| 2 | + ArrowRight, | |
| 3 | + CheckCircle2, | |
| 4 | + GitCompareArrows, | |
| 5 | + MessageCircleQuestion, | |
| 6 | + MessagesSquare, | |
| 7 | + Scale, | |
| 8 | + Sparkles, | |
| 9 | +} from 'lucide-react'; | |
| 2 | 10 | import Link from 'next/link'; |
| 3 | 11 | import { LandingBackground } from '@/components/landing-background'; |
| 4 | 12 | import { LandingHero } from '@/components/landing-hero'; |
| 5 | 13 | import { Reveal } from '@/components/reveal'; |
| 6 | 14 | import { Button } from '@/components/ui/button'; |
| 7 | 15 | import type { DebateResult } from '@/core/types'; |
| 8 | 16 | import { listDemoDebates, loadDebateResult } from '@/db/repositories'; |
| 17 | +import { QUESTION_EXAMPLES } from '@/lib/question-examples'; | |
| 9 | 18 | |
| 10 | 19 | export const dynamic = 'force-dynamic'; |
| 11 | 20 | |
| 12 | 21 | const STEPS = [ |
| 13 | - { icon: MessagesSquare, title: 'Answer', desc: 'Each model answers independently, in parallel.', color: 'var(--stage-answer)' }, | |
| 14 | - { icon: Scale, title: 'Critique', desc: 'They score each other, blind to authorship.', color: 'var(--stage-critique)' }, | |
| 15 | - { icon: PencilLine, title: 'Revise', desc: 'Fix what the critiques got right, or defend it.', color: 'var(--stage-revision)' }, | |
| 16 | - { icon: Users, title: 'Converge', desc: 'A fast model checks how far apart they still are.', color: 'var(--stage-answer)' }, | |
| 17 | - { icon: Gavel, title: 'Synthesize', desc: 'A chairman writes the verdict and the dissent.', color: 'var(--stage-synthesis)' }, | |
| 22 | + { | |
| 23 | + icon: MessageCircleQuestion, | |
| 24 | + title: 'Ask one clear question', | |
| 25 | + description: | |
| 26 | + 'Add your options, priorities, and limits. Roundtable chooses a balanced set of models for you.', | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + icon: MessagesSquare, | |
| 30 | + title: 'Hear independent views', | |
| 31 | + description: | |
| 32 | + 'Each model answers on its own before seeing the others, so the first opinion cannot sway the room.', | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + icon: Scale, | |
| 36 | + title: 'Get a checked verdict', | |
| 37 | + description: | |
| 38 | + 'They challenge weak points, revise their answers, and combine the strongest reasoning into one result.', | |
| 39 | + }, | |
| 40 | +]; | |
| 41 | + | |
| 42 | +const REASONS = [ | |
| 43 | + 'One clear recommendation comes first', | |
| 44 | + 'Remaining disagreements stay visible', | |
| 45 | + 'Every original opinion can be inspected', | |
| 46 | + 'Cost is estimated before anything runs', | |
| 18 | 47 | ]; |
| 19 | 48 | |
| 20 | 49 | export default async function HomePage() { |
| @@ -27,108 +56,167 @@export default async function HomePage() { | ||
| 27 | 56 | } |
| 28 | 57 | |
| 29 | 58 | return ( |
| 30 | - <div> | |
| 59 | + <div className="overflow-hidden"> | |
| 31 | 60 | <LandingBackground /> |
| 32 | - {demo ? <LandingHero result={demo} /> : <MinimalHero />} | |
| 61 | + <LandingHero result={demo} /> | |
| 33 | 62 | |
| 34 | - {/* The five moves of a debate, as a colored pipeline (no boxes). */} | |
| 35 | - <section className="container border-t py-16 sm:py-24"> | |
| 36 | - <Reveal className="mx-auto max-w-2xl text-center"> | |
| 37 | - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">how a debate unfolds</p> | |
| 38 | - <h2 className="mt-3 font-display text-3xl font-medium tracking-tight sm:text-4xl"> | |
| 39 | - Five moves from question to verdict | |
| 40 | - </h2> | |
| 41 | - </Reveal> | |
| 42 | - <ol className="relative mx-auto mt-14 grid max-w-5xl gap-y-10 sm:grid-cols-5 sm:gap-x-4"> | |
| 43 | - <div | |
| 44 | - aria-hidden | |
| 45 | - className="absolute left-[10%] right-[10%] top-6 hidden h-px bg-gradient-to-r from-stage-answer/50 via-stage-revision/50 to-stage-synthesis/50 sm:block" | |
| 46 | - /> | |
| 47 | - {STEPS.map((step, i) => ( | |
| 48 | - <li key={step.title} className="relative flex flex-col items-center px-2 text-center"> | |
| 49 | - <span | |
| 50 | - className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 bg-background" | |
| 51 | - style={{ borderColor: `hsl(${step.color})`, color: `hsl(${step.color})` }} | |
| 63 | + <section className="container py-16 sm:py-24"> | |
| 64 | + <Reveal className="grid gap-8 lg:grid-cols-[0.7fr_1.3fr] lg:gap-16"> | |
| 65 | + <div> | |
| 66 | + <p className="text-xs font-bold uppercase tracking-[0.18em] text-primary"> | |
| 67 | + Made for real decisions | |
| 68 | + </p> | |
| 69 | + <h2 className="mt-3 max-w-md font-display text-3xl font-semibold tracking-[-0.035em] sm:text-4xl"> | |
| 70 | + Bring the messy question. | |
| 71 | + </h2> | |
| 72 | + <p className="mt-4 max-w-md leading-relaxed text-muted-foreground"> | |
| 73 | + Roundtable is most useful when there is no obvious answer, several tradeoffs matter, | |
| 74 | + and you want a recommendation you can inspect. | |
| 75 | + </p> | |
| 76 | + </div> | |
| 77 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 78 | + {QUESTION_EXAMPLES.map((example) => ( | |
| 79 | + <Link | |
| 80 | + key={example.category} | |
| 81 | + href={{ pathname: '/debate', query: { q: example.question } }} | |
| 82 | + className="group rounded-2xl border bg-card/70 p-5 transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:bg-card hover:shadow-lg hover:shadow-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 52 | 83 | > |
| 53 | - <step.icon className="h-5 w-5" strokeWidth={1.75} /> | |
| 54 | - </span> | |
| 55 | - <span className="mt-4 font-mono text-[10px] uppercase tracking-widest text-muted-foreground/70"> | |
| 56 | - Step {i + 1} | |
| 84 | + <div className="flex items-center justify-between"> | |
| 85 | + <span className="text-[11px] font-bold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 86 | + {example.category} | |
| 87 | + </span> | |
| 88 | + <ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" /> | |
| 89 | + </div> | |
| 90 | + <h3 className="mt-6 font-display text-xl font-semibold tracking-tight"> | |
| 91 | + {example.short} | |
| 92 | + </h3> | |
| 93 | + <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> | |
| 94 | + {example.question} | |
| 95 | + </p> | |
| 96 | + </Link> | |
| 97 | + ))} | |
| 98 | + </div> | |
| 99 | + </Reveal> | |
| 100 | + </section> | |
| 101 | + | |
| 102 | + <section className="border-y bg-card/45"> | |
| 103 | + <div className="container py-16 sm:py-24"> | |
| 104 | + <Reveal className="mx-auto max-w-2xl text-center"> | |
| 105 | + <p className="text-xs font-bold uppercase tracking-[0.18em] text-primary"> | |
| 106 | + How it works | |
| 107 | + </p> | |
| 108 | + <h2 className="mt-3 font-display text-3xl font-semibold tracking-[-0.035em] sm:text-4xl"> | |
| 109 | + More than three chat tabs. | |
| 110 | + </h2> | |
| 111 | + <p className="mt-4 text-muted-foreground"> | |
| 112 | + Roundtable keeps the opinions independent, then makes the models test one another | |
| 113 | + before you see the final answer. | |
| 114 | + </p> | |
| 115 | + </Reveal> | |
| 116 | + | |
| 117 | + <ol className="mx-auto mt-12 grid max-w-6xl gap-4 md:grid-cols-3"> | |
| 118 | + {STEPS.map((step, index) => ( | |
| 119 | + <Reveal key={step.title} delay={index * 90}> | |
| 120 | + <li className="relative h-full rounded-2xl border bg-background p-6"> | |
| 121 | + <div className="flex items-center justify-between"> | |
| 122 | + <span className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"> | |
| 123 | + <step.icon className="h-5 w-5" /> | |
| 124 | + </span> | |
| 125 | + <span className="font-mono text-xs text-muted-foreground/60">0{index + 1}</span> | |
| 126 | + </div> | |
| 127 | + <h3 className="mt-8 font-display text-xl font-semibold tracking-tight"> | |
| 128 | + {step.title} | |
| 129 | + </h3> | |
| 130 | + <p className="mt-3 text-sm leading-relaxed text-muted-foreground"> | |
| 131 | + {step.description} | |
| 132 | + </p> | |
| 133 | + </li> | |
| 134 | + </Reveal> | |
| 135 | + ))} | |
| 136 | + </ol> | |
| 137 | + </div> | |
| 138 | + </section> | |
| 139 | + | |
| 140 | + <section className="container py-16 sm:py-24"> | |
| 141 | + <Reveal className="relative overflow-hidden rounded-[2rem] border bg-foreground px-6 py-10 text-background sm:px-10 sm:py-14 lg:px-14"> | |
| 142 | + <div className="absolute right-[-8%] top-[-40%] h-80 w-80 rounded-full bg-primary/40 blur-3xl" /> | |
| 143 | + <div className="relative grid items-center gap-10 lg:grid-cols-[1fr_0.85fr]"> | |
| 144 | + <div> | |
| 145 | + <span className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-[0.18em] text-background/60"> | |
| 146 | + <Sparkles className="h-3.5 w-3.5" /> | |
| 147 | + Clear without hiding the details | |
| 57 | 148 | </span> |
| 58 | - <h3 className="mt-1 font-semibold">{step.title}</h3> | |
| 59 | - <p className="mt-1.5 max-w-[15rem] text-sm leading-relaxed text-muted-foreground">{step.desc}</p> | |
| 60 | - </li> | |
| 61 | - ))} | |
| 62 | - </ol> | |
| 149 | + <h2 className="mt-4 max-w-xl font-display text-3xl font-semibold tracking-[-0.035em] sm:text-5xl"> | |
| 150 | + A verdict you can question, share, and act on. | |
| 151 | + </h2> | |
| 152 | + <p className="mt-5 max-w-xl leading-relaxed text-background/70"> | |
| 153 | + Most AI gives you a confident answer and asks you to trust it. Roundtable keeps the | |
| 154 | + reasoning, changes, and dissent attached to the result. | |
| 155 | + </p> | |
| 156 | + </div> | |
| 157 | + <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1"> | |
| 158 | + {REASONS.map((reason) => ( | |
| 159 | + <li | |
| 160 | + key={reason} | |
| 161 | + className="flex items-center gap-3 rounded-xl border border-background/15 bg-background/5 p-3.5 text-sm" | |
| 162 | + > | |
| 163 | + <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-400" /> | |
| 164 | + {reason} | |
| 165 | + </li> | |
| 166 | + ))} | |
| 167 | + </ul> | |
| 168 | + </div> | |
| 169 | + </Reveal> | |
| 63 | 170 | </section> |
| 64 | 171 | |
| 65 | - <section className="container border-t py-24 sm:py-32"> | |
| 66 | - <Reveal className="mx-auto max-w-3xl text-center"> | |
| 67 | - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 68 | - your question, deliberated | |
| 172 | + <section className="container pb-20 pt-6 text-center sm:pb-28 sm:pt-10"> | |
| 173 | + <Reveal className="mx-auto max-w-3xl"> | |
| 174 | + <p className="text-xs font-bold uppercase tracking-[0.18em] text-primary"> | |
| 175 | + Your next decision | |
| 69 | 176 | </p> |
| 70 | - <h2 className="mx-auto mt-4 max-w-2xl font-display text-4xl font-medium leading-[1.04] tracking-tight sm:text-6xl"> | |
| 71 | - Stop trusting one model's <em className="text-primary">first guess</em>. | |
| 177 | + <h2 className="mt-4 font-display text-4xl font-semibold leading-tight tracking-[-0.04em] sm:text-6xl"> | |
| 178 | + Put it on the table. | |
| 72 | 179 | </h2> |
| 73 | - <p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-muted-foreground"> | |
| 74 | - Convene a council, watch them reason in the open, and get an answer you can actually inspect. | |
| 180 | + <p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-muted-foreground"> | |
| 181 | + Start with smart defaults, or open the controls when you want to choose every model and | |
| 182 | + rule yourself. | |
| 75 | 183 | </p> |
| 76 | - <div className="mt-9 flex flex-wrap items-center justify-center gap-3"> | |
| 77 | - <Button size="lg" asChild className="group"> | |
| 184 | + <div className="mt-8 flex flex-wrap items-center justify-center gap-3"> | |
| 185 | + <Button size="lg" asChild className="rounded-xl px-7"> | |
| 78 | 186 | <Link href="/debate"> |
| 79 | - Start a debate | |
| 80 | - <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" /> | |
| 187 | + Ask a question | |
| 188 | + <ArrowRight className="h-4 w-4" /> | |
| 81 | 189 | </Link> |
| 82 | 190 | </Button> |
| 83 | 191 | <Button size="lg" variant="ghost" asChild> |
| 84 | - <Link href="/demo">Watch a recording</Link> | |
| 192 | + <Link href="/demo">See example results</Link> | |
| 85 | 193 | </Button> |
| 86 | 194 | </div> |
| 87 | 195 | </Reveal> |
| 88 | 196 | </section> |
| 89 | 197 | |
| 90 | 198 | <footer className="border-t"> |
| 91 | - <div className="container flex flex-col items-center justify-between gap-4 py-10 text-sm text-muted-foreground sm:flex-row"> | |
| 92 | - <span className="flex items-center gap-2 font-medium text-foreground"> | |
| 93 | - <GitCompareArrows className="h-4 w-4 text-primary" /> Roundtable | |
| 94 | - </span> | |
| 199 | + <div className="container flex flex-col items-center justify-between gap-5 py-9 text-sm text-muted-foreground sm:flex-row"> | |
| 200 | + <Link | |
| 201 | + href="/" | |
| 202 | + className="flex items-center gap-2 font-display font-semibold text-foreground" | |
| 203 | + > | |
| 204 | + <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground"> | |
| 205 | + <GitCompareArrows className="h-4 w-4" /> | |
| 206 | + </span> | |
| 207 | + Roundtable | |
| 208 | + </Link> | |
| 95 | 209 | <nav className="flex items-center gap-6"> |
| 96 | 210 | <Link href="/debate" className="transition-colors hover:text-foreground"> |
| 97 | - New debate | |
| 211 | + Ask | |
| 98 | 212 | </Link> |
| 99 | 213 | <Link href="/demo" className="transition-colors hover:text-foreground"> |
| 100 | - Demo | |
| 214 | + Examples | |
| 101 | 215 | </Link> |
| 102 | 216 | </nav> |
| 103 | - <span className="text-xs">Next.js · Prisma · OpenRouter · Vercel AI SDK</span> | |
| 217 | + <span className="text-xs">Several opinions. One clear verdict.</span> | |
| 104 | 218 | </div> |
| 105 | 219 | </footer> |
| 106 | 220 | </div> |
| 107 | 221 | ); |
| 108 | 222 | } |
| 109 | - | |
| 110 | -/** Shown only when no demo debate has been seeded yet. */ | |
| 111 | -function MinimalHero() { | |
| 112 | - return ( | |
| 113 | - <section className="container max-w-2xl py-28 text-center"> | |
| 114 | - <h1 className="font-display text-4xl font-medium tracking-tight sm:text-6xl"> | |
| 115 | - Convene a <em className="text-primary">roundtable</em>. | |
| 116 | - </h1> | |
| 117 | - <p className="mx-auto mt-5 max-w-xl text-lg text-muted-foreground"> | |
| 118 | - A council of models answers your question, critiques each other blind, and revises across rounds. A chairman | |
| 119 | - writes the final answer and an honest account of where they still disagree. | |
| 120 | - </p> | |
| 121 | - <div className="mt-8 flex justify-center gap-3"> | |
| 122 | - <Button size="lg" asChild> | |
| 123 | - <Link href="/debate">Start a debate</Link> | |
| 124 | - </Button> | |
| 125 | - <Button size="lg" variant="ghost" asChild> | |
| 126 | - <Link href="/demo">Watch a recorded one</Link> | |
| 127 | - </Button> | |
| 128 | - </div> | |
| 129 | - <p className="mt-5 text-sm text-muted-foreground"> | |
| 130 | - No demo is seeded on this deployment yet. Run <code className="font-mono">pnpm db:seed</code> to add one. | |
| 131 | - </p> | |
| 132 | - </section> | |
| 133 | - ); | |
| 134 | -} |
added src/app/r/[token]/opengraph-image.tsx +182 −0
| @@ -0,0 +1,182 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { loadDebateByShareToken } from '@/db/repositories'; | |
| 3 | +import { truncate } from '@/lib/utils'; | |
| 4 | + | |
| 5 | +export const alt = 'A shared Roundtable verdict'; | |
| 6 | +export const size = { width: 1200, height: 630 }; | |
| 7 | +export const contentType = 'image/png'; | |
| 8 | +export const dynamic = 'force-dynamic'; | |
| 9 | + | |
| 10 | +export default async function SharedVerdictImage({ | |
| 11 | + params, | |
| 12 | +}: { | |
| 13 | + params: Promise<{ token: string }>; | |
| 14 | +}) { | |
| 15 | + const { token } = await params; | |
| 16 | + const result = await loadDebateByShareToken(token).catch(() => null); | |
| 17 | + const question = truncate(result?.config.question ?? 'A question put to the Roundtable', 160); | |
| 18 | + const verdict = truncate( | |
| 19 | + result?.synthesis?.finalAnswer ?? | |
| 20 | + 'Several independent AI opinions were compared, challenged, and combined into one clear recommendation.', | |
| 21 | + 300, | |
| 22 | + ); | |
| 23 | + const opinionCount = result?.participants.length ?? 3; | |
| 24 | + | |
| 25 | + return new ImageResponse( | |
| 26 | + ( | |
| 27 | + <div | |
| 28 | + style={{ | |
| 29 | + position: 'relative', | |
| 30 | + display: 'flex', | |
| 31 | + height: '100%', | |
| 32 | + width: '100%', | |
| 33 | + overflow: 'hidden', | |
| 34 | + background: '#f7f8fc', | |
| 35 | + color: '#171a2b', | |
| 36 | + fontFamily: 'Arial, sans-serif', | |
| 37 | + padding: '58px 64px', | |
| 38 | + }} | |
| 39 | + > | |
| 40 | + <div | |
| 41 | + style={{ | |
| 42 | + position: 'absolute', | |
| 43 | + right: '-180px', | |
| 44 | + top: '-230px', | |
| 45 | + display: 'flex', | |
| 46 | + height: '620px', | |
| 47 | + width: '620px', | |
| 48 | + border: '2px solid rgba(79, 94, 232, 0.12)', | |
| 49 | + borderRadius: '50%', | |
| 50 | + }} | |
| 51 | + /> | |
| 52 | + <div | |
| 53 | + style={{ | |
| 54 | + position: 'absolute', | |
| 55 | + right: '-80px', | |
| 56 | + top: '-130px', | |
| 57 | + display: 'flex', | |
| 58 | + height: '420px', | |
| 59 | + width: '420px', | |
| 60 | + border: '2px solid rgba(79, 94, 232, 0.12)', | |
| 61 | + borderRadius: '50%', | |
| 62 | + }} | |
| 63 | + /> | |
| 64 | + | |
| 65 | + <div | |
| 66 | + style={{ position: 'relative', display: 'flex', width: '100%', flexDirection: 'column' }} | |
| 67 | + > | |
| 68 | + <div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}> | |
| 69 | + <div | |
| 70 | + style={{ | |
| 71 | + display: 'flex', | |
| 72 | + height: '42px', | |
| 73 | + width: '42px', | |
| 74 | + alignItems: 'center', | |
| 75 | + justifyContent: 'center', | |
| 76 | + borderRadius: '12px', | |
| 77 | + background: '#4f5ee8', | |
| 78 | + color: 'white', | |
| 79 | + fontSize: '20px', | |
| 80 | + fontWeight: 800, | |
| 81 | + }} | |
| 82 | + > | |
| 83 | + R | |
| 84 | + </div> | |
| 85 | + <div style={{ display: 'flex', fontSize: '21px', fontWeight: 800 }}>Roundtable</div> | |
| 86 | + <div | |
| 87 | + style={{ | |
| 88 | + display: 'flex', | |
| 89 | + marginLeft: '10px', | |
| 90 | + borderRadius: '999px', | |
| 91 | + background: '#e9ebf8', | |
| 92 | + color: '#596078', | |
| 93 | + padding: '8px 13px', | |
| 94 | + fontSize: '12px', | |
| 95 | + fontWeight: 700, | |
| 96 | + letterSpacing: '0.08em', | |
| 97 | + }} | |
| 98 | + > | |
| 99 | + SHARED VERDICT | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + <div | |
| 104 | + style={{ | |
| 105 | + display: 'flex', | |
| 106 | + marginTop: '46px', | |
| 107 | + maxWidth: '1000px', | |
| 108 | + fontSize: question.length > 110 ? '38px' : '46px', | |
| 109 | + fontWeight: 800, | |
| 110 | + lineHeight: 1.12, | |
| 111 | + letterSpacing: '-0.035em', | |
| 112 | + }} | |
| 113 | + > | |
| 114 | + {question} | |
| 115 | + </div> | |
| 116 | + | |
| 117 | + <div | |
| 118 | + style={{ | |
| 119 | + display: 'flex', | |
| 120 | + marginTop: 'auto', | |
| 121 | + border: '2px solid rgba(23, 164, 118, 0.2)', | |
| 122 | + borderRadius: '20px', | |
| 123 | + background: '#edf9f4', | |
| 124 | + padding: '22px 26px', | |
| 125 | + }} | |
| 126 | + > | |
| 127 | + <div | |
| 128 | + style={{ | |
| 129 | + display: 'flex', | |
| 130 | + height: '36px', | |
| 131 | + width: '36px', | |
| 132 | + flexShrink: 0, | |
| 133 | + alignItems: 'center', | |
| 134 | + justifyContent: 'center', | |
| 135 | + borderRadius: '10px', | |
| 136 | + background: '#17a476', | |
| 137 | + color: 'white', | |
| 138 | + fontSize: '20px', | |
| 139 | + fontWeight: 800, | |
| 140 | + }} | |
| 141 | + > | |
| 142 | + V | |
| 143 | + </div> | |
| 144 | + <div style={{ display: 'flex', marginLeft: '16px', flexDirection: 'column' }}> | |
| 145 | + <div | |
| 146 | + style={{ | |
| 147 | + display: 'flex', | |
| 148 | + color: '#117357', | |
| 149 | + fontSize: '12px', | |
| 150 | + fontWeight: 800, | |
| 151 | + letterSpacing: '0.1em', | |
| 152 | + }} | |
| 153 | + > | |
| 154 | + THE VERDICT | |
| 155 | + </div> | |
| 156 | + <div | |
| 157 | + style={{ display: 'flex', marginTop: '7px', fontSize: '19px', lineHeight: 1.35 }} | |
| 158 | + > | |
| 159 | + {verdict} | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + </div> | |
| 163 | + | |
| 164 | + <div | |
| 165 | + style={{ | |
| 166 | + display: 'flex', | |
| 167 | + marginTop: '18px', | |
| 168 | + justifyContent: 'space-between', | |
| 169 | + color: '#676d82', | |
| 170 | + fontSize: '13px', | |
| 171 | + fontWeight: 700, | |
| 172 | + }} | |
| 173 | + > | |
| 174 | + <div style={{ display: 'flex' }}>{opinionCount} INDEPENDENT OPINIONS</div> | |
| 175 | + <div style={{ display: 'flex' }}>SEVERAL OPINIONS. ONE CLEAR VERDICT.</div> | |
| 176 | + </div> | |
| 177 | + </div> | |
| 178 | + </div> | |
| 179 | + ), | |
| 180 | + size, | |
| 181 | + ); | |
| 182 | +} |
modified src/app/r/[token]/page.tsx +86 −14
| @@ -1,31 +1,103 @@ | ||
| 1 | -import { GitCompareArrows } from 'lucide-react'; | |
| 1 | +import { ArrowRight, GitCompareArrows, UsersRound } from 'lucide-react'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 2 | 3 | import Link from 'next/link'; |
| 3 | 4 | import { notFound } from 'next/navigation'; |
| 4 | -import type { Metadata } from 'next'; | |
| 5 | +import { cache } from 'react'; | |
| 5 | 6 | import { DebateReplay } from '@/components/debate/debate-replay'; |
| 7 | +import { Badge } from '@/components/ui/badge'; | |
| 8 | +import { Button } from '@/components/ui/button'; | |
| 6 | 9 | import { loadDebateByShareToken } from '@/db/repositories'; |
| 10 | +import { truncate } from '@/lib/utils'; | |
| 7 | 11 | |
| 8 | 12 | export const dynamic = 'force-dynamic'; |
| 9 | 13 | |
| 10 | -export const metadata: Metadata = { | |
| 11 | - title: 'Shared deliberation · Roundtable', | |
| 12 | - robots: { index: false, follow: false }, | |
| 13 | -}; | |
| 14 | +const getSharedResult = cache(loadDebateByShareToken); | |
| 15 | + | |
| 16 | +export async function generateMetadata({ | |
| 17 | + params, | |
| 18 | +}: { | |
| 19 | + params: Promise<{ token: string }>; | |
| 20 | +}): Promise<Metadata> { | |
| 21 | + const { token } = await params; | |
| 22 | + const result = await getSharedResult(token); | |
| 23 | + if (!result) return { title: 'Shared verdict', robots: { index: false, follow: false } }; | |
| 24 | + | |
| 25 | + const title = `Verdict: ${truncate(result.config.question, 70)}`; | |
| 26 | + const description = `See how ${result.participants.length} AI models compared this question, challenged each other, and reached a final recommendation.`; | |
| 27 | + return { | |
| 28 | + title, | |
| 29 | + description, | |
| 30 | + robots: { index: false, follow: false }, | |
| 31 | + openGraph: { title, description, type: 'article' }, | |
| 32 | + twitter: { card: 'summary_large_image', title, description }, | |
| 33 | + }; | |
| 34 | +} | |
| 14 | 35 | |
| 15 | 36 | export default async function SharePage({ params }: { params: Promise<{ token: string }> }) { |
| 16 | 37 | const { token } = await params; |
| 17 | - const result = await loadDebateByShareToken(token); | |
| 38 | + const result = await getSharedResult(token); | |
| 18 | 39 | if (!result) notFound(); |
| 19 | 40 | |
| 20 | 41 | 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> | |
| 42 | + <div className="container max-w-6xl py-8 sm:py-12"> | |
| 43 | + <section className="relative overflow-hidden rounded-[1.5rem] border bg-card p-5 sm:p-7"> | |
| 44 | + <div className="absolute right-[-4rem] top-[-5rem] h-48 w-48 rounded-full bg-primary/10 blur-3xl" /> | |
| 45 | + <div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> | |
| 46 | + <div className="max-w-3xl"> | |
| 47 | + <Badge variant="secondary" className="mb-4 gap-1.5 rounded-full"> | |
| 48 | + <GitCompareArrows className="h-3.5 w-3.5 text-primary" /> | |
| 49 | + Shared Roundtable verdict | |
| 50 | + </Badge> | |
| 51 | + <h1 className="font-display text-2xl font-semibold leading-tight tracking-[-0.03em] sm:text-4xl"> | |
| 52 | + {result.config.question} | |
| 53 | + </h1> | |
| 54 | + <div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground"> | |
| 55 | + <span className="flex items-center gap-1.5"> | |
| 56 | + <UsersRound className="h-3.5 w-3.5" /> | |
| 57 | + {result.participants.length} independent opinions | |
| 58 | + </span> | |
| 59 | + <span>{result.totals.rounds} review rounds</span> | |
| 60 | + <span>Original opinions and changes included</span> | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + <Button asChild className="shrink-0 rounded-xl"> | |
| 64 | + <Link href="/debate"> | |
| 65 | + Ask your own question | |
| 66 | + <ArrowRight className="h-4 w-4" /> | |
| 67 | + </Link> | |
| 68 | + </Button> | |
| 69 | + </div> | |
| 70 | + </section> | |
| 71 | + | |
| 72 | + <div className="mt-8"> | |
| 73 | + <DebateReplay result={result} showActions={false} /> | |
| 27 | 74 | </div> |
| 28 | - <DebateReplay result={result} showActions={false} /> | |
| 75 | + | |
| 76 | + <section className="mt-10 rounded-[1.5rem] border bg-foreground px-6 py-9 text-background sm:px-10"> | |
| 77 | + <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> | |
| 78 | + <div> | |
| 79 | + <p className="text-xs font-bold uppercase tracking-[0.16em] text-background/60"> | |
| 80 | + Your turn | |
| 81 | + </p> | |
| 82 | + <h2 className="mt-2 font-display text-2xl font-semibold tracking-tight sm:text-3xl"> | |
| 83 | + Agree with the verdict? Put it back on the table. | |
| 84 | + </h2> | |
| 85 | + <p className="mt-2 max-w-2xl text-sm leading-relaxed text-background/70"> | |
| 86 | + Ask a new question or run this one again with your own model choices and review rules. | |
| 87 | + </p> | |
| 88 | + </div> | |
| 89 | + <div className="flex shrink-0 flex-wrap gap-2"> | |
| 90 | + <Button asChild variant="secondary"> | |
| 91 | + <Link href={{ pathname: '/debate', query: { q: result.config.question } }}> | |
| 92 | + Run this question again | |
| 93 | + </Link> | |
| 94 | + </Button> | |
| 95 | + <Button asChild className="bg-background text-foreground hover:bg-background/90"> | |
| 96 | + <Link href="/debate">Ask something else</Link> | |
| 97 | + </Button> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + </section> | |
| 29 | 101 | </div> |
| 30 | 102 | ); |
| 31 | 103 | } |
modified src/app/signin/page.tsx +9 −4
| @@ -16,8 +16,12 @@export default async function SignInPage() { | ||
| 16 | 16 | <div className="w-full max-w-sm space-y-6 text-center"> |
| 17 | 17 | <GitCompareArrows className="mx-auto h-8 w-8 text-primary" /> |
| 18 | 18 | <div className="space-y-2"> |
| 19 | - <h1 className="font-display text-2xl font-medium tracking-tight">Sign in to Roundtable</h1> | |
| 20 | - <p className="text-sm text-muted-foreground">Keep a replayable history of every debate you run.</p> | |
| 19 | + <h1 className="font-display text-2xl font-semibold tracking-tight"> | |
| 20 | + Sign in to Roundtable | |
| 21 | + </h1> | |
| 22 | + <p className="text-sm text-muted-foreground"> | |
| 23 | + Keep every question and verdict in one place. | |
| 24 | + </p> | |
| 21 | 25 | </div> |
| 22 | 26 | {isGithubAuthConfigured ? ( |
| 23 | 27 | <form |
| @@ -32,13 +36,14 @@export default async function SignInPage() { | ||
| 32 | 36 | </form> |
| 33 | 37 | ) : ( |
| 34 | 38 | <p className="text-sm text-muted-foreground"> |
| 35 | - GitHub authentication isn't configured on this deployment. You can still run debates without an account. | |
| 39 | + GitHub sign-in is not configured on this deployment. You can still ask questions without | |
| 40 | + an account. | |
| 36 | 41 | </p> |
| 37 | 42 | )} |
| 38 | 43 | <p className="text-xs text-muted-foreground"> |
| 39 | 44 | Prefer no account?{' '} |
| 40 | 45 | <Link href="/debate" className="text-primary hover:underline"> |
| 41 | - Run a debate with a session-only key | |
| 46 | + Ask a question without saving an account | |
| 42 | 47 | </Link> |
| 43 | 48 | . |
| 44 | 49 | </p> |
modified src/components/api-key-dialog.tsx +25 −14
| @@ -70,7 +70,9 @@export function ApiKeyDialog({ | ||
| 70 | 70 | } |
| 71 | 71 | const remaining = data.credits?.remaining; |
| 72 | 72 | toast.success( |
| 73 | - remaining != null ? `Key verified - ${formatUsd(remaining)} credits remaining` : 'Key verified and stored', | |
| 73 | + remaining != null | |
| 74 | + ? `Key verified - ${formatUsd(remaining)} credits remaining` | |
| 75 | + : 'Key verified and stored', | |
| 74 | 76 | ); |
| 75 | 77 | setApiKey(''); |
| 76 | 78 | await refresh(); |
| @@ -97,21 +99,22 @@export function ApiKeyDialog({ | ||
| 97 | 99 | <DialogContent> |
| 98 | 100 | <DialogHeader> |
| 99 | 101 | <DialogTitle className="flex items-center gap-2"> |
| 100 | - <KeyRound className="h-5 w-5 text-primary" /> OpenRouter API key | |
| 102 | + <KeyRound className="h-5 w-5 text-primary" /> Connect AI models | |
| 101 | 103 | </DialogTitle> |
| 102 | 104 | <DialogDescription> |
| 103 | - Roundtable is bring-your-own-key - inference is billed to your OpenRouter account, never ours. | |
| 105 | + Paste an OpenRouter key so Roundtable can ask several AI models for you. Model usage is | |
| 106 | + billed to your OpenRouter account. | |
| 104 | 107 | </DialogDescription> |
| 105 | 108 | </DialogHeader> |
| 106 | 109 | |
| 107 | 110 | {status?.mockMode ? ( |
| 108 | 111 | <div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground"> |
| 109 | 112 | <Badge variant="warning" className="mb-2"> |
| 110 | - Mock mode | |
| 113 | + Demo mode | |
| 111 | 114 | </Badge> |
| 112 | 115 | <p> |
| 113 | - This deployment runs with <code className="font-mono">MOCK_LLM=1</code>. Debates use a deterministic | |
| 114 | - offline model - no key required. | |
| 116 | + This deployment uses offline demo models. You can ask a question without connecting an | |
| 117 | + account. | |
| 115 | 118 | </p> |
| 116 | 119 | </div> |
| 117 | 120 | ) : ( |
| @@ -129,7 +132,7 @@export function ApiKeyDialog({ | ||
| 129 | 132 | )} |
| 130 | 133 | |
| 131 | 134 | <div className="space-y-2"> |
| 132 | - <Label htmlFor="rt-key">Paste your key</Label> | |
| 135 | + <Label htmlFor="rt-key">OpenRouter key</Label> | |
| 133 | 136 | <Input |
| 134 | 137 | id="rt-key" |
| 135 | 138 | type="password" |
| @@ -139,29 +142,37 @@export function ApiKeyDialog({ | ||
| 139 | 142 | autoComplete="off" |
| 140 | 143 | /> |
| 141 | 144 | <p className="text-xs text-muted-foreground"> |
| 142 | - Validated against OpenRouter before it is stored. | |
| 145 | + Roundtable checks the key with OpenRouter before using it. | |
| 143 | 146 | </p> |
| 144 | 147 | </div> |
| 145 | 148 | |
| 146 | 149 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> |
| 147 | 150 | <StorageOption |
| 148 | 151 | active={mode === 'session'} |
| 149 | - title="Session only" | |
| 150 | - desc="Kept in an encrypted HttpOnly cookie for 12h. Nothing saved server-side." | |
| 152 | + title="This browser session" | |
| 153 | + desc="Encrypted for 12 hours and not saved to your account." | |
| 151 | 154 | onClick={() => setMode('session')} |
| 152 | 155 | /> |
| 153 | 156 | <StorageOption |
| 154 | 157 | active={mode === 'save'} |
| 155 | 158 | disabled={!status?.authenticated} |
| 156 | - title="Save to account" | |
| 157 | - desc={status?.authenticated ? 'Encrypted at rest (AES-256-GCM).' : 'Sign in to save a key.'} | |
| 159 | + title="Save to my account" | |
| 160 | + desc={ | |
| 161 | + status?.authenticated | |
| 162 | + ? 'Encrypted and available next time.' | |
| 163 | + : 'Sign in to save a key.' | |
| 164 | + } | |
| 158 | 165 | onClick={() => status?.authenticated && setMode('save')} |
| 159 | 166 | /> |
| 160 | 167 | </div> |
| 161 | 168 | |
| 162 | 169 | <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 | |
| 170 | + {busy ? ( | |
| 171 | + <Loader2 className="h-4 w-4 animate-spin" /> | |
| 172 | + ) : ( | |
| 173 | + <ShieldCheck className="h-4 w-4" /> | |
| 174 | + )} | |
| 175 | + Connect models | |
| 165 | 176 | </Button> |
| 166 | 177 | </div> |
| 167 | 178 | )} |
modified src/components/debate/debate-actions.tsx +49 −13
| @@ -5,38 +5,74 @@import Link from 'next/link'; | ||
| 5 | 5 | import { useState } from 'react'; |
| 6 | 6 | import { Button } from '@/components/ui/button'; |
| 7 | 7 | import { toast } from '@/components/ui/sonner'; |
| 8 | +import { truncate } from '@/lib/utils'; | |
| 8 | 9 | |
| 9 | -export function DebateActions({ debateId }: { debateId: string }) { | |
| 10 | +export function DebateActions({ debateId, question }: { debateId: string; question: string }) { | |
| 10 | 11 | const [shared, setShared] = useState(false); |
| 12 | + const [sharing, setSharing] = useState(false); | |
| 11 | 13 | |
| 12 | 14 | const share = async () => { |
| 15 | + setSharing(true); | |
| 13 | 16 | try { |
| 14 | - const res = await fetch(`/api/debates/${debateId}/share`, { method: 'POST' }); | |
| 15 | - if (!res.ok) throw new Error('share failed'); | |
| 16 | - const { url } = (await res.json()) as { url: string }; | |
| 17 | - await navigator.clipboard.writeText(url).catch(() => {}); | |
| 17 | + const response = await fetch(`/api/debates/${debateId}/share`, { method: 'POST' }); | |
| 18 | + if (!response.ok) throw new Error('share failed'); | |
| 19 | + const { url } = (await response.json()) as { url: string }; | |
| 20 | + const text = `I asked several AI models: "${truncate(question, 120)}" See where they agreed and what changed.`; | |
| 21 | + | |
| 22 | + if (navigator.share) { | |
| 23 | + try { | |
| 24 | + await navigator.share({ title: 'Roundtable verdict', text, url }); | |
| 25 | + setShared(true); | |
| 26 | + return; | |
| 27 | + } catch (error) { | |
| 28 | + if (error instanceof DOMException && error.name === 'AbortError') return; | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + const copied = await copyText(url); | |
| 33 | + if (!copied) throw new Error('copy failed'); | |
| 18 | 34 | setShared(true); |
| 19 | - toast.success('Share link copied to clipboard', { description: url }); | |
| 35 | + toast.success('Verdict link copied'); | |
| 20 | 36 | } catch { |
| 21 | - toast.error('Could not create share link'); | |
| 37 | + toast.error('Could not create a share link'); | |
| 38 | + } finally { | |
| 39 | + setSharing(false); | |
| 22 | 40 | } |
| 23 | 41 | }; |
| 24 | 42 | |
| 25 | 43 | return ( |
| 26 | - <div className="flex items-center gap-2"> | |
| 44 | + <div className="flex flex-wrap items-center gap-2"> | |
| 45 | + <Button size="sm" onClick={share} disabled={sharing}> | |
| 46 | + {shared ? <Check className="h-3.5 w-3.5" /> : <Share2 className="h-3.5 w-3.5" />} | |
| 47 | + {shared ? 'Shared' : sharing ? 'Preparing...' : 'Share verdict'} | |
| 48 | + </Button> | |
| 27 | 49 | <Button variant="outline" size="sm" asChild> |
| 28 | 50 | <Link href={`/debate?from=${debateId}`}> |
| 29 | - <RotateCcw className="h-3.5 w-3.5" /> Re-run | |
| 51 | + <RotateCcw className="h-3.5 w-3.5" /> Ask again | |
| 30 | 52 | </Link> |
| 31 | 53 | </Button> |
| 32 | 54 | <Button variant="outline" size="sm" asChild> |
| 33 | 55 | <a href={`/api/debates/${debateId}/export?format=md`} download> |
| 34 | - <Download className="h-3.5 w-3.5" /> Export | |
| 56 | + <Download className="h-3.5 w-3.5" /> Download | |
| 35 | 57 | </a> |
| 36 | 58 | </Button> |
| 37 | - <Button variant="outline" size="sm" onClick={share}> | |
| 38 | - {shared ? <Check className="h-3.5 w-3.5" /> : <Share2 className="h-3.5 w-3.5" />} Share | |
| 39 | - </Button> | |
| 40 | 59 | </div> |
| 41 | 60 | ); |
| 42 | 61 | } |
| 62 | + | |
| 63 | +async function copyText(value: string): Promise<boolean> { | |
| 64 | + try { | |
| 65 | + await navigator.clipboard.writeText(value); | |
| 66 | + return true; | |
| 67 | + } catch { | |
| 68 | + const input = document.createElement('textarea'); | |
| 69 | + input.value = value; | |
| 70 | + input.style.position = 'fixed'; | |
| 71 | + input.style.opacity = '0'; | |
| 72 | + document.body.appendChild(input); | |
| 73 | + input.select(); | |
| 74 | + const copied = document.execCommand('copy'); | |
| 75 | + input.remove(); | |
| 76 | + return copied; | |
| 77 | + } | |
| 78 | +} |
modified src/components/debate/debate-console.tsx +48 −23
| @@ -19,11 +19,19 @@import type { AnswerRecord } from '@/core/types'; | ||
| 19 | 19 | import { allCritiques, currentAnswers, type DebateView } from '@/lib/debate-view'; |
| 20 | 20 | import { formatUsd } from '@/lib/utils'; |
| 21 | 21 | |
| 22 | -export function DebateConsole({ view, showActions = true }: { view: DebateView; showActions?: boolean }) { | |
| 22 | +export function DebateConsole({ | |
| 23 | + view, | |
| 24 | + showActions = true, | |
| 25 | +}: { | |
| 26 | + view: DebateView; | |
| 27 | + showActions?: boolean; | |
| 28 | +}) { | |
| 23 | 29 | const answers = currentAnswers(view); |
| 24 | 30 | const dropped = new Set(view.droppedParticipants); |
| 25 | - const lastConvergence = [...view.rounds].reverse().find((r) => r.convergence)?.convergence ?? null; | |
| 26 | - const finished = view.status === 'completed' || view.status === 'failed' || view.status === 'aborted'; | |
| 31 | + const lastConvergence = | |
| 32 | + [...view.rounds].reverse().find((r) => r.convergence)?.convergence ?? null; | |
| 33 | + const finished = | |
| 34 | + view.status === 'completed' || view.status === 'failed' || view.status === 'aborted'; | |
| 27 | 35 | |
| 28 | 36 | return ( |
| 29 | 37 | <div className="space-y-6"> |
| @@ -34,8 +42,8 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 34 | 42 | <div className="flex items-center gap-2"> |
| 35 | 43 | <StatusBadge status={view.status} /> |
| 36 | 44 | <span className="text-xs text-muted-foreground"> |
| 37 | - {view.participants.length} models · up to {view.config?.maxRounds ?? '-'} rounds · converge ≥ | |
| 38 | - {view.config?.convergenceThreshold ?? '-'} | |
| 45 | + {view.participants.length} independent opinions · up to{' '} | |
| 46 | + {view.config?.maxRounds ?? '-'} review rounds | |
| 39 | 47 | </span> |
| 40 | 48 | </div> |
| 41 | 49 | <h1 className="text-balance font-display text-2xl font-medium leading-snug tracking-tight"> |
| @@ -44,7 +52,9 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 44 | 52 | </div> |
| 45 | 53 | <div className="flex items-center gap-2"> |
| 46 | 54 | <CostMeter view={view} /> |
| 47 | - {showActions && finished && view.debateId && <DebateActions debateId={view.debateId} />} | |
| 55 | + {showActions && finished && view.debateId && ( | |
| 56 | + <DebateActions debateId={view.debateId} question={view.question} /> | |
| 57 | + )} | |
| 48 | 58 | </div> |
| 49 | 59 | </div> |
| 50 | 60 | |
| @@ -58,8 +68,8 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 58 | 68 | <div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm"> |
| 59 | 69 | <Gavel className="h-4 w-4 text-primary" /> |
| 60 | 70 | <span> |
| 61 | - You concluded the debate after round {view.gavelStruck.round}; the chairman synthesized the answers as | |
| 62 | - they stood. | |
| 71 | + You asked for the verdict after review round {view.gavelStruck.round}. It was prepared | |
| 72 | + from the opinions completed at that point. | |
| 63 | 73 | </span> |
| 64 | 74 | </div> |
| 65 | 75 | )} |
| @@ -69,8 +79,9 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 69 | 79 | <PiggyBank className="h-4 w-4 text-amber-500" /> |
| 70 | 80 | <span> |
| 71 | 81 | Budget cap reached after round {view.budgetReached.round} (spent{' '} |
| 72 | - {formatUsd(view.budgetReached.totalCostUsd)} of a {formatUsd(view.budgetReached.maxCostUsd)} cap); | |
| 73 | - remaining rounds were skipped and the chairman synthesized what existed. | |
| 82 | + {formatUsd(view.budgetReached.totalCostUsd)} of a{' '} | |
| 83 | + {formatUsd(view.budgetReached.maxCostUsd)} cap); remaining rounds were skipped and the | |
| 84 | + verdict was prepared from the work completed so far. | |
| 74 | 85 | </span> |
| 75 | 86 | </div> |
| 76 | 87 | )} |
| @@ -88,7 +99,10 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 88 | 99 | provenance={view.provenance} |
| 89 | 100 | /> |
| 90 | 101 | {lastConvergence && lastConvergence.disagreements.length > 0 ? ( |
| 91 | - <Disagreements disagreements={lastConvergence.disagreements} participants={view.participants} /> | |
| 102 | + <Disagreements | |
| 103 | + disagreements={lastConvergence.disagreements} | |
| 104 | + participants={view.participants} | |
| 105 | + /> | |
| 92 | 106 | ) : ( |
| 93 | 107 | <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm"> |
| 94 | 108 | <div className="mb-1 flex items-center gap-2 font-medium text-emerald-600 dark:text-emerald-400"> |
| @@ -105,7 +119,9 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 105 | 119 | |
| 106 | 120 | {/* Council panels */} |
| 107 | 121 | <section id="section-council" className="space-y-3"> |
| 108 | - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">Council answers</h2> | |
| 122 | + <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 123 | + Independent opinions | |
| 124 | + </h2> | |
| 109 | 125 | <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> |
| 110 | 126 | {view.participants.map((p, i) => ( |
| 111 | 127 | <ModelPanel |
| @@ -135,18 +151,24 @@export function DebateConsole({ view, showActions = true }: { view: DebateView; | ||
| 135 | 151 | {/* Pressure response */} |
| 136 | 152 | {view.rounds.some((r) => r.revisions.length > 0) && ( |
| 137 | 153 | <section id="section-spine" className="space-y-3"> |
| 138 | - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">Under pressure</h2> | |
| 154 | + <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 155 | + How opinions changed | |
| 156 | + </h2> | |
| 139 | 157 | <div className="rounded-xl border bg-card p-4"> |
| 140 | 158 | <SpinePanel participants={view.participants} rounds={view.rounds} /> |
| 141 | 159 | </div> |
| 142 | - {finished && <MasqueradeNote participants={view.participants} critiques={allCritiques(view)} />} | |
| 160 | + {finished && ( | |
| 161 | + <MasqueradeNote participants={view.participants} critiques={allCritiques(view)} /> | |
| 162 | + )} | |
| 143 | 163 | </section> |
| 144 | 164 | )} |
| 145 | 165 | |
| 146 | 166 | {/* Cost breakdown */} |
| 147 | 167 | {finished && ( |
| 148 | 168 | <section id="section-cost" className="space-y-3"> |
| 149 | - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">Cost breakdown</h2> | |
| 169 | + <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> | |
| 170 | + Usage and cost | |
| 171 | + </h2> | |
| 150 | 172 | <div className="rounded-xl border bg-card p-4"> |
| 151 | 173 | <CostBreakdown view={view} /> |
| 152 | 174 | </div> |
| @@ -175,10 +197,10 @@function RoundSection({ | ||
| 175 | 197 | > |
| 176 | 198 | <Scale className="h-4 w-4" /> |
| 177 | 199 | </span> |
| 178 | - <h2 className="text-sm font-semibold">Round {round}</h2> | |
| 200 | + <h2 className="text-sm font-semibold">Review round {round}</h2> | |
| 179 | 201 | {record.convergence && ( |
| 180 | 202 | <div className="ml-auto flex items-center gap-2"> |
| 181 | - <span className="text-xs text-muted-foreground">convergence</span> | |
| 203 | + <span className="text-xs text-muted-foreground">agreement</span> | |
| 182 | 204 | <div className="w-28"> |
| 183 | 205 | <Progress value={record.convergence.score} /> |
| 184 | 206 | </div> |
| @@ -192,8 +214,8 @@function RoundSection({ | ||
| 192 | 214 | <div className="p-4"> |
| 193 | 215 | <Tabs defaultValue="matrix"> |
| 194 | 216 | <TabsList> |
| 195 | - <TabsTrigger value="matrix">Critique matrix</TabsTrigger> | |
| 196 | - <TabsTrigger value="revisions">Revisions & diffs</TabsTrigger> | |
| 217 | + <TabsTrigger value="matrix">Peer review</TabsTrigger> | |
| 218 | + <TabsTrigger value="revisions">Answer changes</TabsTrigger> | |
| 197 | 219 | </TabsList> |
| 198 | 220 | <TabsContent value="matrix" className="pt-2"> |
| 199 | 221 | <CritiqueMatrix participants={view.participants} critiques={record.critiques} /> |
| @@ -243,10 +265,13 @@function answersBeforeRound(view: DebateView, round: number): Record<string, Ans | ||
| 243 | 265 | } |
| 244 | 266 | |
| 245 | 267 | function StatusBadge({ status }: { status: DebateView['status'] }) { |
| 246 | - const map: Record<DebateView['status'], { variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning'; label: string }> = { | |
| 247 | - pending: { variant: 'secondary', label: 'Pending' }, | |
| 248 | - running: { variant: 'warning', label: 'Deliberating' }, | |
| 249 | - completed: { variant: 'success', label: 'Completed' }, | |
| 268 | + const map: Record< | |
| 269 | + DebateView['status'], | |
| 270 | + { variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning'; label: string } | |
| 271 | + > = { | |
| 272 | + pending: { variant: 'secondary', label: 'Getting ready' }, | |
| 273 | + running: { variant: 'warning', label: 'Comparing opinions' }, | |
| 274 | + completed: { variant: 'success', label: 'Verdict ready' }, | |
| 250 | 275 | failed: { variant: 'destructive', label: 'Failed' }, |
| 251 | 276 | aborted: { variant: 'secondary', label: 'Aborted' }, |
| 252 | 277 | }; |
modified src/components/debate/disagreements.tsx +9 −3
| @@ -17,7 +17,7 @@export function Disagreements({ | ||
| 17 | 17 | return ( |
| 18 | 18 | <div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"> |
| 19 | 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 | |
| 20 | + <GitFork className="h-4 w-4" /> Where views still differ | |
| 21 | 21 | </div> |
| 22 | 22 | <div className="space-y-3"> |
| 23 | 23 | {disagreements.map((d, i) => ( |
| @@ -32,12 +32,18 @@export function Disagreements({ | ||
| 32 | 32 | <li key={j} className="flex items-start gap-2 text-xs"> |
| 33 | 33 | <span |
| 34 | 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))' }} | |
| 35 | + style={{ | |
| 36 | + backgroundColor: entry | |
| 37 | + ? participantColor(entry.i) | |
| 38 | + : 'hsl(var(--muted-foreground))', | |
| 39 | + }} | |
| 36 | 40 | > |
| 37 | 41 | {entry ? participantTag(entry.i) : pos.label} |
| 38 | 42 | </span> |
| 39 | 43 | <span> |
| 40 | - <span className="font-medium">{entry ? entry.p.displayName : `Response ${pos.label}`}:</span>{' '} | |
| 44 | + <span className="font-medium"> | |
| 45 | + {entry ? entry.p.displayName : `Response ${pos.label}`}: | |
| 46 | + </span>{' '} | |
| 41 | 47 | <span className="text-muted-foreground">{pos.stance}</span> |
| 42 | 48 | </span> |
| 43 | 49 | </li> |
modified src/components/debate/final-answer.tsx +107 −88
| @@ -1,14 +1,10 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | -import { AlertTriangle, Gavel } from 'lucide-react'; | |
| 3 | +import { AlertTriangle, ChevronDown, Gavel } from 'lucide-react'; | |
| 4 | 4 | import { RichText } from '@/components/debate/rich-text'; |
| 5 | 5 | import { Badge } from '@/components/ui/badge'; |
| 6 | 6 | import { CopyButton } from '@/components/ui/copy-button'; |
| 7 | -import { | |
| 8 | - Tooltip, | |
| 9 | - TooltipContent, | |
| 10 | - TooltipTrigger, | |
| 11 | -} from '@/components/ui/tooltip'; | |
| 7 | +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; | |
| 12 | 8 | import { |
| 13 | 9 | displayNameForModel, |
| 14 | 10 | type Participant, |
| @@ -28,122 +24,145 @@export function FinalAnswer({ | ||
| 28 | 24 | chairmanProviderConflict: boolean; |
| 29 | 25 | provenance?: ProvenanceRecord | null; |
| 30 | 26 | }) { |
| 31 | - const byId = new Map(participants.map((p, i) => [p.id, { p, i }])); | |
| 32 | - const unsourcedCount = provenance?.claims.filter((c) => c.unsourced).length ?? 0; | |
| 27 | + const byId = new Map( | |
| 28 | + participants.map((participant, index) => [participant.id, { participant, index }]), | |
| 29 | + ); | |
| 30 | + const untracedCount = provenance?.claims.filter((claim) => claim.unsourced).length ?? 0; | |
| 33 | 31 | |
| 34 | 32 | return ( |
| 35 | - <div className="overflow-hidden rounded-xl border-2 border-emerald-500/30 bg-gradient-to-b from-emerald-500/[0.06] to-transparent"> | |
| 36 | - <div className="flex flex-wrap items-center gap-2 border-b border-emerald-500/20 p-4"> | |
| 37 | - <span | |
| 38 | - className="flex h-8 w-8 items-center justify-center rounded-lg text-white" | |
| 39 | - style={{ backgroundColor: 'hsl(var(--stage-synthesis))' }} | |
| 40 | - > | |
| 33 | + <div className="overflow-hidden rounded-[1.25rem] border-2 border-emerald-500/25 bg-card shadow-lg shadow-emerald-500/5"> | |
| 34 | + <div className="flex flex-wrap items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/[0.055] p-4 sm:p-5"> | |
| 35 | + <span className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500 text-white shadow-sm"> | |
| 41 | 36 | <Gavel className="h-4 w-4" /> |
| 42 | 37 | </span> |
| 43 | 38 | <div> |
| 44 | - <div className="text-sm font-semibold">Chairman synthesis</div> | |
| 45 | - <div className="font-mono text-[11px] text-muted-foreground">{synthesis.model}</div> | |
| 39 | + <div className="text-[10px] font-bold uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300"> | |
| 40 | + Final recommendation | |
| 41 | + </div> | |
| 42 | + <div className="mt-0.5 font-display text-lg font-semibold">The verdict</div> | |
| 43 | + <div className="text-[11px] text-muted-foreground"> | |
| 44 | + Combined by {displayNameForModel(synthesis.model)} | |
| 45 | + </div> | |
| 46 | 46 | </div> |
| 47 | 47 | <div className="ml-auto flex items-center gap-2"> |
| 48 | 48 | {chairmanProviderConflict && ( |
| 49 | 49 | <Tooltip> |
| 50 | 50 | <TooltipTrigger asChild> |
| 51 | 51 | <Badge variant="warning" className="cursor-help gap-1"> |
| 52 | - <AlertTriangle className="h-3 w-3" /> provider overlap | |
| 52 | + <AlertTriangle className="h-3 w-3" /> model mix | |
| 53 | 53 | </Badge> |
| 54 | 54 | </TooltipTrigger> |
| 55 | 55 | <TooltipContent className="max-w-xs"> |
| 56 | - The chairman shares a provider family with a council member, a self-preference risk. Answers are shown | |
| 57 | - to the chairman anonymized to mitigate it, but consider a chairman from an independent provider. | |
| 56 | + The same AI provider appears in the opinion group and the verdict model. Names were | |
| 57 | + hidden during review to reduce bias. | |
| 58 | 58 | </TooltipContent> |
| 59 | 59 | </Tooltip> |
| 60 | 60 | )} |
| 61 | - <CopyButton text={synthesis.finalAnswer} label="Copy answer" /> | |
| 61 | + <CopyButton text={synthesis.finalAnswer} label="Copy verdict" /> | |
| 62 | 62 | </div> |
| 63 | 63 | </div> |
| 64 | 64 | |
| 65 | - <div className="p-5"> | |
| 66 | - <RichText text={synthesis.finalAnswer} className="text-[15px]" /> | |
| 65 | + <div className="p-5 sm:p-6"> | |
| 66 | + <RichText text={synthesis.finalAnswer} className="text-[15px] leading-relaxed" /> | |
| 67 | 67 | |
| 68 | 68 | {provenance && provenance.claims.length > 0 && ( |
| 69 | - <div className="mt-5 rounded-lg border bg-card p-4"> | |
| 70 | - <div className="mb-1 flex flex-wrap items-center gap-2"> | |
| 71 | - <span className="text-sm font-semibold">Claim check</span> | |
| 72 | - {unsourcedCount > 0 ? ( | |
| 73 | - <Badge variant="warning">{unsourcedCount} chairman addition{unsourcedCount > 1 ? 's' : ''}</Badge> | |
| 69 | + <details className="group mt-6 rounded-xl border bg-background/55"> | |
| 70 | + <summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> | |
| 71 | + How this verdict was checked | |
| 72 | + {untracedCount > 0 ? ( | |
| 73 | + <Badge variant="warning">{untracedCount} new in verdict</Badge> | |
| 74 | 74 | ) : ( |
| 75 | - <Badge variant="success">all claims sourced</Badge> | |
| 75 | + <Badge variant="success">Every claim traced</Badge> | |
| 76 | 76 | )} |
| 77 | + <ChevronDown className="ml-auto h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" /> | |
| 78 | + </summary> | |
| 79 | + <div className="border-t p-4"> | |
| 80 | + <p className="mb-3 text-xs leading-relaxed text-muted-foreground"> | |
| 81 | + Each important claim is matched to the opinions that support or challenge it. A new | |
| 82 | + claim is one that appeared only when the final verdict was written. | |
| 83 | + </p> | |
| 84 | + <ul className="space-y-2.5"> | |
| 85 | + {provenance.claims.map((claim, claimIndex) => ( | |
| 86 | + <li key={claimIndex} className="flex items-start gap-2 text-xs"> | |
| 87 | + <span className="mt-1 flex shrink-0 items-center gap-0.5"> | |
| 88 | + {claim.unsourced ? ( | |
| 89 | + <span | |
| 90 | + className="h-2 w-2 rounded-full bg-amber-500" | |
| 91 | + title="New in the final verdict" | |
| 92 | + /> | |
| 93 | + ) : ( | |
| 94 | + claim.supportedBy.map((support) => { | |
| 95 | + const entry = byId.get(support.participantId); | |
| 96 | + const index = entry?.index ?? 0; | |
| 97 | + return ( | |
| 98 | + <span | |
| 99 | + key={support.participantId} | |
| 100 | + className="flex h-4 w-4 items-center justify-center rounded text-[9px] font-bold text-white" | |
| 101 | + style={{ backgroundColor: participantColor(index) }} | |
| 102 | + title={`Supported by ${entry?.participant.displayName ?? support.model}`} | |
| 103 | + > | |
| 104 | + {participantTag(index)} | |
| 105 | + </span> | |
| 106 | + ); | |
| 107 | + }) | |
| 108 | + )} | |
| 109 | + </span> | |
| 110 | + <span | |
| 111 | + className={claim.unsourced ? 'text-amber-600 dark:text-amber-400' : undefined} | |
| 112 | + > | |
| 113 | + {claim.text} | |
| 114 | + {claim.unsourced && ( | |
| 115 | + <span className="ml-1 font-medium">(new in the verdict)</span> | |
| 116 | + )} | |
| 117 | + {claim.contestedBy.length > 0 && ( | |
| 118 | + <span className="text-muted-foreground"> | |
| 119 | + {' '} | |
| 120 | + · challenged by{' '} | |
| 121 | + {claim.contestedBy | |
| 122 | + .map( | |
| 123 | + (challenge) => | |
| 124 | + byId.get(challenge.participantId)?.participant.displayName ?? | |
| 125 | + displayNameForModel(challenge.model), | |
| 126 | + ) | |
| 127 | + .join(', ')} | |
| 128 | + </span> | |
| 129 | + )} | |
| 130 | + </span> | |
| 131 | + </li> | |
| 132 | + ))} | |
| 133 | + </ul> | |
| 77 | 134 | </div> |
| 78 | - <p className="mb-3 text-xs text-muted-foreground"> | |
| 79 | - Each substantive claim in the final answer, traced back to the council by {provenance.model}. A claim | |
| 80 | - nobody argued is flagged: that is where synthesis hallucination hides. | |
| 81 | - </p> | |
| 82 | - <ul className="space-y-2"> | |
| 83 | - {provenance.claims.map((claim, i) => ( | |
| 84 | - <li key={i} className="flex items-start gap-2 text-xs"> | |
| 85 | - <span className="mt-1 flex shrink-0 items-center gap-0.5"> | |
| 86 | - {claim.unsourced ? ( | |
| 87 | - <span className="h-2 w-2 rounded-full bg-amber-500" title="No council member made this claim" /> | |
| 88 | - ) : ( | |
| 89 | - claim.supportedBy.map((m) => { | |
| 90 | - const entry = byId.get(m.participantId); | |
| 91 | - const idx = entry?.i ?? 0; | |
| 92 | - return ( | |
| 93 | - <span | |
| 94 | - key={m.participantId} | |
| 95 | - className="flex h-4 w-4 items-center justify-center rounded text-[9px] font-bold text-white" | |
| 96 | - style={{ backgroundColor: participantColor(idx) }} | |
| 97 | - title={`Supported by ${entry?.p.displayName ?? m.model}`} | |
| 98 | - > | |
| 99 | - {participantTag(idx)} | |
| 100 | - </span> | |
| 101 | - ); | |
| 102 | - }) | |
| 103 | - )} | |
| 104 | - </span> | |
| 105 | - <span className={claim.unsourced ? 'text-amber-600 dark:text-amber-400' : undefined}> | |
| 106 | - {claim.text} | |
| 107 | - {claim.unsourced && <span className="ml-1 font-medium">(chairman's own addition)</span>} | |
| 108 | - {claim.contestedBy.length > 0 && ( | |
| 109 | - <span className="text-muted-foreground"> | |
| 110 | - {' '} | |
| 111 | - · contested by{' '} | |
| 112 | - {claim.contestedBy | |
| 113 | - .map((m) => byId.get(m.participantId)?.p.displayName ?? displayNameForModel(m.model)) | |
| 114 | - .join(', ')} | |
| 115 | - </span> | |
| 116 | - )} | |
| 117 | - </span> | |
| 118 | - </li> | |
| 119 | - ))} | |
| 120 | - </ul> | |
| 121 | - </div> | |
| 135 | + </details> | |
| 122 | 136 | )} |
| 123 | 137 | |
| 124 | 138 | {synthesis.dissent.length > 0 && ( |
| 125 | - <div className="mt-5 rounded-lg border bg-card p-4"> | |
| 126 | - <div className="mb-2 text-sm font-semibold">Dissent report</div> | |
| 127 | - <div className="space-y-2.5"> | |
| 128 | - {synthesis.dissent.map((d, i) => ( | |
| 129 | - <div key={i}> | |
| 130 | - <div className="text-sm font-medium">{d.topic}</div> | |
| 131 | - <ul className="mt-1 space-y-1"> | |
| 132 | - {d.positions.map((pos, j) => { | |
| 133 | - const entry = byId.get(pos.participantId); | |
| 134 | - const idx = entry?.i ?? 0; | |
| 135 | - const name = entry?.p.displayName ?? displayNameForModel(pos.model); | |
| 139 | + <details className="group mt-3 rounded-xl border bg-background/55"> | |
| 140 | + <summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> | |
| 141 | + Where opinions still differ | |
| 142 | + <Badge variant="secondary">{synthesis.dissent.length}</Badge> | |
| 143 | + <ChevronDown className="ml-auto h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" /> | |
| 144 | + </summary> | |
| 145 | + <div className="space-y-4 border-t p-4"> | |
| 146 | + {synthesis.dissent.map((dissent, dissentIndex) => ( | |
| 147 | + <div key={dissentIndex}> | |
| 148 | + <div className="text-sm font-medium">{dissent.topic}</div> | |
| 149 | + <ul className="mt-2 space-y-2"> | |
| 150 | + {dissent.positions.map((position, positionIndex) => { | |
| 151 | + const entry = byId.get(position.participantId); | |
| 152 | + const index = entry?.index ?? 0; | |
| 153 | + const name = | |
| 154 | + entry?.participant.displayName ?? displayNameForModel(position.model); | |
| 136 | 155 | return ( |
| 137 | - <li key={j} className="flex items-start gap-2 text-xs"> | |
| 156 | + <li key={positionIndex} className="flex items-start gap-2 text-xs"> | |
| 138 | 157 | <span |
| 139 | 158 | className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-[9px] font-bold text-white" |
| 140 | - style={{ backgroundColor: participantColor(idx) }} | |
| 159 | + style={{ backgroundColor: participantColor(index) }} | |
| 141 | 160 | > |
| 142 | - {participantTag(idx)} | |
| 161 | + {participantTag(index)} | |
| 143 | 162 | </span> |
| 144 | 163 | <span> |
| 145 | 164 | <span className="font-medium">{name}:</span>{' '} |
| 146 | - <span className="text-muted-foreground">{pos.position}</span> | |
| 165 | + <span className="text-muted-foreground">{position.position}</span> | |
| 147 | 166 | </span> |
| 148 | 167 | </li> |
| 149 | 168 | ); |
| @@ -152,7 +171,7 @@export function FinalAnswer({ | ||
| 152 | 171 | </div> |
| 153 | 172 | ))} |
| 154 | 173 | </div> |
| 155 | - </div> | |
| 174 | + </details> | |
| 156 | 175 | )} |
| 157 | 176 | </div> |
| 158 | 177 | </div> |
modified src/components/debate/new-debate.tsx +344 −227
| @@ -1,13 +1,26 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | -import { AlertTriangle, Gavel, KeyRound, Loader2, Play, RotateCcw, Save, Square } from 'lucide-react'; | |
| 3 | +import { | |
| 4 | + AlertTriangle, | |
| 5 | + ChevronDown, | |
| 6 | + Gavel, | |
| 7 | + KeyRound, | |
| 8 | + Loader2, | |
| 9 | + Play, | |
| 10 | + RotateCcw, | |
| 11 | + Save, | |
| 12 | + Settings2, | |
| 13 | + Square, | |
| 14 | + UsersRound, | |
| 15 | +} from 'lucide-react'; | |
| 4 | 16 | import { useEffect, useMemo, useState } from 'react'; |
| 5 | 17 | import { ApiKeyDialog } from '@/components/api-key-dialog'; |
| 6 | 18 | import { DebateConsole } from '@/components/debate/debate-console'; |
| 7 | 19 | import { ModelCombobox } from '@/components/debate/model-combobox'; |
| 8 | 20 | import { ModelPicker } from '@/components/debate/model-picker'; |
| 9 | 21 | import { Badge } from '@/components/ui/badge'; |
| 10 | 22 | import { Button } from '@/components/ui/button'; |
| 23 | +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; | |
| 11 | 24 | import { Input } from '@/components/ui/input'; |
| 12 | 25 | import { Label } from '@/components/ui/label'; |
| 13 | 26 | import { Slider } from '@/components/ui/slider'; |
| @@ -17,6 +30,7 @@import type { DebateResult } from '@/core/types'; | ||
| 17 | 30 | import { useDebateStream } from '@/hooks/use-debate-stream'; |
| 18 | 31 | import { useModels } from '@/hooks/use-models'; |
| 19 | 32 | import { estimateDebateCostUsd } from '@/lib/cost-estimate'; |
| 33 | +import { QUESTION_EXAMPLES } from '@/lib/question-examples'; | |
| 20 | 34 | import { formatUsd } from '@/lib/utils'; |
| 21 | 35 | |
| 22 | 36 | interface Preset { |
| @@ -30,13 +44,17 @@interface Preset { | ||
| 30 | 44 | temperature: number; |
| 31 | 45 | } |
| 32 | 46 | |
| 33 | -const EXAMPLE = 'Should a small startup build on a monolith or microservices? Give a decisive recommendation.'; | |
| 34 | - | |
| 35 | -export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | |
| 47 | +export function NewDebate({ | |
| 48 | + fromDebateId, | |
| 49 | + initialQuestion = '', | |
| 50 | +}: { | |
| 51 | + fromDebateId?: string; | |
| 52 | + initialQuestion?: string; | |
| 53 | +}) { | |
| 36 | 54 | const { view, phase, errorMsg, errorCode, start, cancel, reset } = useDebateStream(); |
| 37 | - const { models } = useModels(); | |
| 55 | + const { models, loading: modelsLoading, error: modelsError } = useModels(); | |
| 38 | 56 | |
| 39 | - const [question, setQuestion] = useState(''); | |
| 57 | + const [question, setQuestion] = useState(initialQuestion.slice(0, 8000)); | |
| 40 | 58 | const [council, setCouncil] = useState<string[]>([]); |
| 41 | 59 | const [chairman, setChairman] = useState(''); |
| 42 | 60 | const [convergenceModel, setConvergenceModel] = useState(DEFAULT_CONVERGENCE_MODEL); |
| @@ -49,13 +67,13 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 49 | 67 | const [keyDialogOpen, setKeyDialogOpen] = useState(false); |
| 50 | 68 | const [needsKey, setNeedsKey] = useState(false); |
| 51 | 69 | const [prefilled, setPrefilled] = useState(false); |
| 70 | + const [advancedOpen, setAdvancedOpen] = useState(false); | |
| 52 | 71 | |
| 53 | - // Prefill the whole form from a previous debate (/debate?from=<id>). | |
| 54 | 72 | useEffect(() => { |
| 55 | 73 | if (!fromDebateId || prefilled) return; |
| 56 | 74 | let cancelled = false; |
| 57 | 75 | fetch(`/api/debates/${fromDebateId}`) |
| 58 | - .then((r) => (r.ok ? (r.json() as Promise<DebateResult>) : null)) | |
| 76 | + .then((response) => (response.ok ? (response.json() as Promise<DebateResult>) : null)) | |
| 59 | 77 | .then((result) => { |
| 60 | 78 | if (!result || cancelled) return; |
| 61 | 79 | setQuestion(result.config.question); |
| @@ -66,67 +84,73 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 66 | 84 | setThreshold(result.config.convergenceThreshold); |
| 67 | 85 | setTemperature(result.config.temperature); |
| 68 | 86 | setPrefilled(true); |
| 69 | - toast.message('Loaded question and council from the previous debate'); | |
| 87 | + toast.message('Loaded the question and setup from your previous roundtable'); | |
| 70 | 88 | }) |
| 71 | 89 | .catch(() => {}); |
| 72 | 90 | return () => { |
| 73 | 91 | cancelled = true; |
| 74 | 92 | }; |
| 75 | 93 | }, [fromDebateId, prefilled]); |
| 76 | 94 | |
| 77 | - // A NO_KEY failure is a setup problem, not a debate failure: send the user | |
| 78 | - // straight to the key dialog with their composed debate intact. | |
| 79 | 95 | useEffect(() => { |
| 80 | 96 | if (errorCode !== 'NO_KEY') return; |
| 81 | 97 | setNeedsKey(true); |
| 82 | 98 | setKeyDialogOpen(true); |
| 83 | 99 | reset(); |
| 84 | 100 | }, [errorCode, reset]); |
| 85 | 101 | |
| 86 | - // Seed a sensible default council + chairman once the catalog arrives. | |
| 87 | 102 | useEffect(() => { |
| 88 | 103 | if (models.length === 0 || council.length > 0) return; |
| 89 | - const want = [ | |
| 104 | + const preferred = [ | |
| 90 | 105 | 'openai/gpt-4o', |
| 91 | 106 | 'anthropic/claude-3.5-sonnet', |
| 92 | 107 | 'google/gemini-pro-1.5', |
| 93 | - ].filter((id) => models.some((m) => m.id === id)); | |
| 94 | - const seed = want.length >= 3 ? want : models.slice(0, 3).map((m) => m.id); | |
| 95 | - setCouncil(seed); | |
| 108 | + ].filter((id) => models.some((model) => model.id === id)); | |
| 109 | + const selected = | |
| 110 | + preferred.length >= 3 ? preferred : models.slice(0, 3).map((model) => model.id); | |
| 111 | + setCouncil(selected); | |
| 96 | 112 | if (!chairman) { |
| 97 | - const chair = models.find((m) => m.id === 'x-ai/grok-2-1212') ?? models.find((m) => !seed.includes(m.id)); | |
| 98 | - if (chair) setChairman(chair.id); | |
| 113 | + const verdictModel = | |
| 114 | + models.find((model) => model.id === 'x-ai/grok-2-1212') ?? | |
| 115 | + models.find((model) => !selected.includes(model.id)); | |
| 116 | + if (verdictModel) setChairman(verdictModel.id); | |
| 99 | 117 | } |
| 100 | 118 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 101 | 119 | }, [models]); |
| 102 | 120 | |
| 103 | 121 | useEffect(() => { |
| 104 | 122 | fetch('/api/presets') |
| 105 | - .then((r) => r.json()) | |
| 106 | - .then((d: { presets: Preset[] }) => setPresets(d.presets ?? [])) | |
| 123 | + .then((response) => response.json()) | |
| 124 | + .then((data: { presets: Preset[] }) => setPresets(data.presets ?? [])) | |
| 107 | 125 | .catch(() => {}); |
| 108 | 126 | }, []); |
| 109 | 127 | |
| 110 | - const providerConflict = chairman && council.length > 0 && chairmanSharesProvider(chairman, council); | |
| 128 | + const providerConflict = | |
| 129 | + chairman && council.length > 0 && chairmanSharesProvider(chairman, council); | |
| 111 | 130 | const maxCostUsd = Number.parseFloat(maxCost); |
| 112 | 131 | const capValid = maxCost.trim() === '' || (Number.isFinite(maxCostUsd) && maxCostUsd > 0); |
| 113 | - const startBlocker = | |
| 114 | - question.trim().length < 3 | |
| 115 | - ? 'Write a question to get started' | |
| 116 | - : council.length < 3 | |
| 117 | - ? `Pick at least 3 council models (${council.length} selected)` | |
| 118 | - : council.length > 6 | |
| 119 | - ? 'A council holds at most 6 models' | |
| 120 | - : !chairman | |
| 121 | - ? 'Pick a chairman to write the final verdict' | |
| 122 | - : !capValid | |
| 123 | - ? 'Fix the budget cap (positive amount or empty)' | |
| 124 | - : null; | |
| 132 | + | |
| 133 | + let startBlocker: string | null = null; | |
| 134 | + if (question.trim().length < 3) startBlocker = 'Write a question to get started'; | |
| 135 | + else if (question.trim().length > 8000) startBlocker = 'Shorten the question to 8,000 characters'; | |
| 136 | + else if (council.length < 3) { | |
| 137 | + if (modelsLoading) startBlocker = 'Choosing your models...'; | |
| 138 | + else if (modelsError) startBlocker = 'Open settings and retry the model list'; | |
| 139 | + else startBlocker = `Choose at least 3 models (${council.length} selected)`; | |
| 140 | + } else if (council.length > 6) startBlocker = 'Choose no more than 6 models'; | |
| 141 | + else if (!chairman) startBlocker = 'Choose a model to combine the final verdict'; | |
| 142 | + else if (!capValid) startBlocker = 'Fix the spending limit'; | |
| 143 | + | |
| 125 | 144 | const canStart = startBlocker === null; |
| 126 | 145 | const running = phase === 'connecting' || phase === 'streaming'; |
| 127 | - | |
| 128 | 146 | const priceMap = useMemo( |
| 129 | - () => new Map(models.map((m) => [m.id, { prompt: m.promptPrice, completion: m.completionPrice }])), | |
| 147 | + () => | |
| 148 | + new Map( | |
| 149 | + models.map((model) => [ | |
| 150 | + model.id, | |
| 151 | + { prompt: model.promptPrice, completion: model.completionPrice }, | |
| 152 | + ]), | |
| 153 | + ), | |
| 130 | 154 | [models], |
| 131 | 155 | ); |
| 132 | 156 | const estimate = useMemo( |
| @@ -143,19 +167,19 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 143 | 167 | [canStart, council, chairman, convergenceModel, maxRounds, priceMap], |
| 144 | 168 | ); |
| 145 | 169 | |
| 146 | - const applyPreset = (p: Preset) => { | |
| 147 | - setCouncil(p.models); | |
| 148 | - setChairman(p.chairmanModel); | |
| 149 | - setConvergenceModel(p.convergenceModel ?? DEFAULT_CONVERGENCE_MODEL); | |
| 150 | - setMaxRounds(p.maxRounds); | |
| 151 | - setThreshold(p.convergenceThreshold); | |
| 152 | - setTemperature(p.temperature); | |
| 153 | - toast.success(`Loaded preset "${p.name}"`); | |
| 170 | + const applyPreset = (preset: Preset) => { | |
| 171 | + setCouncil(preset.models); | |
| 172 | + setChairman(preset.chairmanModel); | |
| 173 | + setConvergenceModel(preset.convergenceModel ?? DEFAULT_CONVERGENCE_MODEL); | |
| 174 | + setMaxRounds(preset.maxRounds); | |
| 175 | + setThreshold(preset.convergenceThreshold); | |
| 176 | + setTemperature(preset.temperature); | |
| 177 | + toast.success(`Loaded setup "${preset.name}"`); | |
| 154 | 178 | }; |
| 155 | 179 | |
| 156 | 180 | const savePreset = async () => { |
| 157 | 181 | if (!presetName.trim()) return; |
| 158 | - const res = await fetch('/api/presets', { | |
| 182 | + const response = await fetch('/api/presets', { | |
| 159 | 183 | method: 'POST', |
| 160 | 184 | headers: { 'Content-Type': 'application/json' }, |
| 161 | 185 | body: JSON.stringify({ |
| @@ -168,23 +192,23 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 168 | 192 | temperature, |
| 169 | 193 | }), |
| 170 | 194 | }); |
| 171 | - if (res.ok) { | |
| 172 | - const { preset } = (await res.json()) as { preset: Preset }; | |
| 173 | - setPresets((p) => [preset, ...p.filter((x) => x.id !== preset.id)]); | |
| 195 | + if (response.ok) { | |
| 196 | + const { preset } = (await response.json()) as { preset: Preset }; | |
| 197 | + setPresets((current) => [preset, ...current.filter((item) => item.id !== preset.id)]); | |
| 174 | 198 | setPresetName(''); |
| 175 | - toast.success('Preset saved'); | |
| 176 | - } else if (res.status === 401) { | |
| 177 | - toast.error('Sign in to save presets'); | |
| 199 | + toast.success('Setup saved'); | |
| 200 | + } else if (response.status === 401) { | |
| 201 | + toast.error('Sign in to save setups'); | |
| 178 | 202 | } else { |
| 179 | - toast.error('Could not save preset'); | |
| 203 | + toast.error('Could not save setup'); | |
| 180 | 204 | } |
| 181 | 205 | }; |
| 182 | 206 | |
| 183 | 207 | const cancelDebateRun = async () => { |
| 184 | 208 | if (!view.debateId) return; |
| 185 | 209 | try { |
| 186 | 210 | await fetch(`/api/debates/${view.debateId}/cancel`, { method: 'POST' }); |
| 187 | - toast.message('Cancelling debate...'); | |
| 211 | + toast.message('Cancelling the roundtable...'); | |
| 188 | 212 | } catch { |
| 189 | 213 | toast.error('Could not cancel'); |
| 190 | 214 | } |
| @@ -194,13 +218,14 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 194 | 218 | if (!view.debateId) return; |
| 195 | 219 | try { |
| 196 | 220 | await fetch(`/api/debates/${view.debateId}/gavel`, { method: 'POST' }); |
| 197 | - toast.message('Concluding: the chairman will synthesize the answers as they stand.'); | |
| 221 | + toast.message('Preparing a verdict from the opinions completed so far'); | |
| 198 | 222 | } catch { |
| 199 | - toast.error('Could not conclude the debate'); | |
| 223 | + toast.error('Could not prepare the verdict'); | |
| 200 | 224 | } |
| 201 | 225 | }; |
| 202 | 226 | |
| 203 | - const launch = () => | |
| 227 | + const launch = () => { | |
| 228 | + if (!canStart) return; | |
| 204 | 229 | start({ |
| 205 | 230 | question: question.trim(), |
| 206 | 231 | models: council, |
| @@ -212,180 +237,290 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 212 | 237 | perModelTimeoutMs: 90_000, |
| 213 | 238 | ...(capValid && maxCost.trim() !== '' ? { maxCostUsd } : {}), |
| 214 | 239 | }); |
| 240 | + }; | |
| 215 | 241 | |
| 216 | 242 | if (phase === 'idle') { |
| 217 | 243 | return ( |
| 218 | - <div className="mx-auto max-w-3xl"> | |
| 219 | - <div className="space-y-3 pb-10"> | |
| 220 | - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">new debate</p> | |
| 221 | - <h1 className="font-display text-3xl font-medium tracking-tight sm:text-4xl"> | |
| 222 | - Put it to the <em className="text-primary">council</em>. | |
| 244 | + <div className="mx-auto max-w-4xl"> | |
| 245 | + <div className="mx-auto max-w-2xl pb-9 text-center"> | |
| 246 | + <Badge variant="secondary" className="mb-4 rounded-full px-3 py-1"> | |
| 247 | + New question | |
| 248 | + </Badge> | |
| 249 | + <h1 className="font-display text-4xl font-semibold tracking-[-0.04em] sm:text-5xl"> | |
| 250 | + What do you need to decide? | |
| 223 | 251 | </h1> |
| 224 | - <p className="max-w-xl text-sm leading-relaxed text-muted-foreground"> | |
| 225 | - A council of models answers your question, critiques each other blind, and revises. A chairman writes the | |
| 226 | - verdict. | |
| 252 | + <p className="mx-auto mt-4 max-w-xl leading-relaxed text-muted-foreground"> | |
| 253 | + Describe the choice, what matters to you, and any limits. Roundtable will compare | |
| 254 | + independent opinions and give you one checked verdict. | |
| 227 | 255 | </p> |
| 228 | 256 | </div> |
| 229 | 257 | |
| 230 | - <FormSection number="01" label="Question"> | |
| 258 | + <section className="rounded-[1.5rem] border bg-card p-4 shadow-xl shadow-primary/5 sm:p-6"> | |
| 259 | + <Label htmlFor="debate-question" className="text-sm font-semibold"> | |
| 260 | + Your question | |
| 261 | + </Label> | |
| 231 | 262 | <textarea |
| 263 | + id="debate-question" | |
| 232 | 264 | value={question} |
| 233 | - onChange={(e) => setQuestion(e.target.value)} | |
| 234 | - onKeyDown={(e) => { | |
| 235 | - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canStart) { | |
| 236 | - e.preventDefault(); | |
| 265 | + onChange={(event) => setQuestion(event.target.value)} | |
| 266 | + onKeyDown={(event) => { | |
| 267 | + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canStart) { | |
| 268 | + event.preventDefault(); | |
| 237 | 269 | launch(); |
| 238 | 270 | } |
| 239 | 271 | }} |
| 240 | - placeholder={EXAMPLE} | |
| 241 | - rows={3} | |
| 242 | - aria-label="Debate question" | |
| 243 | - 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" | |
| 272 | + placeholder="Include your options, priorities, and constraints..." | |
| 273 | + rows={5} | |
| 274 | + autoFocus={!initialQuestion} | |
| 275 | + className="mt-3 flex w-full resize-y rounded-xl border border-input bg-background/70 px-4 py-3 text-base leading-relaxed shadow-sm placeholder:text-muted-foreground/65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 244 | 276 | /> |
| 245 | - <div className="mt-2 flex items-center justify-between"> | |
| 246 | - <button className="text-xs text-primary hover:underline" onClick={() => setQuestion(EXAMPLE)}> | |
| 247 | - Use an example question | |
| 248 | - </button> | |
| 277 | + <div className="mt-2 flex items-center justify-between gap-3"> | |
| 278 | + <span className="text-xs text-muted-foreground">{question.length} / 8,000</span> | |
| 249 | 279 | <span className="text-xs text-muted-foreground"> |
| 250 | 280 | <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Ctrl</kbd> +{' '} |
| 251 | - <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Enter</kbd> to start | |
| 281 | + <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Enter</kbd> to | |
| 282 | + start | |
| 252 | 283 | </span> |
| 253 | 284 | </div> |
| 254 | - </FormSection> | |
| 255 | - | |
| 256 | - <FormSection number="02" label="Council"> | |
| 257 | - <ModelPicker council={council} onChange={setCouncil} /> | |
| 258 | - </FormSection> | |
| 259 | - | |
| 260 | - <FormSection number="03" label="Rules"> | |
| 261 | - <div className="space-y-5"> | |
| 262 | - <div className="grid gap-4 sm:grid-cols-2"> | |
| 263 | - <div className="space-y-1.5"> | |
| 264 | - <Label>Chairman</Label> | |
| 265 | - <p className="text-xs text-muted-foreground"> | |
| 266 | - Reads every model's answer and writes the final verdict. Best chosen from outside the council. | |
| 267 | - </p> | |
| 268 | - <ModelCombobox value={chairman} onChange={setChairman} placeholder="Independent model preferred" /> | |
| 269 | - {providerConflict && ( | |
| 270 | - <p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400"> | |
| 271 | - <AlertTriangle className="h-3.5 w-3.5" /> Shares a provider family with a council member. | |
| 272 | - </p> | |
| 273 | - )} | |
| 274 | - </div> | |
| 275 | - <div className="space-y-1.5"> | |
| 276 | - <Label>Agreement checker</Label> | |
| 277 | - <p className="text-xs text-muted-foreground"> | |
| 278 | - A fast, cheap model that scores how much the council still disagrees after each round. | |
| 279 | - </p> | |
| 280 | - <ModelCombobox value={convergenceModel} onChange={setConvergenceModel} placeholder="A fast, cheap model" /> | |
| 281 | - </div> | |
| 282 | - </div> | |
| 283 | 285 | |
| 284 | - <SliderRow | |
| 285 | - label="Debate rounds" | |
| 286 | - value={maxRounds} | |
| 287 | - min={1} | |
| 288 | - max={5} | |
| 289 | - step={1} | |
| 290 | - onChange={setMaxRounds} | |
| 291 | - display={String(maxRounds)} | |
| 292 | - hint="How many times the models critique and revise each other. More rounds refine the answer but cost more." | |
| 293 | - /> | |
| 294 | - <SliderRow | |
| 295 | - label="Stop-early threshold" | |
| 296 | - value={threshold} | |
| 297 | - min={50} | |
| 298 | - max={100} | |
| 299 | - step={1} | |
| 300 | - onChange={setThreshold} | |
| 301 | - display={`${threshold}/100`} | |
| 302 | - hint="End the debate early once the council's agreement reaches this score, instead of using every round." | |
| 303 | - /> | |
| 304 | - <SliderRow | |
| 305 | - label="Creativity" | |
| 306 | - value={temperature} | |
| 307 | - min={0} | |
| 308 | - max={1.5} | |
| 309 | - step={0.1} | |
| 310 | - onChange={setTemperature} | |
| 311 | - display={temperature.toFixed(1)} | |
| 312 | - hint="Low keeps answers focused and consistent. High makes them more varied and creative." | |
| 313 | - /> | |
| 314 | - | |
| 315 | - <div className="space-y-1.5"> | |
| 316 | - <Label htmlFor="rt-budget">Budget cap (optional)</Label> | |
| 317 | - <div className="flex items-center gap-2"> | |
| 318 | - <span className="text-sm text-muted-foreground">$</span> | |
| 319 | - <Input | |
| 320 | - id="rt-budget" | |
| 321 | - inputMode="decimal" | |
| 322 | - placeholder="no cap" | |
| 323 | - value={maxCost} | |
| 324 | - onChange={(e) => setMaxCost(e.target.value)} | |
| 325 | - className="h-8 max-w-[8rem]" | |
| 326 | - /> | |
| 327 | - </div> | |
| 328 | - <p className="text-xs text-muted-foreground"> | |
| 329 | - If spend crosses this, remaining rounds are skipped and the chairman synthesizes what exists, so you | |
| 330 | - still get an answer for the money already spent. | |
| 331 | - </p> | |
| 332 | - {!capValid && ( | |
| 333 | - <p className="text-xs text-destructive">Enter a positive dollar amount, or leave empty for no cap.</p> | |
| 334 | - )} | |
| 286 | + <div className="mt-5 border-t pt-4"> | |
| 287 | + <p className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground"> | |
| 288 | + Need a starting point? | |
| 289 | + </p> | |
| 290 | + <div className="grid gap-2 sm:grid-cols-2"> | |
| 291 | + {QUESTION_EXAMPLES.map((example) => ( | |
| 292 | + <button | |
| 293 | + key={example.short} | |
| 294 | + type="button" | |
| 295 | + onClick={() => setQuestion(example.question)} | |
| 296 | + className="rounded-xl border bg-background/60 p-3 text-left transition-colors hover:border-primary/35 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 297 | + > | |
| 298 | + <span className="block text-[10px] font-bold uppercase tracking-[0.14em] text-primary"> | |
| 299 | + {example.category} | |
| 300 | + </span> | |
| 301 | + <span className="mt-1 block text-sm font-semibold">{example.short}</span> | |
| 302 | + </button> | |
| 303 | + ))} | |
| 335 | 304 | </div> |
| 336 | - | |
| 337 | - {presets.length > 0 && ( | |
| 338 | - <div className="space-y-1.5"> | |
| 339 | - <Label>Presets</Label> | |
| 340 | - <div className="flex flex-wrap gap-2"> | |
| 341 | - {presets.map((p) => ( | |
| 342 | - <Button key={p.id} variant="secondary" size="sm" onClick={() => applyPreset(p)}> | |
| 343 | - {p.name} | |
| 344 | - </Button> | |
| 345 | - ))} | |
| 346 | - </div> | |
| 347 | - </div> | |
| 305 | + </div> | |
| 306 | + </section> | |
| 307 | + | |
| 308 | + <div className="mt-5 flex flex-col gap-4 rounded-2xl border bg-card/70 p-4 sm:flex-row sm:items-center"> | |
| 309 | + <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary"> | |
| 310 | + {modelsLoading && council.length === 0 ? ( | |
| 311 | + <Loader2 className="h-5 w-5 animate-spin" /> | |
| 312 | + ) : ( | |
| 313 | + <UsersRound className="h-5 w-5" /> | |
| 348 | 314 | )} |
| 315 | + </span> | |
| 316 | + <div className="min-w-0 flex-1"> | |
| 317 | + <p className="font-semibold">Your roundtable is ready</p> | |
| 318 | + <p className="mt-0.5 text-sm text-muted-foreground"> | |
| 319 | + {council.length >= 3 | |
| 320 | + ? `${council.length} independent models, up to ${maxRounds} review ${maxRounds === 1 ? 'round' : 'rounds'}, and one final verdict.` | |
| 321 | + : 'Choosing a balanced set of models for your question.'} | |
| 322 | + </p> | |
| 323 | + </div> | |
| 324 | + {canStart && ( | |
| 325 | + <div className="shrink-0 text-xs text-muted-foreground sm:text-right"> | |
| 326 | + Estimated maximum | |
| 327 | + <strong className="ml-1 text-foreground">{formatUsd(estimate)}</strong> | |
| 328 | + </div> | |
| 329 | + )} | |
| 330 | + </div> | |
| 349 | 331 | |
| 350 | - <div className="flex items-center gap-2"> | |
| 351 | - <Input | |
| 352 | - value={presetName} | |
| 353 | - onChange={(e) => setPresetName(e.target.value)} | |
| 354 | - placeholder="Save this council as..." | |
| 355 | - className="h-8 max-w-xs" | |
| 332 | + <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-4"> | |
| 333 | + <CollapsibleTrigger asChild> | |
| 334 | + <button | |
| 335 | + type="button" | |
| 336 | + className="flex w-full items-center gap-3 rounded-xl border bg-background/70 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 337 | + > | |
| 338 | + <Settings2 className="h-4 w-4 text-muted-foreground" /> | |
| 339 | + Customize models and rules | |
| 340 | + <span className="ml-auto text-xs font-normal text-muted-foreground">Optional</span> | |
| 341 | + <ChevronDown | |
| 342 | + className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`} | |
| 356 | 343 | /> |
| 357 | - <Button variant="outline" size="sm" onClick={savePreset} disabled={!presetName.trim()}> | |
| 358 | - <Save className="h-3.5 w-3.5" /> Save | |
| 359 | - </Button> | |
| 344 | + </button> | |
| 345 | + </CollapsibleTrigger> | |
| 346 | + <CollapsibleContent> | |
| 347 | + <div className="mt-3 space-y-8 rounded-2xl border bg-card p-5 sm:p-7"> | |
| 348 | + <section> | |
| 349 | + <h2 className="font-display text-xl font-semibold">Choose the opinions</h2> | |
| 350 | + <p className="mb-4 mt-1 text-sm text-muted-foreground"> | |
| 351 | + Pick 3 to 6 models. Different providers usually give you a wider range of views. | |
| 352 | + </p> | |
| 353 | + <ModelPicker council={council} onChange={setCouncil} /> | |
| 354 | + </section> | |
| 355 | + | |
| 356 | + <section className="border-t pt-7"> | |
| 357 | + <h2 className="font-display text-xl font-semibold">Set the review rules</h2> | |
| 358 | + <p className="mb-5 mt-1 text-sm text-muted-foreground"> | |
| 359 | + The defaults work for most questions. Adjust them when you need tighter control | |
| 360 | + over cost or style. | |
| 361 | + </p> | |
| 362 | + <div className="space-y-6"> | |
| 363 | + <div className="grid gap-5 sm:grid-cols-2"> | |
| 364 | + <div className="space-y-1.5"> | |
| 365 | + <Label>Verdict model</Label> | |
| 366 | + <p className="text-xs text-muted-foreground"> | |
| 367 | + Reads every opinion and combines the final recommendation. | |
| 368 | + </p> | |
| 369 | + <ModelCombobox | |
| 370 | + value={chairman} | |
| 371 | + onChange={setChairman} | |
| 372 | + placeholder="Choose a verdict model" | |
| 373 | + /> | |
| 374 | + {providerConflict && ( | |
| 375 | + <p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400"> | |
| 376 | + <AlertTriangle className="h-3.5 w-3.5" /> This provider is also | |
| 377 | + represented in the group. | |
| 378 | + </p> | |
| 379 | + )} | |
| 380 | + </div> | |
| 381 | + <div className="space-y-1.5"> | |
| 382 | + <Label>Agreement checker</Label> | |
| 383 | + <p className="text-xs text-muted-foreground"> | |
| 384 | + Measures whether another review round is still useful. | |
| 385 | + </p> | |
| 386 | + <ModelCombobox | |
| 387 | + value={convergenceModel} | |
| 388 | + onChange={setConvergenceModel} | |
| 389 | + placeholder="Choose an agreement model" | |
| 390 | + /> | |
| 391 | + </div> | |
| 392 | + </div> | |
| 393 | + | |
| 394 | + <SliderRow | |
| 395 | + label="Review rounds" | |
| 396 | + value={maxRounds} | |
| 397 | + min={1} | |
| 398 | + max={5} | |
| 399 | + step={1} | |
| 400 | + onChange={setMaxRounds} | |
| 401 | + display={String(maxRounds)} | |
| 402 | + hint="More rounds let the models challenge and improve their answers again, but cost more." | |
| 403 | + /> | |
| 404 | + <SliderRow | |
| 405 | + label="Stop when agreement reaches" | |
| 406 | + value={threshold} | |
| 407 | + min={50} | |
| 408 | + max={100} | |
| 409 | + step={1} | |
| 410 | + onChange={setThreshold} | |
| 411 | + display={`${threshold}/100`} | |
| 412 | + hint="Roundtable stops early when the opinions have already reached this level of agreement." | |
| 413 | + /> | |
| 414 | + <SliderRow | |
| 415 | + label="Answer variety" | |
| 416 | + value={temperature} | |
| 417 | + min={0} | |
| 418 | + max={1.5} | |
| 419 | + step={0.1} | |
| 420 | + onChange={setTemperature} | |
| 421 | + display={temperature.toFixed(1)} | |
| 422 | + hint="Lower values stay focused. Higher values explore more varied approaches." | |
| 423 | + /> | |
| 424 | + | |
| 425 | + <div className="space-y-1.5"> | |
| 426 | + <Label htmlFor="rt-budget">Spending limit (optional)</Label> | |
| 427 | + <div className="flex items-center gap-2"> | |
| 428 | + <span className="text-sm text-muted-foreground">$</span> | |
| 429 | + <Input | |
| 430 | + id="rt-budget" | |
| 431 | + inputMode="decimal" | |
| 432 | + placeholder="No limit" | |
| 433 | + value={maxCost} | |
| 434 | + onChange={(event) => setMaxCost(event.target.value)} | |
| 435 | + className="h-9 max-w-[9rem]" | |
| 436 | + /> | |
| 437 | + </div> | |
| 438 | + <p className="text-xs text-muted-foreground"> | |
| 439 | + If the limit is reached, remaining rounds are skipped and you still receive a | |
| 440 | + verdict from the work completed so far. | |
| 441 | + </p> | |
| 442 | + {!capValid && ( | |
| 443 | + <p className="text-xs text-destructive"> | |
| 444 | + Enter a positive dollar amount or leave this empty. | |
| 445 | + </p> | |
| 446 | + )} | |
| 447 | + </div> | |
| 448 | + | |
| 449 | + {presets.length > 0 && ( | |
| 450 | + <div className="space-y-2"> | |
| 451 | + <Label>Saved setups</Label> | |
| 452 | + <div className="flex flex-wrap gap-2"> | |
| 453 | + {presets.map((preset) => ( | |
| 454 | + <Button | |
| 455 | + key={preset.id} | |
| 456 | + variant="secondary" | |
| 457 | + size="sm" | |
| 458 | + onClick={() => applyPreset(preset)} | |
| 459 | + > | |
| 460 | + {preset.name} | |
| 461 | + </Button> | |
| 462 | + ))} | |
| 463 | + </div> | |
| 464 | + </div> | |
| 465 | + )} | |
| 466 | + | |
| 467 | + <div className="flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 468 | + <Input | |
| 469 | + value={presetName} | |
| 470 | + onChange={(event) => setPresetName(event.target.value)} | |
| 471 | + placeholder="Name this setup" | |
| 472 | + className="h-9 max-w-xs" | |
| 473 | + /> | |
| 474 | + <Button | |
| 475 | + variant="outline" | |
| 476 | + size="sm" | |
| 477 | + onClick={savePreset} | |
| 478 | + disabled={!presetName.trim()} | |
| 479 | + > | |
| 480 | + <Save className="h-3.5 w-3.5" /> Save setup | |
| 481 | + </Button> | |
| 482 | + </div> | |
| 483 | + </div> | |
| 484 | + </section> | |
| 360 | 485 | </div> |
| 361 | - </div> | |
| 362 | - </FormSection> | |
| 486 | + </CollapsibleContent> | |
| 487 | + </Collapsible> | |
| 363 | 488 | |
| 364 | - <div className="space-y-4 border-t pt-6"> | |
| 489 | + <div className="mt-6 space-y-4"> | |
| 365 | 490 | {needsKey && ( |
| 366 | - <div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm"> | |
| 491 | + <div className="flex flex-wrap items-center gap-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-sm"> | |
| 367 | 492 | <KeyRound className="h-4 w-4 text-amber-500" /> |
| 368 | - <span>Running a real debate needs an OpenRouter key. Add one, then press Start again.</span> | |
| 369 | - <Button variant="outline" size="sm" className="ml-auto" onClick={() => setKeyDialogOpen(true)}> | |
| 370 | - Add key | |
| 493 | + <span>Connect your models once, then your question will stay ready here.</span> | |
| 494 | + <Button | |
| 495 | + variant="outline" | |
| 496 | + size="sm" | |
| 497 | + className="ml-auto" | |
| 498 | + onClick={() => setKeyDialogOpen(true)} | |
| 499 | + > | |
| 500 | + Connect models | |
| 371 | 501 | </Button> |
| 372 | 502 | </div> |
| 373 | 503 | )} |
| 374 | 504 | |
| 375 | 505 | {errorMsg && ( |
| 376 | - <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> | |
| 506 | + <div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> | |
| 377 | 507 | <AlertTriangle className="h-4 w-4" /> {errorMsg} |
| 378 | 508 | </div> |
| 379 | 509 | )} |
| 380 | 510 | |
| 381 | - <div className="sticky bottom-4 flex items-center justify-end gap-3 pb-2"> | |
| 382 | - <span className="rounded-md bg-background/80 px-2.5 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"> | |
| 511 | + <div className="sticky bottom-4 flex flex-col gap-3 rounded-2xl border bg-background/90 p-3 shadow-xl backdrop-blur sm:flex-row sm:items-center sm:justify-between"> | |
| 512 | + <span className="px-1 text-xs text-muted-foreground"> | |
| 383 | 513 | {canStart |
| 384 | - ? `~${formatUsd(estimate)} est. · up to ${maxRounds} ${maxRounds === 1 ? 'round' : 'rounds'}` | |
| 514 | + ? `Estimated maximum ${formatUsd(estimate)}. You will see live progress.` | |
| 385 | 515 | : startBlocker} |
| 386 | 516 | </span> |
| 387 | - <Button size="lg" disabled={!canStart} onClick={launch} className="shadow-lg"> | |
| 388 | - <Play className="h-4 w-4" /> Start debate | |
| 517 | + <Button | |
| 518 | + size="lg" | |
| 519 | + disabled={!canStart} | |
| 520 | + onClick={launch} | |
| 521 | + className="rounded-xl px-7 shadow-lg" | |
| 522 | + > | |
| 523 | + <Play className="h-4 w-4" /> Get my verdict | |
| 389 | 524 | </Button> |
| 390 | 525 | </div> |
| 391 | 526 | </div> |
| @@ -404,18 +539,18 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 404 | 539 | |
| 405 | 540 | return ( |
| 406 | 541 | <div className="space-y-4"> |
| 407 | - <div className="flex items-center justify-between"> | |
| 542 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 408 | 543 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| 409 | 544 | {running ? ( |
| 410 | 545 | <> |
| 411 | - <Loader2 className="h-4 w-4 animate-spin text-primary" /> Debate in progress - you can leave; it | |
| 412 | - continues server-side. | |
| 546 | + <Loader2 className="h-4 w-4 animate-spin text-primary" /> Roundtable is working. You | |
| 547 | + can leave this page and it will continue. | |
| 413 | 548 | </> |
| 414 | 549 | ) : ( |
| 415 | - <Badge variant="secondary">Finished</Badge> | |
| 550 | + <Badge variant="success">Verdict ready</Badge> | |
| 416 | 551 | )} |
| 417 | 552 | </div> |
| 418 | - <div className="flex gap-2"> | |
| 553 | + <div className="flex flex-wrap gap-2"> | |
| 419 | 554 | {running && ( |
| 420 | 555 | <> |
| 421 | 556 | <Button variant="outline" size="sm" onClick={cancel}> |
| @@ -426,23 +561,23 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 426 | 561 | size="sm" |
| 427 | 562 | onClick={concludeNow} |
| 428 | 563 | disabled={!view.debateId || Boolean(view.gavelStruck)} |
| 429 | - title="Skip remaining rounds and have the chairman synthesize now" | |
| 564 | + title="Skip remaining reviews and prepare the final verdict now" | |
| 430 | 565 | > |
| 431 | - <Gavel className="h-3.5 w-3.5" /> Conclude now | |
| 566 | + <Gavel className="h-3.5 w-3.5" /> Get verdict now | |
| 432 | 567 | </Button> |
| 433 | 568 | <Button variant="destructive" size="sm" onClick={cancelDebateRun}> |
| 434 | - <Square className="h-3.5 w-3.5" /> Cancel debate | |
| 569 | + <Square className="h-3.5 w-3.5" /> Cancel | |
| 435 | 570 | </Button> |
| 436 | 571 | </> |
| 437 | 572 | )} |
| 438 | 573 | <Button variant="outline" size="sm" onClick={reset}> |
| 439 | - <RotateCcw className="h-3.5 w-3.5" /> New debate | |
| 574 | + <RotateCcw className="h-3.5 w-3.5" /> New question | |
| 440 | 575 | </Button> |
| 441 | 576 | </div> |
| 442 | 577 | </div> |
| 443 | 578 | |
| 444 | 579 | {errorMsg && ( |
| 445 | - <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> | |
| 580 | + <div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> | |
| 446 | 581 | <AlertTriangle className="h-4 w-4" /> {errorMsg} |
| 447 | 582 | </div> |
| 448 | 583 | )} |
| @@ -452,30 +587,6 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) { | ||
| 452 | 587 | ); |
| 453 | 588 | } |
| 454 | 589 | |
| 455 | -/** | |
| 456 | - * Editorial form section: numbered mono label on a rail, content beside it, | |
| 457 | - * hairline rule above. Replaces the stacked-card look. | |
| 458 | - */ | |
| 459 | -function FormSection({ | |
| 460 | - number, | |
| 461 | - label, | |
| 462 | - children, | |
| 463 | -}: { | |
| 464 | - number: string; | |
| 465 | - label: string; | |
| 466 | - children: React.ReactNode; | |
| 467 | -}) { | |
| 468 | - return ( | |
| 469 | - <section className="grid gap-4 border-t py-8 sm:grid-cols-[9rem_1fr] sm:gap-8"> | |
| 470 | - <div className="flex items-baseline gap-2 sm:block"> | |
| 471 | - <span className="font-mono text-xs text-muted-foreground/60">{number}</span> | |
| 472 | - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground sm:mt-1.5">{label}</h2> | |
| 473 | - </div> | |
| 474 | - <div className="min-w-0">{children}</div> | |
| 475 | - </section> | |
| 476 | - ); | |
| 477 | -} | |
| 478 | - | |
| 479 | 590 | function SliderRow({ |
| 480 | 591 | label, |
| 481 | 592 | value, |
| @@ -491,7 +602,7 @@function SliderRow({ | ||
| 491 | 602 | min: number; |
| 492 | 603 | max: number; |
| 493 | 604 | step: number; |
| 494 | - onChange: (v: number) => void; | |
| 605 | + onChange: (value: number) => void; | |
| 495 | 606 | display: string; |
| 496 | 607 | hint?: string; |
| 497 | 608 | }) { |
| @@ -501,7 +612,13 @@function SliderRow({ | ||
| 501 | 612 | <Label>{label}</Label> |
| 502 | 613 | <span className="font-mono text-xs text-muted-foreground">{display}</span> |
| 503 | 614 | </div> |
| 504 | - <Slider value={[value]} min={min} max={max} step={step} onValueChange={(v) => onChange(v[0]!)} /> | |
| 615 | + <Slider | |
| 616 | + value={[value]} | |
| 617 | + min={min} | |
| 618 | + max={max} | |
| 619 | + step={step} | |
| 620 | + onValueChange={(values) => onChange(values[0]!)} | |
| 621 | + /> | |
| 505 | 622 | {hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>} |
| 506 | 623 | </div> |
| 507 | 624 | ); |
modified src/components/debate/stage-timeline.tsx +16 −10
| @@ -55,7 +55,9 @@export function StageTimeline({ view }: { view: DebateView }) { | ||
| 55 | 55 | </span> |
| 56 | 56 | <span className="leading-tight"> |
| 57 | 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>} | |
| 58 | + {node.sub && ( | |
| 59 | + <span className="block text-[11px] text-muted-foreground">{node.sub}</span> | |
| 60 | + )} | |
| 59 | 61 | </span> |
| 60 | 62 | </button> |
| 61 | 63 | {i < nodes.length - 1 && <span className="mx-1 h-px w-5 bg-border" />} |
| @@ -73,8 +75,8 @@function buildNodes(view: DebateView): TimelineNode[] { | ||
| 73 | 75 | |
| 74 | 76 | nodes.push({ |
| 75 | 77 | key: 'answers', |
| 76 | - label: 'Round 0', | |
| 77 | - sub: 'Answers', | |
| 78 | + label: 'Opinions', | |
| 79 | + sub: 'Independent', | |
| 78 | 80 | icon: <MessagesSquare className="h-4 w-4" />, |
| 79 | 81 | status: answersActive ? 'active' : answersDone ? 'done' : 'pending', |
| 80 | 82 | accent: 'var(--stage-answer)', |
| @@ -87,8 +89,8 @@function buildNodes(view: DebateView): TimelineNode[] { | ||
| 87 | 89 | const isActiveRound = view.activeStage?.round === r; |
| 88 | 90 | nodes.push({ |
| 89 | 91 | key: `crit-${r}`, |
| 90 | - label: `Round ${r}`, | |
| 91 | - sub: 'Critique', | |
| 92 | + label: `Review ${r}`, | |
| 93 | + sub: 'Challenge', | |
| 92 | 94 | icon: <Scale className="h-4 w-4" />, |
| 93 | 95 | status: round?.critiques.length |
| 94 | 96 | ? 'done' |
| @@ -100,8 +102,8 @@function buildNodes(view: DebateView): TimelineNode[] { | ||
| 100 | 102 | }); |
| 101 | 103 | nodes.push({ |
| 102 | 104 | key: `rev-${r}`, |
| 103 | - label: `Round ${r}`, | |
| 104 | - sub: 'Revision', | |
| 105 | + label: `Review ${r}`, | |
| 106 | + sub: 'Improve', | |
| 105 | 107 | icon: <PencilLine className="h-4 w-4" />, |
| 106 | 108 | status: round?.revisions.length |
| 107 | 109 | ? 'done' |
| @@ -115,10 +117,14 @@function buildNodes(view: DebateView): TimelineNode[] { | ||
| 115 | 117 | |
| 116 | 118 | nodes.push({ |
| 117 | 119 | key: 'synthesis', |
| 118 | - label: 'Synthesis', | |
| 119 | - sub: 'Chairman', | |
| 120 | + label: 'Verdict', | |
| 121 | + sub: 'Combine', | |
| 120 | 122 | icon: <Gavel className="h-4 w-4" />, |
| 121 | - status: view.synthesis ? 'done' : view.activeStage?.stage === 'synthesis' ? 'active' : 'pending', | |
| 123 | + status: view.synthesis | |
| 124 | + ? 'done' | |
| 125 | + : view.activeStage?.stage === 'synthesis' | |
| 126 | + ? 'active' | |
| 127 | + : 'pending', | |
| 122 | 128 | accent: 'var(--stage-synthesis)', |
| 123 | 129 | anchor: 'section-final', |
| 124 | 130 | }); |
added src/components/decision-starter.tsx +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { ArrowRight } from 'lucide-react'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useState } from 'react'; | |
| 6 | +import { Button } from '@/components/ui/button'; | |
| 7 | +import { QUESTION_EXAMPLES } from '@/lib/question-examples'; | |
| 8 | + | |
| 9 | +export function DecisionStarter() { | |
| 10 | + const router = useRouter(); | |
| 11 | + const [question, setQuestion] = useState(''); | |
| 12 | + | |
| 13 | + const submit = () => { | |
| 14 | + const value = question.trim(); | |
| 15 | + if (value.length < 3) return; | |
| 16 | + router.push(`/debate?q=${encodeURIComponent(value)}`); | |
| 17 | + }; | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <div> | |
| 21 | + <form | |
| 22 | + onSubmit={(event) => { | |
| 23 | + event.preventDefault(); | |
| 24 | + submit(); | |
| 25 | + }} | |
| 26 | + className="rounded-[1.35rem] border bg-card p-2 shadow-xl shadow-primary/10 ring-1 ring-foreground/[0.03]" | |
| 27 | + > | |
| 28 | + <label htmlFor="home-question" className="sr-only"> | |
| 29 | + What do you want help deciding? | |
| 30 | + </label> | |
| 31 | + <textarea | |
| 32 | + id="home-question" | |
| 33 | + value={question} | |
| 34 | + onChange={(event) => setQuestion(event.target.value)} | |
| 35 | + rows={3} | |
| 36 | + maxLength={1600} | |
| 37 | + placeholder="What do you want help deciding?" | |
| 38 | + className="block w-full resize-none bg-transparent px-3 py-3 text-base leading-relaxed outline-none placeholder:text-muted-foreground/70" | |
| 39 | + /> | |
| 40 | + <div className="flex items-center justify-between gap-3 border-t px-2 pt-2"> | |
| 41 | + <span className="hidden text-xs text-muted-foreground sm:block"> | |
| 42 | + Add your options, priorities, and limits. | |
| 43 | + </span> | |
| 44 | + <Button | |
| 45 | + type="submit" | |
| 46 | + size="lg" | |
| 47 | + disabled={question.trim().length < 3} | |
| 48 | + className="ml-auto rounded-xl" | |
| 49 | + > | |
| 50 | + Ask the table | |
| 51 | + <ArrowRight className="h-4 w-4" /> | |
| 52 | + </Button> | |
| 53 | + </div> | |
| 54 | + </form> | |
| 55 | + | |
| 56 | + <div className="mt-4 flex flex-wrap gap-2"> | |
| 57 | + <span className="py-1.5 text-xs font-medium text-muted-foreground">Try one:</span> | |
| 58 | + {QUESTION_EXAMPLES.slice(0, 3).map((example) => ( | |
| 59 | + <button | |
| 60 | + key={example.short} | |
| 61 | + type="button" | |
| 62 | + onClick={() => setQuestion(example.question)} | |
| 63 | + className="rounded-full border bg-background/70 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 64 | + > | |
| 65 | + {example.short} | |
| 66 | + </button> | |
| 67 | + ))} | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + ); | |
| 71 | +} |
modified src/components/history-list.tsx +11 −6
| @@ -26,7 +26,7 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) { | ||
| 26 | 26 | setDeleting(id); |
| 27 | 27 | const res = await fetch(`/api/debates/${id}`, { method: 'DELETE' }); |
| 28 | 28 | if (res.ok) { |
| 29 | - toast.success('Debate deleted'); | |
| 29 | + toast.success('Question deleted'); | |
| 30 | 30 | router.refresh(); |
| 31 | 31 | } else { |
| 32 | 32 | toast.error('Could not delete'); |
| @@ -48,7 +48,9 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) { | ||
| 48 | 48 | |
| 49 | 49 | {filtered.length === 0 ? ( |
| 50 | 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.'} | |
| 51 | + {debates.length === 0 | |
| 52 | + ? 'No questions yet. Ask one to see it here.' | |
| 53 | + : 'No questions match your search.'} | |
| 52 | 54 | </div> |
| 53 | 55 | ) : ( |
| 54 | 56 | <ul className="space-y-2"> |
| @@ -64,13 +66,16 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) { | ||
| 64 | 66 | </div> |
| 65 | 67 | <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> |
| 66 | 68 | <span className="flex items-center gap-1"> |
| 67 | - <MessageSquare className="h-3 w-3" /> {d.models.length} models · {d.roundsCompleted} rounds | |
| 69 | + <MessageSquare className="h-3 w-3" /> {d.models.length} models ·{' '} | |
| 70 | + {d.roundsCompleted} rounds | |
| 68 | 71 | </span> |
| 69 | 72 | <span className="flex items-center gap-1"> |
| 70 | 73 | <Coins className="h-3 w-3" /> {formatUsd(d.totalCostUsd)} |
| 71 | 74 | </span> |
| 72 | 75 | <span>{formatRelativeTime(d.createdAt)}</span> |
| 73 | - <span className="hidden truncate sm:inline">{d.models.map(displayNameForModel).join(' · ')}</span> | |
| 76 | + <span className="hidden truncate sm:inline"> | |
| 77 | + {d.models.map(displayNameForModel).join(' · ')} | |
| 78 | + </span> | |
| 74 | 79 | </div> |
| 75 | 80 | </Link> |
| 76 | 81 | {d.shareToken && <Badge variant="secondary">shared</Badge>} |
| @@ -79,7 +84,7 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) { | ||
| 79 | 84 | size="icon" |
| 80 | 85 | className="opacity-0 transition-opacity group-hover:opacity-100" |
| 81 | 86 | asChild |
| 82 | - aria-label="Re-run with this question and council" | |
| 87 | + aria-label="Ask this question again with the same models" | |
| 83 | 88 | > |
| 84 | 89 | <Link href={`/debate?from=${d.id}`}> |
| 85 | 90 | <RotateCcw className="h-4 w-4" /> |
| @@ -91,7 +96,7 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) { | ||
| 91 | 96 | className="opacity-0 transition-opacity group-hover:opacity-100" |
| 92 | 97 | disabled={deleting === d.id} |
| 93 | 98 | onClick={() => remove(d.id)} |
| 94 | - aria-label="Delete debate" | |
| 99 | + aria-label="Delete question" | |
| 95 | 100 | > |
| 96 | 101 | <Trash2 className="h-4 w-4" /> |
| 97 | 102 | </Button> |
modified src/components/key-button.tsx +15 −4
| @@ -8,12 +8,17 @@import { cn } from '@/lib/utils'; | ||
| 8 | 8 | |
| 9 | 9 | export function KeyButton() { |
| 10 | 10 | const [open, setOpen] = useState(false); |
| 11 | - const [state, setState] = useState<{ hasKey: boolean; mock: boolean }>({ hasKey: false, mock: false }); | |
| 11 | + const [state, setState] = useState<{ hasKey: boolean; mock: boolean }>({ | |
| 12 | + hasKey: false, | |
| 13 | + mock: false, | |
| 14 | + }); | |
| 12 | 15 | |
| 13 | 16 | const load = () => |
| 14 | 17 | fetch('/api/keys') |
| 15 | 18 | .then((r) => r.json()) |
| 16 | - .then((s: { hasKey: boolean; mockMode: boolean }) => setState({ hasKey: s.hasKey, mock: s.mockMode })) | |
| 19 | + .then((s: { hasKey: boolean; mockMode: boolean }) => | |
| 20 | + setState({ hasKey: s.hasKey, mock: s.mockMode }), | |
| 21 | + ) | |
| 17 | 22 | .catch(() => {}); |
| 18 | 23 | |
| 19 | 24 | useEffect(() => { |
| @@ -26,11 +31,17 @@export function KeyButton() { | ||
| 26 | 31 | <span |
| 27 | 32 | className={cn( |
| 28 | 33 | 'h-2 w-2 rounded-full', |
| 29 | - state.mock ? 'bg-amber-500' : state.hasKey ? 'bg-emerald-500' : 'bg-muted-foreground/40', | |
| 34 | + state.mock | |
| 35 | + ? 'bg-amber-500' | |
| 36 | + : state.hasKey | |
| 37 | + ? 'bg-emerald-500' | |
| 38 | + : 'bg-muted-foreground/40', | |
| 30 | 39 | )} |
| 31 | 40 | /> |
| 32 | 41 | <KeyRound className="h-4 w-4" /> |
| 33 | - <span className="hidden sm:inline">{state.mock ? 'Mock' : state.hasKey ? 'Key set' : 'Add key'}</span> | |
| 42 | + <span className="hidden sm:inline"> | |
| 43 | + {state.mock ? 'Demo models' : state.hasKey ? 'Models connected' : 'Connect models'} | |
| 44 | + </span> | |
| 34 | 45 | </Button> |
| 35 | 46 | <ApiKeyDialog open={open} onOpenChange={setOpen} onChanged={load} /> |
| 36 | 47 | </> |
modified src/components/landing-background.tsx +5 −34
| @@ -1,40 +1,11 @@ | ||
| 1 | -/** | |
| 2 | - * Atmospheric backdrop for the landing page: soft glows in the four debate-stage | |
| 3 | - * colors (answer / critique / revision / synthesis) over a fine film grain, so | |
| 4 | - * the palette is tied to the actual product rather than a generic gradient. | |
| 5 | - */ | |
| 6 | 1 | export function LandingBackground() { |
| 7 | 2 | return ( |
| 8 | 3 | <div aria-hidden className="pointer-events-none fixed inset-0 -z-10 overflow-hidden"> |
| 9 | - <Glow className="-left-[12%] -top-[15%] h-[46rem] w-[46rem]" color="var(--stage-answer)" opacity={0.18} /> | |
| 10 | - <Glow className="-right-[10%] top-[2%] h-[40rem] w-[40rem]" color="var(--stage-revision)" opacity={0.15} delay="-6s" /> | |
| 11 | - <Glow className="left-[28%] top-[34%] h-[38rem] w-[38rem]" color="var(--stage-synthesis)" opacity={0.11} delay="-12s" /> | |
| 12 | - <Glow className="right-[18%] top-[48%] h-[30rem] w-[30rem]" color="var(--stage-critique)" opacity={0.1} delay="-3s" /> | |
| 13 | - <div className="absolute inset-0 bg-grain opacity-[0.35] mix-blend-soft-light dark:opacity-[0.4]" /> | |
| 14 | - <div className="absolute inset-x-0 bottom-0 h-72 bg-gradient-to-b from-transparent to-background" /> | |
| 4 | + <div className="absolute inset-x-0 top-0 h-[52rem] bg-[radial-gradient(circle_at_78%_22%,hsl(var(--primary)/0.14),transparent_34%),radial-gradient(circle_at_12%_8%,hsl(var(--stage-answer)/0.08),transparent_28%)]" /> | |
| 5 | + <div className="decision-grid absolute inset-x-0 top-0 h-[42rem] opacity-35 [mask-image:linear-gradient(to_bottom,black,transparent)] dark:opacity-20" /> | |
| 6 | + <div className="absolute right-[-16rem] top-[-11rem] h-[44rem] w-[44rem] rounded-full border border-primary/10" /> | |
| 7 | + <div className="absolute right-[-11rem] top-[-6rem] h-[34rem] w-[34rem] rounded-full border border-primary/10" /> | |
| 8 | + <div className="absolute right-[-6rem] top-[-1rem] h-[24rem] w-[24rem] rounded-full border border-primary/10" /> | |
| 15 | 9 | </div> |
| 16 | 10 | ); |
| 17 | 11 | } |
| 18 | - | |
| 19 | -function Glow({ | |
| 20 | - className, | |
| 21 | - color, | |
| 22 | - opacity, | |
| 23 | - delay = '0s', | |
| 24 | -}: { | |
| 25 | - className: string; | |
| 26 | - color: string; | |
| 27 | - opacity: number; | |
| 28 | - delay?: string; | |
| 29 | -}) { | |
| 30 | - return ( | |
| 31 | - <div | |
| 32 | - className={`animate-drift absolute rounded-full blur-[130px] ${className}`} | |
| 33 | - style={{ | |
| 34 | - background: `radial-gradient(circle, hsl(${color}) 0%, transparent 70%)`, | |
| 35 | - opacity, | |
| 36 | - animationDelay: delay, | |
| 37 | - }} | |
| 38 | - /> | |
| 39 | - ); | |
| 40 | -} |
modified src/components/landing-hero.tsx +163 −132
| @@ -1,154 +1,185 @@ | ||
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { ArrowRight, Pause, Play } from 'lucide-react'; | |
| 1 | +import { ArrowUpRight, Check, Gavel, UsersRound } from 'lucide-react'; | |
| 4 | 2 | import Link from 'next/link'; |
| 5 | -import { useEffect, useState } from 'react'; | |
| 6 | -import { CouncilNodes } from '@/components/debate/council-nodes'; | |
| 7 | -import { DebateConsole } from '@/components/debate/debate-console'; | |
| 8 | -import { Button } from '@/components/ui/button'; | |
| 3 | +import { DecisionStarter } from '@/components/decision-starter'; | |
| 4 | +import { Badge } from '@/components/ui/badge'; | |
| 9 | 5 | import type { DebateResult } from '@/core/types'; |
| 10 | -import { useDebatePlayback } from '@/hooks/use-debate-playback'; | |
| 11 | 6 | import { truncate } from '@/lib/utils'; |
| 12 | 7 | |
| 13 | -/** | |
| 14 | - * The landing hero: a real recorded debate, front and center. It loads showing | |
| 15 | - * the finished verdict - nothing moving - so a visitor gets the payoff at once. | |
| 16 | - * Pressing play replays the whole deliberation live (streaming tokens, critique, | |
| 17 | - * revision, synthesis); it settles back on the answer and never loops on its own. | |
| 18 | - */ | |
| 19 | -export function LandingHero({ result }: { result: DebateResult }) { | |
| 20 | - const { view, state, progress, play, pause, restart, skipToEnd, setSpeed } = useDebatePlayback(result); | |
| 21 | - const [hasPlayed, setHasPlayed] = useState(false); | |
| 22 | - | |
| 23 | - // Load on the final answer, ready to read. We only animate on demand. | |
| 24 | - useEffect(() => { | |
| 25 | - setSpeed(2); | |
| 26 | - skipToEnd(); | |
| 27 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 28 | - }, []); | |
| 8 | +const FALLBACK_QUESTION = 'Should I take the higher-paying startup job or stay in my stable role?'; | |
| 9 | +const FALLBACK_OPINIONS = [ | |
| 10 | + { | |
| 11 | + name: 'Growth view', | |
| 12 | + content: | |
| 13 | + 'Take the startup role if the manager is strong and you have enough savings for the added risk.', | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + name: 'Risk view', | |
| 17 | + content: | |
| 18 | + 'Stay unless the new role gives you specific skills and ownership that your current job cannot offer.', | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + name: 'Values view', | |
| 22 | + content: | |
| 23 | + 'Choose the role that fits the life you want for the next two years, not salary alone.', | |
| 24 | + }, | |
| 25 | +]; | |
| 26 | +const FALLBACK_VERDICT = | |
| 27 | + 'Take the startup role only if it clearly improves your manager, ownership, and learning. Keep the stable role if the move is mainly about salary.'; | |
| 29 | 28 | |
| 30 | - const isLive = state === 'playing'; | |
| 31 | - const watch = () => { | |
| 32 | - setHasPlayed(true); | |
| 33 | - restart(); | |
| 34 | - }; | |
| 29 | +export function LandingHero({ result }: { result?: DebateResult | null }) { | |
| 30 | + const question = result?.config.question ?? FALLBACK_QUESTION; | |
| 31 | + const opinions = | |
| 32 | + result?.initialAnswers.slice(0, 3).map((answer) => ({ | |
| 33 | + name: | |
| 34 | + result.participants.find((participant) => participant.id === answer.participantId) | |
| 35 | + ?.displayName ?? 'AI view', | |
| 36 | + content: compact(answer.content, 150), | |
| 37 | + })) ?? FALLBACK_OPINIONS; | |
| 38 | + const verdict = compact(result?.synthesis?.finalAnswer ?? FALLBACK_VERDICT, 320); | |
| 35 | 39 | |
| 36 | 40 | return ( |
| 37 | - <section className="container py-10 md:py-14"> | |
| 38 | - <div className="mx-auto mb-9 max-w-2xl text-center"> | |
| 39 | - <div | |
| 40 | - className="reveal mb-5 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground" | |
| 41 | - style={{ animationDelay: '0ms' }} | |
| 42 | - > | |
| 43 | - {isLive ? ( | |
| 44 | - <> | |
| 45 | - <span className="h-1.5 w-1.5 animate-pulse-subtle rounded-full bg-emerald-500" /> | |
| 46 | - the council is in session | |
| 47 | - </> | |
| 48 | - ) : ( | |
| 49 | - <> | |
| 50 | - <span className="h-1.5 w-1.5 rounded-full bg-primary/60" /> | |
| 51 | - one real debate, start to finish | |
| 52 | - </> | |
| 53 | - )} | |
| 54 | - </div> | |
| 55 | - <h1 | |
| 56 | - className="reveal font-display text-4xl font-medium leading-[1.04] tracking-[-0.01em] sm:text-[4.25rem]" | |
| 57 | - style={{ animationDelay: '80ms' }} | |
| 58 | - > | |
| 59 | - Watch models <em className="text-primary">argue</em> their way to a better answer. | |
| 60 | - </h1> | |
| 61 | - <p | |
| 62 | - className="reveal mx-auto mt-5 max-w-xl text-pretty text-lg leading-relaxed text-muted-foreground" | |
| 63 | - style={{ animationDelay: '160ms' }} | |
| 64 | - > | |
| 65 | - Below is the verdict from a real debate. Press play to watch how the council got there: they answer, critique | |
| 66 | - each other blind, and revise, before a chairman calls it. | |
| 67 | - </p> | |
| 68 | - <div | |
| 69 | - className="reveal mt-7 flex flex-wrap items-center justify-center gap-3" | |
| 70 | - style={{ animationDelay: '240ms' }} | |
| 71 | - > | |
| 72 | - <Button size="lg" asChild className="group"> | |
| 73 | - <Link href="/debate"> | |
| 74 | - Start a debate | |
| 75 | - <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" /> | |
| 41 | + <section className="container relative py-14 sm:py-20 lg:py-24"> | |
| 42 | + <div className="grid items-center gap-14 lg:grid-cols-[minmax(0,1.02fr)_minmax(28rem,0.98fr)] lg:gap-16"> | |
| 43 | + <div className="relative z-10"> | |
| 44 | + <Badge variant="secondary" className="mb-6 gap-2 rounded-full px-3 py-1.5 font-medium"> | |
| 45 | + <UsersRound className="h-3.5 w-3.5 text-primary" /> | |
| 46 | + One question, several independent opinions | |
| 47 | + </Badge> | |
| 48 | + <h1 className="max-w-3xl font-display text-5xl font-semibold leading-[0.98] tracking-[-0.045em] sm:text-6xl lg:text-7xl"> | |
| 49 | + Ask once. Get the <span className="text-primary">whole picture.</span> | |
| 50 | + </h1> | |
| 51 | + <p className="mt-6 max-w-xl text-lg leading-relaxed text-muted-foreground sm:text-xl"> | |
| 52 | + Roundtable asks leading AI models separately, lets them challenge each other, then gives | |
| 53 | + you one clear recommendation and shows where they disagree. | |
| 54 | + </p> | |
| 55 | + <div className="mt-8 max-w-2xl"> | |
| 56 | + <DecisionStarter /> | |
| 57 | + </div> | |
| 58 | + <div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground"> | |
| 59 | + <span className="flex items-center gap-1.5"> | |
| 60 | + <Check className="h-3.5 w-3.5 text-emerald-500" /> Smart defaults included | |
| 61 | + </span> | |
| 62 | + <span className="flex items-center gap-1.5"> | |
| 63 | + <Check className="h-3.5 w-3.5 text-emerald-500" /> Every opinion stays visible | |
| 64 | + </span> | |
| 65 | + <Link | |
| 66 | + href="/demo" | |
| 67 | + className="font-medium text-foreground underline-offset-4 hover:underline" | |
| 68 | + > | |
| 69 | + See example verdicts | |
| 76 | 70 | </Link> |
| 77 | - </Button> | |
| 78 | - <Button size="lg" variant="ghost" asChild> | |
| 79 | - <Link href="/demo">Browse the recordings</Link> | |
| 80 | - </Button> | |
| 71 | + </div> | |
| 81 | 72 | </div> |
| 82 | - </div> | |
| 83 | 73 | |
| 84 | - <div className="reveal mb-6 flex justify-center" style={{ animationDelay: '320ms' }}> | |
| 85 | - <CouncilNodes participants={result.participants} chairmanModel={result.config.chairmanModel} /> | |
| 74 | + <VerdictStack | |
| 75 | + question={question} | |
| 76 | + opinions={opinions} | |
| 77 | + verdict={verdict} | |
| 78 | + demoId={result?.debateId} | |
| 79 | + /> | |
| 86 | 80 | </div> |
| 81 | + </section> | |
| 82 | + ); | |
| 83 | +} | |
| 87 | 84 | |
| 88 | - <div className="reveal relative mx-auto max-w-5xl" style={{ animationDelay: '400ms' }}> | |
| 89 | - <div className="pointer-events-none absolute -inset-x-4 -top-4 bottom-4 -z-10 rounded-[2.5rem] bg-primary/[0.07] blur-3xl" /> | |
| 90 | - <div className="relative overflow-hidden rounded-2xl border bg-card shadow-2xl ring-1 ring-black/5 dark:ring-white/10"> | |
| 91 | - <div className="absolute inset-x-0 top-0 z-20 h-0.5 bg-transparent"> | |
| 92 | - <div | |
| 93 | - className="h-full bg-gradient-to-r from-primary to-violet-400 transition-all duration-300" | |
| 94 | - style={{ width: `${progress}%` }} | |
| 95 | - /> | |
| 85 | +function VerdictStack({ | |
| 86 | + question, | |
| 87 | + opinions, | |
| 88 | + verdict, | |
| 89 | + demoId, | |
| 90 | +}: { | |
| 91 | + question: string; | |
| 92 | + opinions: { name: string; content: string }[]; | |
| 93 | + verdict: string; | |
| 94 | + demoId?: string; | |
| 95 | +}) { | |
| 96 | + return ( | |
| 97 | + <div className="relative mx-auto w-full max-w-xl"> | |
| 98 | + <div className="absolute -inset-8 -z-10 rounded-full bg-primary/10 blur-3xl" /> | |
| 99 | + <div className="rounded-[1.75rem] border bg-card/95 p-4 shadow-2xl shadow-foreground/10 ring-1 ring-foreground/[0.04] sm:p-5"> | |
| 100 | + <div className="flex items-center justify-between gap-4 border-b pb-4"> | |
| 101 | + <div className="min-w-0"> | |
| 102 | + <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> | |
| 103 | + Question on the table | |
| 104 | + </p> | |
| 105 | + <p className="mt-1.5 text-sm font-semibold leading-snug">{truncate(question, 120)}</p> | |
| 96 | 106 | </div> |
| 97 | - <div className="flex items-center gap-3 border-b bg-muted/30 px-4 py-2.5"> | |
| 98 | - <span className="flex shrink-0 gap-1.5"> | |
| 99 | - <span className="h-2.5 w-2.5 rounded-full bg-red-400/70" /> | |
| 100 | - <span className="h-2.5 w-2.5 rounded-full bg-amber-400/70" /> | |
| 101 | - <span className="h-2.5 w-2.5 rounded-full bg-emerald-400/70" /> | |
| 102 | - </span> | |
| 103 | - <div className="mx-auto hidden max-w-md flex-1 items-center justify-center rounded-md bg-background/60 px-3 py-1 sm:flex"> | |
| 104 | - <span className="truncate font-mono text-[11px] text-muted-foreground"> | |
| 105 | - {truncate(view.question || result.config.question, 64)} | |
| 106 | - </span> | |
| 107 | - </div> | |
| 108 | - {isLive ? ( | |
| 109 | - <span className="ml-auto flex shrink-0 items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-500 sm:ml-0"> | |
| 110 | - <span className="h-1.5 w-1.5 animate-pulse-subtle rounded-full bg-emerald-500" /> | |
| 111 | - live | |
| 112 | - </span> | |
| 113 | - ) : ( | |
| 114 | - <span className="ml-auto flex shrink-0 items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground sm:ml-0"> | |
| 115 | - verdict | |
| 107 | + <div | |
| 108 | + className="flex shrink-0 -space-x-2" | |
| 109 | + aria-label={`${opinions.length} independent opinions`} | |
| 110 | + > | |
| 111 | + {opinions.map((opinion, index) => ( | |
| 112 | + <span | |
| 113 | + key={opinion.name} | |
| 114 | + className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card text-[11px] font-bold text-white" | |
| 115 | + style={{ | |
| 116 | + backgroundColor: `hsl(var(--stage-${['answer', 'critique', 'revision'][index] ?? 'answer'}))`, | |
| 117 | + }} | |
| 118 | + > | |
| 119 | + {index + 1} | |
| 116 | 120 | </span> |
| 117 | - )} | |
| 118 | - </div> | |
| 119 | - | |
| 120 | - <div className="p-4 md:p-6"> | |
| 121 | - <DebateConsole view={view} showActions={false} /> | |
| 121 | + ))} | |
| 122 | 122 | </div> |
| 123 | 123 | </div> |
| 124 | 124 | |
| 125 | - <div className="mt-5 flex flex-wrap items-center justify-center gap-3"> | |
| 126 | - {state === 'playing' ? ( | |
| 127 | - <Button variant="secondary" size="sm" onClick={pause}> | |
| 128 | - <Pause className="h-4 w-4" /> Pause | |
| 129 | - </Button> | |
| 130 | - ) : state === 'paused' ? ( | |
| 131 | - <Button size="sm" onClick={play}> | |
| 132 | - <Play className="h-4 w-4" /> Resume | |
| 133 | - </Button> | |
| 134 | - ) : ( | |
| 135 | - <Button size="sm" onClick={watch} className="group"> | |
| 136 | - <Play className="h-4 w-4 transition-transform group-hover:scale-110" /> | |
| 137 | - {hasPlayed ? 'Replay the debate' : 'Watch it unfold'} | |
| 138 | - </Button> | |
| 139 | - )} | |
| 140 | - | |
| 141 | - {(state === 'playing' || state === 'paused') && ( | |
| 142 | - <Button variant="ghost" size="sm" onClick={skipToEnd}> | |
| 143 | - Skip to the answer | |
| 144 | - </Button> | |
| 145 | - )} | |
| 125 | + <div className="relative mt-4 space-y-2.5"> | |
| 126 | + {opinions.map((opinion, index) => ( | |
| 127 | + <div | |
| 128 | + key={`${opinion.name}-${index}`} | |
| 129 | + className="rounded-xl border bg-background/80 p-3.5 transition-transform duration-300 hover:translate-x-1" | |
| 130 | + style={{ | |
| 131 | + marginLeft: `${index * 10}px`, | |
| 132 | + marginRight: `${(opinions.length - index - 1) * 10}px`, | |
| 133 | + }} | |
| 134 | + > | |
| 135 | + <div className="flex items-center gap-2"> | |
| 136 | + <span | |
| 137 | + className="h-2 w-2 rounded-full" | |
| 138 | + style={{ | |
| 139 | + backgroundColor: `hsl(var(--stage-${['answer', 'critique', 'revision'][index] ?? 'answer'}))`, | |
| 140 | + }} | |
| 141 | + /> | |
| 142 | + <span className="text-xs font-semibold">{opinion.name}</span> | |
| 143 | + <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground"> | |
| 144 | + independent | |
| 145 | + </span> | |
| 146 | + </div> | |
| 147 | + <p className="mt-2 text-xs leading-relaxed text-muted-foreground"> | |
| 148 | + {opinion.content} | |
| 149 | + </p> | |
| 150 | + </div> | |
| 151 | + ))} | |
| 152 | + </div> | |
| 146 | 153 | |
| 147 | - <Button asChild variant="outline" size="sm"> | |
| 148 | - <Link href={`/demo/${result.debateId}`}>Open the full replay</Link> | |
| 149 | - </Button> | |
| 154 | + <div className="relative mt-4 overflow-hidden rounded-2xl border border-emerald-500/25 bg-emerald-500/[0.07] p-5"> | |
| 155 | + <div className="absolute right-0 top-0 h-28 w-28 -translate-y-8 translate-x-8 rounded-full bg-emerald-400/15 blur-2xl" /> | |
| 156 | + <div className="relative flex items-start gap-3"> | |
| 157 | + <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500 text-white shadow-sm"> | |
| 158 | + <Gavel className="h-4 w-4" /> | |
| 159 | + </span> | |
| 160 | + <div> | |
| 161 | + <p className="text-xs font-bold uppercase tracking-[0.14em] text-emerald-700 dark:text-emerald-300"> | |
| 162 | + The verdict | |
| 163 | + </p> | |
| 164 | + <p className="mt-2 text-sm leading-relaxed text-foreground/90">{verdict}</p> | |
| 165 | + </div> | |
| 166 | + </div> | |
| 150 | 167 | </div> |
| 168 | + | |
| 169 | + {demoId && ( | |
| 170 | + <Link | |
| 171 | + href={`/demo/${demoId}`} | |
| 172 | + className="mt-4 flex items-center justify-center gap-1.5 rounded-xl py-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" | |
| 173 | + > | |
| 174 | + Open this real example | |
| 175 | + <ArrowUpRight className="h-3.5 w-3.5" /> | |
| 176 | + </Link> | |
| 177 | + )} | |
| 151 | 178 | </div> |
| 152 | - </section> | |
| 179 | + </div> | |
| 153 | 180 | ); |
| 154 | 181 | } |
| 182 | + | |
| 183 | +function compact(value: string, length: number): string { | |
| 184 | + return truncate(value.replace(/\s+/g, ' ').trim(), length); | |
| 185 | +} |
modified src/components/site-header.tsx +13 −9
| @@ -11,17 +11,21 @@export async function SiteHeader() { | ||
| 11 | 11 | const user = session?.user; |
| 12 | 12 | |
| 13 | 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"> | |
| 17 | - <GitCompareArrows className="h-5 w-5 text-primary" /> | |
| 18 | - <span className="font-display text-[17px] font-semibold tracking-tight">Roundtable</span> | |
| 14 | + <header className="sticky top-0 z-40 w-full border-b bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70"> | |
| 15 | + <div className="container flex h-16 items-center gap-4"> | |
| 16 | + <Link href="/" className="flex items-center gap-2.5"> | |
| 17 | + <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> | |
| 18 | + <GitCompareArrows className="h-4 w-4" /> | |
| 19 | + </span> | |
| 20 | + <span className="font-display text-[17px] font-semibold tracking-[-0.025em]"> | |
| 21 | + Roundtable | |
| 22 | + </span> | |
| 19 | 23 | </Link> |
| 20 | 24 | |
| 21 | - <nav className="ml-4 hidden items-center gap-1 text-sm md:flex"> | |
| 22 | - <NavLink href="/debate">New debate</NavLink> | |
| 23 | - <NavLink href="/demo">Demo</NavLink> | |
| 24 | - {user && <NavLink href="/history">History</NavLink>} | |
| 25 | + <nav className="ml-5 hidden items-center gap-1 text-sm md:flex"> | |
| 26 | + <NavLink href="/debate">Ask a question</NavLink> | |
| 27 | + <NavLink href="/demo">Examples</NavLink> | |
| 28 | + {user && <NavLink href="/history">Past questions</NavLink>} | |
| 25 | 29 | </nav> |
| 26 | 30 | |
| 27 | 31 | <div className="ml-auto flex items-center gap-1"> |
added src/lib/question-examples.ts +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +export const QUESTION_EXAMPLES = [ | |
| 2 | + { | |
| 3 | + category: 'Career', | |
| 4 | + short: 'Compare two job offers', | |
| 5 | + question: | |
| 6 | + 'I have two job offers. One pays more at a risky startup, while the other offers stability and a stronger manager. Help me decide what to prioritize.', | |
| 7 | + }, | |
| 8 | + { | |
| 9 | + category: 'Buying', | |
| 10 | + short: 'Choose the right laptop', | |
| 11 | + question: | |
| 12 | + 'Help me choose between a MacBook Air and a Windows ultrabook for travel, light creative work, and a budget of $1,500.', | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + category: 'Ideas', | |
| 16 | + short: 'Stress-test an idea', | |
| 17 | + question: | |
| 18 | + 'Stress-test this business idea and give me a clear go or no-go recommendation: a weekly meal-planning service for busy families.', | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + category: 'Work', | |
| 22 | + short: 'Make a team decision', | |
| 23 | + question: | |
| 24 | + 'My small team is deciding whether to build this feature now or improve onboarding first. Compare the tradeoffs and recommend one.', | |
| 25 | + }, | |
| 26 | +] as const; |
modified tailwind.config.ts +1 −1
| @@ -84,7 +84,7 @@const config: Config = { | ||
| 84 | 84 | fontFamily: { |
| 85 | 85 | sans: ['var(--font-sans)', 'ui-sans-serif', 'system-ui', 'sans-serif'], |
| 86 | 86 | mono: ['var(--font-mono)', 'ui-monospace', 'monospace'], |
| 87 | - display: ['var(--font-display)', 'ui-serif', 'Georgia', 'serif'], | |
| 87 | + display: ['var(--font-display)', 'ui-sans-serif', 'system-ui', 'sans-serif'], | |
| 88 | 88 | }, |
| 89 | 89 | }, |
| 90 | 90 | }, |