profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
new-debate.tsx 24,910 bytes
1 'use client';
2
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';
16 import { useEffect, useMemo, useState } from 'react';
17 import { ApiKeyDialog } from '@/components/api-key-dialog';
18 import { DebateConsole } from '@/components/debate/debate-console';
19 import { ModelCombobox } from '@/components/debate/model-combobox';
20 import { ModelPicker } from '@/components/debate/model-picker';
21 import { Badge } from '@/components/ui/badge';
22 import { Button } from '@/components/ui/button';
23 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
24 import { Input } from '@/components/ui/input';
25 import { Label } from '@/components/ui/label';
26 import { Slider } from '@/components/ui/slider';
27 import { toast } from '@/components/ui/sonner';
28 import { chairmanSharesProvider, DEFAULT_CONVERGENCE_MODEL } from '@/core/models';
29 import type { DebateResult } from '@/core/types';
30 import { useDebateStream } from '@/hooks/use-debate-stream';
31 import { useModels } from '@/hooks/use-models';
32 import { estimateDebateCostUsd } from '@/lib/cost-estimate';
33 import { QUESTION_EXAMPLES } from '@/lib/question-examples';
34 import { formatUsd } from '@/lib/utils';
35
36 interface Preset {
37 id: string;
38 name: string;
39 models: string[];
40 chairmanModel: string;
41 convergenceModel: string | null;
42 maxRounds: number;
43 convergenceThreshold: number;
44 temperature: number;
45 }
46
47 export function NewDebate({
48 fromDebateId,
49 initialQuestion = '',
50 }: {
51 fromDebateId?: string;
52 initialQuestion?: string;
53 }) {
54 const { view, phase, errorMsg, errorCode, start, cancel, reset } = useDebateStream();
55 const { models, loading: modelsLoading, error: modelsError } = useModels();
56
57 const [question, setQuestion] = useState(initialQuestion.slice(0, 8000));
58 const [council, setCouncil] = useState<string[]>([]);
59 const [chairman, setChairman] = useState('');
60 const [convergenceModel, setConvergenceModel] = useState(DEFAULT_CONVERGENCE_MODEL);
61 const [maxRounds, setMaxRounds] = useState(3);
62 const [threshold, setThreshold] = useState(85);
63 const [temperature, setTemperature] = useState(0.7);
64 const [presets, setPresets] = useState<Preset[]>([]);
65 const [presetName, setPresetName] = useState('');
66 const [maxCost, setMaxCost] = useState('');
67 const [keyDialogOpen, setKeyDialogOpen] = useState(false);
68 const [needsKey, setNeedsKey] = useState(false);
69 const [prefilled, setPrefilled] = useState(false);
70 const [advancedOpen, setAdvancedOpen] = useState(false);
71
72 useEffect(() => {
73 if (!fromDebateId || prefilled) return;
74 let cancelled = false;
75 fetch(`/api/debates/${fromDebateId}`)
76 .then((response) => (response.ok ? (response.json() as Promise<DebateResult>) : null))
77 .then((result) => {
78 if (!result || cancelled) return;
79 setQuestion(result.config.question);
80 setCouncil(result.config.models);
81 setChairman(result.config.chairmanModel);
82 setConvergenceModel(result.config.convergenceModel);
83 setMaxRounds(result.config.maxRounds);
84 setThreshold(result.config.convergenceThreshold);
85 setTemperature(result.config.temperature);
86 setPrefilled(true);
87 toast.message('Loaded the question and setup from your previous roundtable');
88 })
89 .catch(() => {});
90 return () => {
91 cancelled = true;
92 };
93 }, [fromDebateId, prefilled]);
94
95 useEffect(() => {
96 if (errorCode !== 'NO_KEY') return;
97 setNeedsKey(true);
98 setKeyDialogOpen(true);
99 reset();
100 }, [errorCode, reset]);
101
102 useEffect(() => {
103 if (models.length === 0 || council.length > 0) return;
104 const preferred = [
105 'openai/gpt-4o',
106 'anthropic/claude-3.5-sonnet',
107 'google/gemini-pro-1.5',
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);
112 if (!chairman) {
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);
117 }
118 // eslint-disable-next-line react-hooks/exhaustive-deps
119 }, [models]);
120
121 useEffect(() => {
122 fetch('/api/presets')
123 .then((response) => response.json())
124 .then((data: { presets: Preset[] }) => setPresets(data.presets ?? []))
125 .catch(() => {});
126 }, []);
127
128 const providerConflict =
129 chairman && council.length > 0 && chairmanSharesProvider(chairman, council);
130 const maxCostUsd = Number.parseFloat(maxCost);
131 const capValid = maxCost.trim() === '' || (Number.isFinite(maxCostUsd) && maxCostUsd > 0);
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
144 const canStart = startBlocker === null;
145 const running = phase === 'connecting' || phase === 'streaming';
146 const priceMap = useMemo(
147 () =>
148 new Map(
149 models.map((model) => [
150 model.id,
151 { prompt: model.promptPrice, completion: model.completionPrice },
152 ]),
153 ),
154 [models],
155 );
156 const estimate = useMemo(
157 () =>
158 canStart
159 ? estimateDebateCostUsd({
160 councilModels: council,
161 chairmanModel: chairman,
162 convergenceModel,
163 rounds: maxRounds,
164 price: (id) => priceMap.get(id),
165 })
166 : 0,
167 [canStart, council, chairman, convergenceModel, maxRounds, priceMap],
168 );
169
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}"`);
178 };
179
180 const savePreset = async () => {
181 if (!presetName.trim()) return;
182 const response = await fetch('/api/presets', {
183 method: 'POST',
184 headers: { 'Content-Type': 'application/json' },
185 body: JSON.stringify({
186 name: presetName.trim(),
187 models: council,
188 chairmanModel: chairman,
189 convergenceModel,
190 maxRounds,
191 convergenceThreshold: threshold,
192 temperature,
193 }),
194 });
195 if (response.ok) {
196 const { preset } = (await response.json()) as { preset: Preset };
197 setPresets((current) => [preset, ...current.filter((item) => item.id !== preset.id)]);
198 setPresetName('');
199 toast.success('Setup saved');
200 } else if (response.status === 401) {
201 toast.error('Sign in to save setups');
202 } else {
203 toast.error('Could not save setup');
204 }
205 };
206
207 const cancelDebateRun = async () => {
208 if (!view.debateId) return;
209 try {
210 const response = await fetch(`/api/debates/${view.debateId}/cancel`, { method: 'POST' });
211 if (!response.ok) throw new Error('Cancel request failed');
212 const result = (await response.json()) as { ok: boolean };
213 if (!result.ok) {
214 toast.message('The roundtable is no longer running');
215 return;
216 }
217 toast.message('Cancelling the roundtable...');
218 } catch {
219 toast.error('Could not cancel');
220 }
221 };
222
223 const concludeNow = async () => {
224 if (!view.debateId) return;
225 try {
226 const response = await fetch(`/api/debates/${view.debateId}/gavel`, { method: 'POST' });
227 if (!response.ok) throw new Error('Verdict request failed');
228 const result = (await response.json()) as { ok: boolean };
229 if (!result.ok) {
230 toast.message('The roundtable is no longer running');
231 return;
232 }
233 toast.message('Preparing a verdict from the opinions completed so far');
234 } catch {
235 toast.error('Could not prepare the verdict');
236 }
237 };
238
239 const launch = () => {
240 if (!canStart) return;
241 start({
242 question: question.trim(),
243 models: council,
244 chairmanModel: chairman,
245 convergenceModel,
246 maxRounds,
247 convergenceThreshold: threshold,
248 temperature,
249 perModelTimeoutMs: 90_000,
250 ...(capValid && maxCost.trim() !== '' ? { maxCostUsd } : {}),
251 });
252 };
253
254 if (phase === 'idle') {
255 return (
256 <div className="mx-auto max-w-4xl">
257 <div className="mx-auto max-w-2xl pb-9 text-center">
258 <Badge variant="secondary" className="mb-4 rounded-full px-3 py-1">
259 New question
260 </Badge>
261 <h1 className="font-display text-4xl font-semibold tracking-[-0.04em] sm:text-5xl">
262 What do you need to decide?
263 </h1>
264 <p className="mx-auto mt-4 max-w-xl leading-relaxed text-muted-foreground">
265 Describe the choice, what matters to you, and any limits. Roundtable will compare
266 independent opinions and give you one checked verdict.
267 </p>
268 </div>
269
270 <section className="rounded-[1.5rem] border bg-card p-4 shadow-xl shadow-primary/5 sm:p-6">
271 <Label htmlFor="debate-question" className="text-sm font-semibold">
272 Your question
273 </Label>
274 <textarea
275 id="debate-question"
276 value={question}
277 onChange={(event) => setQuestion(event.target.value)}
278 onKeyDown={(event) => {
279 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canStart) {
280 event.preventDefault();
281 launch();
282 }
283 }}
284 placeholder="Include your options, priorities, and constraints..."
285 rows={5}
286 autoFocus={!initialQuestion}
287 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"
288 />
289 <div className="mt-2 flex items-center justify-between gap-3">
290 <span className="text-xs text-muted-foreground">{question.length} / 8,000</span>
291 <span className="text-xs text-muted-foreground">
292 <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Ctrl</kbd> +{' '}
293 <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Enter</kbd> to
294 start
295 </span>
296 </div>
297
298 <div className="mt-5 border-t pt-4">
299 <p className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
300 Need a starting point?
301 </p>
302 <div className="grid gap-2 sm:grid-cols-2">
303 {QUESTION_EXAMPLES.map((example) => (
304 <button
305 key={example.short}
306 type="button"
307 onClick={() => setQuestion(example.question)}
308 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"
309 >
310 <span className="block text-[10px] font-bold uppercase tracking-[0.14em] text-primary">
311 {example.category}
312 </span>
313 <span className="mt-1 block text-sm font-semibold">{example.short}</span>
314 </button>
315 ))}
316 </div>
317 </div>
318 </section>
319
320 <div className="mt-5 flex flex-col gap-4 rounded-2xl border bg-card/70 p-4 sm:flex-row sm:items-center">
321 <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
322 {modelsLoading && council.length === 0 ? (
323 <Loader2 className="h-5 w-5 animate-spin" />
324 ) : (
325 <UsersRound className="h-5 w-5" />
326 )}
327 </span>
328 <div className="min-w-0 flex-1">
329 <p className="font-semibold">Your roundtable is ready</p>
330 <p className="mt-0.5 text-sm text-muted-foreground">
331 {council.length >= 3
332 ? `${council.length} independent models, up to ${maxRounds} review ${maxRounds === 1 ? 'round' : 'rounds'}, and one final verdict.`
333 : 'Choosing a balanced set of models for your question.'}
334 </p>
335 </div>
336 {canStart && (
337 <div className="shrink-0 text-xs text-muted-foreground sm:text-right">
338 Estimated maximum
339 <strong className="ml-1 text-foreground">{formatUsd(estimate)}</strong>
340 </div>
341 )}
342 </div>
343
344 <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-4">
345 <CollapsibleTrigger asChild>
346 <button
347 type="button"
348 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"
349 >
350 <Settings2 className="h-4 w-4 text-muted-foreground" />
351 Customize models and rules
352 <span className="ml-auto text-xs font-normal text-muted-foreground">Optional</span>
353 <ChevronDown
354 className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`}
355 />
356 </button>
357 </CollapsibleTrigger>
358 <CollapsibleContent>
359 <div className="mt-3 space-y-8 rounded-2xl border bg-card p-5 sm:p-7">
360 <section>
361 <h2 className="font-display text-xl font-semibold">Choose the opinions</h2>
362 <p className="mb-4 mt-1 text-sm text-muted-foreground">
363 Pick 3 to 6 models. Different providers usually give you a wider range of views.
364 </p>
365 <ModelPicker council={council} onChange={setCouncil} />
366 </section>
367
368 <section className="border-t pt-7">
369 <h2 className="font-display text-xl font-semibold">Set the review rules</h2>
370 <p className="mb-5 mt-1 text-sm text-muted-foreground">
371 The defaults work for most questions. Adjust them when you need tighter control
372 over cost or style.
373 </p>
374 <div className="space-y-6">
375 <div className="grid gap-5 sm:grid-cols-2">
376 <div className="space-y-1.5">
377 <Label>Verdict model</Label>
378 <p className="text-xs text-muted-foreground">
379 Reads every opinion and combines the final recommendation.
380 </p>
381 <ModelCombobox
382 value={chairman}
383 onChange={setChairman}
384 placeholder="Choose a verdict model"
385 />
386 {providerConflict && (
387 <p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
388 <AlertTriangle className="h-3.5 w-3.5" /> This provider is also
389 represented in the group.
390 </p>
391 )}
392 </div>
393 <div className="space-y-1.5">
394 <Label>Agreement checker</Label>
395 <p className="text-xs text-muted-foreground">
396 Measures whether another review round is still useful.
397 </p>
398 <ModelCombobox
399 value={convergenceModel}
400 onChange={setConvergenceModel}
401 placeholder="Choose an agreement model"
402 />
403 </div>
404 </div>
405
406 <SliderRow
407 label="Review rounds"
408 value={maxRounds}
409 min={1}
410 max={5}
411 step={1}
412 onChange={setMaxRounds}
413 display={String(maxRounds)}
414 hint="More rounds let the models challenge and improve their answers again, but cost more."
415 />
416 <SliderRow
417 label="Stop when agreement reaches"
418 value={threshold}
419 min={50}
420 max={100}
421 step={1}
422 onChange={setThreshold}
423 display={`${threshold}/100`}
424 hint="Roundtable stops early when the opinions have already reached this level of agreement."
425 />
426 <SliderRow
427 label="Answer variety"
428 value={temperature}
429 min={0}
430 max={1.5}
431 step={0.1}
432 onChange={setTemperature}
433 display={temperature.toFixed(1)}
434 hint="Lower values stay focused. Higher values explore more varied approaches."
435 />
436
437 <div className="space-y-1.5">
438 <Label htmlFor="rt-budget">Spending limit (optional)</Label>
439 <div className="flex items-center gap-2">
440 <span className="text-sm text-muted-foreground">$</span>
441 <Input
442 id="rt-budget"
443 inputMode="decimal"
444 placeholder="No limit"
445 value={maxCost}
446 onChange={(event) => setMaxCost(event.target.value)}
447 className="h-9 max-w-[9rem]"
448 />
449 </div>
450 <p className="text-xs text-muted-foreground">
451 If the limit is reached, remaining rounds are skipped and you still receive a
452 verdict from the work completed so far.
453 </p>
454 {!capValid && (
455 <p className="text-xs text-destructive">
456 Enter a positive dollar amount or leave this empty.
457 </p>
458 )}
459 </div>
460
461 {presets.length > 0 && (
462 <div className="space-y-2">
463 <Label>Saved setups</Label>
464 <div className="flex flex-wrap gap-2">
465 {presets.map((preset) => (
466 <Button
467 key={preset.id}
468 variant="secondary"
469 size="sm"
470 onClick={() => applyPreset(preset)}
471 >
472 {preset.name}
473 </Button>
474 ))}
475 </div>
476 </div>
477 )}
478
479 <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
480 <Input
481 value={presetName}
482 onChange={(event) => setPresetName(event.target.value)}
483 placeholder="Name this setup"
484 className="h-9 max-w-xs"
485 />
486 <Button
487 variant="outline"
488 size="sm"
489 onClick={savePreset}
490 disabled={!presetName.trim()}
491 >
492 <Save className="h-3.5 w-3.5" /> Save setup
493 </Button>
494 </div>
495 </div>
496 </section>
497 </div>
498 </CollapsibleContent>
499 </Collapsible>
500
501 <div className="mt-6 space-y-4">
502 {needsKey && (
503 <div className="flex flex-wrap items-center gap-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
504 <KeyRound className="h-4 w-4 text-amber-500" />
505 <span>Connect your models once, then your question will stay ready here.</span>
506 <Button
507 variant="outline"
508 size="sm"
509 className="ml-auto"
510 onClick={() => setKeyDialogOpen(true)}
511 >
512 Connect models
513 </Button>
514 </div>
515 )}
516
517 {errorMsg && (
518 <div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
519 <AlertTriangle className="h-4 w-4" /> {errorMsg}
520 </div>
521 )}
522
523 <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">
524 <span className="px-1 text-xs text-muted-foreground">
525 {canStart
526 ? `Estimated maximum ${formatUsd(estimate)}. You will see live progress.`
527 : startBlocker}
528 </span>
529 <Button
530 size="lg"
531 disabled={!canStart}
532 onClick={launch}
533 className="rounded-xl px-7 shadow-lg"
534 >
535 <Play className="h-4 w-4" /> Get my verdict
536 </Button>
537 </div>
538 </div>
539
540 <ApiKeyDialog
541 open={keyDialogOpen}
542 onOpenChange={setKeyDialogOpen}
543 onChanged={() => {
544 setNeedsKey(false);
545 setKeyDialogOpen(false);
546 }}
547 />
548 </div>
549 );
550 }
551
552 return (
553 <div className="space-y-4">
554 <div className="flex flex-wrap items-center justify-between gap-3">
555 <div className="flex items-center gap-2 text-sm text-muted-foreground">
556 {running ? (
557 <>
558 <Loader2 className="h-4 w-4 animate-spin text-primary" /> Roundtable is working. You
559 can leave this page and it will continue.
560 </>
561 ) : (
562 <Badge variant="success">Verdict ready</Badge>
563 )}
564 </div>
565 <div className="flex flex-wrap gap-2">
566 {running && (
567 <>
568 <Button variant="outline" size="sm" onClick={cancel}>
569 <Square className="h-3.5 w-3.5" /> Stop watching
570 </Button>
571 <Button
572 variant="outline"
573 size="sm"
574 onClick={concludeNow}
575 disabled={!view.debateId || Boolean(view.gavelStruck)}
576 title="Skip remaining reviews and prepare the final verdict now"
577 >
578 <Gavel className="h-3.5 w-3.5" /> Get verdict now
579 </Button>
580 <Button variant="destructive" size="sm" onClick={cancelDebateRun}>
581 <Square className="h-3.5 w-3.5" /> Cancel
582 </Button>
583 </>
584 )}
585 <Button variant="outline" size="sm" onClick={reset}>
586 <RotateCcw className="h-3.5 w-3.5" /> New question
587 </Button>
588 </div>
589 </div>
590
591 {errorMsg && (
592 <div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
593 <AlertTriangle className="h-4 w-4" /> {errorMsg}
594 </div>
595 )}
596
597 <DebateConsole view={view} />
598 </div>
599 );
600 }
601
602 function SliderRow({
603 label,
604 value,
605 min,
606 max,
607 step,
608 onChange,
609 display,
610 hint,
611 }: {
612 label: string;
613 value: number;
614 min: number;
615 max: number;
616 step: number;
617 onChange: (value: number) => void;
618 display: string;
619 hint?: string;
620 }) {
621 return (
622 <div className="space-y-2">
623 <div className="flex items-center justify-between">
624 <Label>{label}</Label>
625 <span className="font-mono text-xs text-muted-foreground">{display}</span>
626 </div>
627 <Slider
628 value={[value]}
629 min={min}
630 max={max}
631 step={step}
632 onValueChange={(values) => onChange(values[0]!)}
633 />
634 {hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
635 </div>
636 );
637 }
638