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 | |