debate-console.tsx
10,874 bytes
| 1 | 'use client'; |
|---|---|
| 2 | |
| 3 | import { AlertTriangle, CircleCheckBig, Gavel, PiggyBank, Scale } from 'lucide-react'; |
| 4 | import { CostBreakdown } from '@/components/debate/cost-breakdown'; |
| 5 | import { CostMeter } from '@/components/debate/cost-meter'; |
| 6 | import { CritiqueMatrix } from '@/components/debate/critique-matrix'; |
| 7 | import { DebateActions } from '@/components/debate/debate-actions'; |
| 8 | import { Disagreements } from '@/components/debate/disagreements'; |
| 9 | import { FinalAnswer } from '@/components/debate/final-answer'; |
| 10 | import { MasqueradeNote } from '@/components/debate/masquerade-note'; |
| 11 | import { ModelPanel } from '@/components/debate/model-panel'; |
| 12 | import { RevisionDiff } from '@/components/debate/revision-diff'; |
| 13 | import { SpinePanel } from '@/components/debate/spine-panel'; |
| 14 | import { StageTimeline } from '@/components/debate/stage-timeline'; |
| 15 | import { Badge } from '@/components/ui/badge'; |
| 16 | import { Progress } from '@/components/ui/progress'; |
| 17 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; |
| 18 | import type { AnswerRecord } from '@/core/types'; |
| 19 | import { allCritiques, currentAnswers, type DebateView } from '@/lib/debate-view'; |
| 20 | import { formatUsd } from '@/lib/utils'; |
| 21 | |
| 22 | export function DebateConsole({ |
| 23 | view, |
| 24 | showActions = true, |
| 25 | }: { |
| 26 | view: DebateView; |
| 27 | showActions?: boolean; |
| 28 | }) { |
| 29 | const answers = currentAnswers(view); |
| 30 | const dropped = new Set(view.droppedParticipants); |
| 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'; |
| 35 | |
| 36 | return ( |
| 37 | <div className="space-y-6"> |
| 38 | {/* Header */} |
| 39 | <div className="space-y-4"> |
| 40 | <div className="flex flex-wrap items-start justify-between gap-3"> |
| 41 | <div className="min-w-0 space-y-1"> |
| 42 | <div className="flex items-center gap-2"> |
| 43 | <StatusBadge status={view.status} /> |
| 44 | <span className="text-xs text-muted-foreground"> |
| 45 | {view.participants.length} independent opinions ยท up to{' '} |
| 46 | {view.config?.maxRounds ?? '-'} review rounds |
| 47 | </span> |
| 48 | </div> |
| 49 | <h1 className="text-balance font-display text-2xl font-medium leading-snug tracking-tight"> |
| 50 | {view.question} |
| 51 | </h1> |
| 52 | </div> |
| 53 | <div className="flex items-center gap-2"> |
| 54 | <CostMeter view={view} /> |
| 55 | {showActions && finished && view.debateId && ( |
| 56 | <DebateActions debateId={view.debateId} question={view.question} /> |
| 57 | )} |
| 58 | </div> |
| 59 | </div> |
| 60 | |
| 61 | {view.error && ( |
| 62 | <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> |
| 63 | <AlertTriangle className="h-4 w-4" /> {view.error} |
| 64 | </div> |
| 65 | )} |
| 66 | |
| 67 | {view.gavelStruck && ( |
| 68 | <div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm"> |
| 69 | <Gavel className="h-4 w-4 text-primary" /> |
| 70 | <span> |
| 71 | You asked for the verdict after review round {view.gavelStruck.round}. It was prepared |
| 72 | from the opinions completed at that point. |
| 73 | </span> |
| 74 | </div> |
| 75 | )} |
| 76 | |
| 77 | {view.budgetReached && ( |
| 78 | <div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm"> |
| 79 | <PiggyBank className="h-4 w-4 text-amber-500" /> |
| 80 | <span> |
| 81 | Budget cap reached after round {view.budgetReached.round} (spent{' '} |
| 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. |
| 85 | </span> |
| 86 | </div> |
| 87 | )} |
| 88 | |
| 89 | <StageTimeline view={view} /> |
| 90 | </div> |
| 91 | |
| 92 | {/* Final answer + disagreements (surfaced up top once available) */} |
| 93 | {view.synthesis && ( |
| 94 | <section id="section-final" className="grid gap-4 lg:grid-cols-[1fr_320px]"> |
| 95 | <FinalAnswer |
| 96 | synthesis={view.synthesis} |
| 97 | participants={view.participants} |
| 98 | chairmanProviderConflict={view.chairmanProviderConflict} |
| 99 | provenance={view.provenance} |
| 100 | /> |
| 101 | {lastConvergence && lastConvergence.disagreements.length > 0 ? ( |
| 102 | <Disagreements |
| 103 | disagreements={lastConvergence.disagreements} |
| 104 | participants={view.participants} |
| 105 | /> |
| 106 | ) : ( |
| 107 | <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm"> |
| 108 | <div className="mb-1 flex items-center gap-2 font-medium text-emerald-600 dark:text-emerald-400"> |
| 109 | <CircleCheckBig className="h-4 w-4" /> Converged |
| 110 | </div> |
| 111 | <p className="text-muted-foreground"> |
| 112 | The council reached substantial agreement |
| 113 | {lastConvergence ? ` (score ${lastConvergence.score}/100)` : ''}. |
| 114 | </p> |
| 115 | </div> |
| 116 | )} |
| 117 | </section> |
| 118 | )} |
| 119 | |
| 120 | {/* Council panels */} |
| 121 | <section id="section-council" className="space-y-3"> |
| 122 | <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> |
| 123 | Independent opinions |
| 124 | </h2> |
| 125 | <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> |
| 126 | {view.participants.map((p, i) => ( |
| 127 | <ModelPanel |
| 128 | key={p.id} |
| 129 | index={i} |
| 130 | displayName={p.displayName} |
| 131 | model={p.model} |
| 132 | answer={answers[p.id]} |
| 133 | streamingText={view.streaming[p.id]} |
| 134 | workingStage={view.working[p.id]} |
| 135 | dropped={dropped.has(p.id)} |
| 136 | /> |
| 137 | ))} |
| 138 | </div> |
| 139 | </section> |
| 140 | |
| 141 | {/* Rounds */} |
| 142 | {view.rounds.map((round) => ( |
| 143 | <RoundSection |
| 144 | key={round.round} |
| 145 | round={round.round} |
| 146 | view={view} |
| 147 | answersBefore={answersBeforeRound(view, round.round)} |
| 148 | /> |
| 149 | ))} |
| 150 | |
| 151 | {/* Pressure response */} |
| 152 | {view.rounds.some((r) => r.revisions.length > 0) && ( |
| 153 | <section id="section-spine" className="space-y-3"> |
| 154 | <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> |
| 155 | How opinions changed |
| 156 | </h2> |
| 157 | <div className="rounded-xl border bg-card p-4"> |
| 158 | <SpinePanel participants={view.participants} rounds={view.rounds} /> |
| 159 | </div> |
| 160 | {finished && ( |
| 161 | <MasqueradeNote participants={view.participants} critiques={allCritiques(view)} /> |
| 162 | )} |
| 163 | </section> |
| 164 | )} |
| 165 | |
| 166 | {/* Cost breakdown */} |
| 167 | {finished && ( |
| 168 | <section id="section-cost" className="space-y-3"> |
| 169 | <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground"> |
| 170 | Usage and cost |
| 171 | </h2> |
| 172 | <div className="rounded-xl border bg-card p-4"> |
| 173 | <CostBreakdown view={view} /> |
| 174 | </div> |
| 175 | </section> |
| 176 | )} |
| 177 | </div> |
| 178 | ); |
| 179 | } |
| 180 | |
| 181 | function RoundSection({ |
| 182 | round, |
| 183 | view, |
| 184 | answersBefore, |
| 185 | }: { |
| 186 | round: number; |
| 187 | view: DebateView; |
| 188 | answersBefore: Record<string, AnswerRecord>; |
| 189 | }) { |
| 190 | const record = view.rounds.find((r) => r.round === round)!; |
| 191 | return ( |
| 192 | <section id={`section-round-${round}`} className="rounded-xl border bg-card"> |
| 193 | <div className="flex flex-wrap items-center gap-2 border-b p-4"> |
| 194 | <span |
| 195 | className="flex h-7 w-7 items-center justify-center rounded-md text-white" |
| 196 | style={{ backgroundColor: 'hsl(var(--stage-critique))' }} |
| 197 | > |
| 198 | <Scale className="h-4 w-4" /> |
| 199 | </span> |
| 200 | <h2 className="text-sm font-semibold">Review round {round}</h2> |
| 201 | {record.convergence && ( |
| 202 | <div className="ml-auto flex items-center gap-2"> |
| 203 | <span className="text-xs text-muted-foreground">agreement</span> |
| 204 | <div className="w-28"> |
| 205 | <Progress value={record.convergence.score} /> |
| 206 | </div> |
| 207 | <Badge variant={record.convergence.converged ? 'success' : 'secondary'}> |
| 208 | {record.convergence.score}/100 |
| 209 | </Badge> |
| 210 | </div> |
| 211 | )} |
| 212 | </div> |
| 213 | |
| 214 | <div className="p-4"> |
| 215 | <Tabs defaultValue="matrix"> |
| 216 | <TabsList> |
| 217 | <TabsTrigger value="matrix">Peer review</TabsTrigger> |
| 218 | <TabsTrigger value="revisions">Answer changes</TabsTrigger> |
| 219 | </TabsList> |
| 220 | <TabsContent value="matrix" className="pt-2"> |
| 221 | <CritiqueMatrix participants={view.participants} critiques={record.critiques} /> |
| 222 | </TabsContent> |
| 223 | <TabsContent value="revisions" className="space-y-4 pt-2"> |
| 224 | {record.revisions.length === 0 && ( |
| 225 | <p className="text-sm text-muted-foreground">No revisions recorded yet.</p> |
| 226 | )} |
| 227 | {record.revisions.map((rev) => { |
| 228 | const before = answersBefore[rev.participantId]; |
| 229 | const idx = view.participants.findIndex((p) => p.id === rev.participantId); |
| 230 | return ( |
| 231 | <RevisionDiff |
| 232 | key={rev.participantId} |
| 233 | index={idx < 0 ? 0 : idx} |
| 234 | displayName={view.participants[idx]?.displayName ?? rev.model} |
| 235 | previous={before?.content ?? ''} |
| 236 | next={rev.content} |
| 237 | changelog={rev.changelog} |
| 238 | /> |
| 239 | ); |
| 240 | })} |
| 241 | </TabsContent> |
| 242 | </Tabs> |
| 243 | </div> |
| 244 | </section> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | /** Answer each participant held entering a given round (for the diff baseline). */ |
| 249 | function answersBeforeRound(view: DebateView, round: number): Record<string, AnswerRecord> { |
| 250 | const out: Record<string, AnswerRecord> = { ...view.initialAnswers }; |
| 251 | for (const r of view.rounds) { |
| 252 | if (r.round >= round) break; |
| 253 | for (const rev of r.revisions) { |
| 254 | out[rev.participantId] = { |
| 255 | participantId: rev.participantId, |
| 256 | model: rev.model, |
| 257 | round: rev.round, |
| 258 | content: rev.content, |
| 259 | usage: rev.usage, |
| 260 | latencyMs: rev.latencyMs, |
| 261 | }; |
| 262 | } |
| 263 | } |
| 264 | return out; |
| 265 | } |
| 266 | |
| 267 | function StatusBadge({ status }: { status: DebateView['status'] }) { |
| 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' }, |
| 275 | failed: { variant: 'destructive', label: 'Failed' }, |
| 276 | aborted: { variant: 'secondary', label: 'Aborted' }, |
| 277 | }; |
| 278 | const s = map[status]; |
| 279 | return <Badge variant={s.variant}>{s.label}</Badge>; |
| 280 | } |
| 281 | |