profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
use-debate-playback.ts 6,027 bytes
1 'use client';
2
3 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4 import type { DebateEvent } from '@/core/events';
5 import { chairmanSharesProvider } from '@/core/models';
6 import type { DebateResult } from '@/core/types';
7 import { applyEvent, initialDebateView, type DebateView } from '@/lib/debate-view';
8
9 interface TimedEvent {
10 event: DebateEvent;
11 /** Delay before this event fires, in "playback units" (scaled by speed). */
12 delay: number;
13 }
14
15 /** Reconstruct an animated event timeline from a finished debate. */
16 function buildTimeline(result: DebateResult): TimedEvent[] {
17 const timeline: TimedEvent[] = [];
18 const emit = (event: DebateEvent, delay = 120) => timeline.push({ event, delay });
19
20 emit(
21 {
22 type: 'debate_started',
23 debateId: result.debateId,
24 config: result.config,
25 participants: result.participants,
26 chairmanModel: result.config.chairmanModel,
27 chairmanProviderConflict: chairmanSharesProvider(result.config.chairmanModel, result.config.models),
28 },
29 0,
30 );
31
32 emit({ type: 'round_started', round: 0, kind: 'answers' }, 200);
33 for (const a of result.initialAnswers) {
34 emit({ type: 'stage_started', round: 0, stage: 'answer', participantId: a.participantId, model: a.model }, 60);
35 for (const chunk of chunkText(a.content, 14)) {
36 emit({ type: 'token_delta', round: 0, stage: 'answer', participantId: a.participantId, delta: chunk }, 45);
37 }
38 emit({ type: 'answer_completed', round: 0, record: a }, 80);
39 }
40 emitCost(result, emit, 0);
41
42 for (const round of result.rounds) {
43 emit({ type: 'round_started', round: round.round, kind: 'cycle' }, 250);
44 for (const c of round.critiques) {
45 emit({ type: 'stage_started', round: round.round, stage: 'critique', participantId: c.reviewerParticipantId, model: c.reviewerModel }, 90);
46 emit({ type: 'critique_completed', round: round.round, record: c }, 260);
47 }
48 for (const rev of round.revisions) {
49 emit({ type: 'stage_started', round: round.round, stage: 'revision', participantId: rev.participantId, model: rev.model }, 90);
50 emit({ type: 'revision_completed', round: round.round, record: rev }, 260);
51 }
52 if (round.convergence) {
53 emit({ type: 'stage_started', round: round.round, stage: 'convergence', model: round.convergence.model }, 90);
54 emit({ type: 'convergence_result', round: round.round, record: round.convergence }, 220);
55 }
56 }
57
58 if (result.synthesis) {
59 emit({ type: 'stage_started', round: result.rounds.length, stage: 'synthesis', model: result.synthesis.model }, 300);
60 emit({ type: 'synthesis_completed', record: result.synthesis }, 400);
61 }
62 if (result.provenance) {
63 emit({ type: 'stage_started', round: result.provenance.round, stage: 'provenance', model: result.provenance.model }, 120);
64 emit({ type: 'provenance_completed', record: result.provenance }, 260);
65 }
66 emit(
67 {
68 type: 'debate_completed',
69 debateId: result.debateId,
70 status: result.status,
71 totalCostUsd: result.totals.costUsd,
72 rounds: result.totals.rounds,
73 durationMs: result.totals.durationMs,
74 },
75 200,
76 );
77 return timeline;
78 }
79
80 function emitCost(result: DebateResult, emit: (e: DebateEvent, d?: number) => void, phase: number) {
81 void phase;
82 emit(
83 {
84 type: 'cost_update',
85 totalCostUsd: result.totals.costUsd,
86 promptTokens: result.totals.promptTokens,
87 completionTokens: result.totals.completionTokens,
88 costByModel: result.totals.costByModel,
89 },
90 40,
91 );
92 }
93
94 function chunkText(text: string, parts: number): string[] {
95 const size = Math.ceil(text.length / parts) || 1;
96 const out: string[] = [];
97 for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
98 return out.length ? out : [''];
99 }
100
101 export type PlaybackState = 'idle' | 'playing' | 'paused' | 'finished';
102
103 /**
104 * Replays a recorded debate through the same reducer the live stream uses, so
105 * the demo animates exactly like a real debate - including simulated token
106 * streaming - with play/pause/speed/skip controls.
107 */
108 export function useDebatePlayback(result: DebateResult) {
109 const timeline = useMemo(() => buildTimeline(result), [result]);
110 const [view, setView] = useState<DebateView>(() => initialDebateView(result.config.question));
111 const [state, setState] = useState<PlaybackState>('idle');
112 const [index, setIndex] = useState(0);
113 const [speed, setSpeed] = useState(1);
114 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
115
116 const clearTimer = () => {
117 if (timer.current) clearTimeout(timer.current);
118 timer.current = null;
119 };
120
121 const play = useCallback(() => {
122 setState((s) => (s === 'finished' ? 'playing' : 'playing'));
123 if (state === 'finished') {
124 setView(initialDebateView(result.config.question));
125 setIndex(0);
126 }
127 }, [state, result.config.question]);
128
129 const pause = useCallback(() => setState('paused'), []);
130
131 const restart = useCallback(() => {
132 clearTimer();
133 setView(initialDebateView(result.config.question));
134 setIndex(0);
135 setState('playing');
136 }, [result.config.question]);
137
138 const skipToEnd = useCallback(() => {
139 clearTimer();
140 let v = initialDebateView(result.config.question);
141 for (const t of timeline) v = applyEvent(v, t.event);
142 setView(v);
143 setIndex(timeline.length);
144 setState('finished');
145 }, [timeline, result.config.question]);
146
147 useEffect(() => {
148 if (state !== 'playing') return;
149 if (index >= timeline.length) {
150 setState('finished');
151 return;
152 }
153 const next = timeline[index]!;
154 timer.current = setTimeout(
155 () => {
156 setView((v) => applyEvent(v, next.event));
157 setIndex((i) => i + 1);
158 },
159 Math.max(8, next.delay / speed),
160 );
161 return clearTimer;
162 }, [state, index, timeline, speed]);
163
164 const progress = timeline.length ? (index / timeline.length) * 100 : 0;
165
166 return { view, state, progress, speed, setSpeed, play, pause, restart, skipToEnd };
167 }
168