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