profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
debate-view.ts 10,068 bytes
1 /**
2 * The client-side debate view-model and its reducer.
3 *
4 * `applyEvent` folds the `DebateEvent` stream into a `DebateView` - the single
5 * shape every debate component renders. Because live streaming and recorded
6 * playback both feed the SAME reducer, and `fromResult` lifts a persisted
7 * `DebateResult` into the same shape, one set of components serves live debates,
8 * history replay, the demo, and the public share page.
9 */
10 import type { DebateEvent } from '@/core/events';
11 import { chairmanSharesProvider } from '@/core/models';
12 import type {
13 AnswerRecord,
14 ConvergenceRecord,
15 CritiqueRecord,
16 DebateConfig,
17 DebateResult,
18 DebateStatus,
19 FailureRecord,
20 Participant,
21 ProvenanceRecord,
22 RevisionRecord,
23 RoundRecord,
24 StageType,
25 SynthesisRecord,
26 } from '@/core/types';
27
28 export interface DebateView {
29 debateId: string;
30 status: DebateStatus;
31 question: string;
32 config: DebateConfig | null;
33 participants: Participant[];
34 chairmanModel: string;
35 convergenceModel: string;
36 chairmanProviderConflict: boolean;
37 initialAnswers: Record<string, AnswerRecord>;
38 rounds: RoundRecord[];
39 synthesis: SynthesisRecord | null;
40 provenance: ProvenanceRecord | null;
41 failures: FailureRecord[];
42 totals: {
43 costUsd: number;
44 promptTokens: number;
45 completionTokens: number;
46 costByModel: Record<string, number>;
47 };
48 /** Live-only: participantId -> answer text streaming in right now. */
49 streaming: Record<string, string>;
50 activeStage: { round: number; stage: StageType } | null;
51 /** participantId -> the stage it is currently working on. */
52 working: Record<string, StageType>;
53 droppedParticipants: string[];
54 /** Set when the spend cap cut the debate short of its configured rounds. */
55 budgetReached: { round: number; totalCostUsd: number; maxCostUsd: number } | null;
56 /** Set when the user concluded the debate early (live streams only). */
57 gavelStruck: { round: number } | null;
58 error?: string;
59 }
60
61 export function initialDebateView(question = ''): DebateView {
62 return {
63 debateId: '',
64 status: 'pending',
65 question,
66 config: null,
67 participants: [],
68 chairmanModel: '',
69 convergenceModel: '',
70 chairmanProviderConflict: false,
71 initialAnswers: {},
72 rounds: [],
73 synthesis: null,
74 provenance: null,
75 failures: [],
76 totals: { costUsd: 0, promptTokens: 0, completionTokens: 0, costByModel: {} },
77 streaming: {},
78 activeStage: null,
79 working: {},
80 droppedParticipants: [],
81 budgetReached: null,
82 gavelStruck: null,
83 };
84 }
85
86 function ensureRound(rounds: RoundRecord[], round: number): RoundRecord[] {
87 if (rounds.some((r) => r.round === round)) return rounds;
88 return [...rounds, { round, critiques: [], revisions: [], convergence: null }].sort((a, b) => a.round - b.round);
89 }
90
91 function updateRound(rounds: RoundRecord[], round: number, fn: (r: RoundRecord) => RoundRecord): RoundRecord[] {
92 return ensureRound(rounds, round).map((r) => (r.round === round ? fn(r) : r));
93 }
94
95 export function applyEvent(prev: DebateView, event: DebateEvent): DebateView {
96 switch (event.type) {
97 case 'debate_started':
98 return {
99 ...prev,
100 debateId: event.debateId,
101 status: 'running',
102 config: event.config,
103 question: event.config.question,
104 participants: event.participants,
105 chairmanModel: event.chairmanModel,
106 convergenceModel: event.config.convergenceModel,
107 chairmanProviderConflict: event.chairmanProviderConflict,
108 };
109
110 case 'stage_started': {
111 const working = { ...prev.working };
112 const streaming = { ...prev.streaming };
113 if (event.participantId) {
114 working[event.participantId] = event.stage;
115 if (event.stage === 'answer' && streaming[event.participantId] === undefined) {
116 streaming[event.participantId] = '';
117 }
118 }
119 return { ...prev, activeStage: { round: event.round, stage: event.stage }, working, streaming };
120 }
121
122 case 'token_delta': {
123 const current = prev.streaming[event.participantId] ?? '';
124 return { ...prev, streaming: { ...prev.streaming, [event.participantId]: current + event.delta } };
125 }
126
127 case 'answer_completed': {
128 const working = { ...prev.working };
129 delete working[event.record.participantId];
130 const streaming = { ...prev.streaming };
131 delete streaming[event.record.participantId];
132 return {
133 ...prev,
134 initialAnswers: { ...prev.initialAnswers, [event.record.participantId]: event.record },
135 working,
136 streaming,
137 };
138 }
139
140 case 'critique_completed': {
141 const working = { ...prev.working };
142 delete working[event.record.reviewerParticipantId];
143 return {
144 ...prev,
145 rounds: updateRound(prev.rounds, event.round, (r) => ({
146 ...r,
147 critiques: [...r.critiques.filter((c) => c.reviewerParticipantId !== event.record.reviewerParticipantId), event.record],
148 })),
149 working,
150 };
151 }
152
153 case 'revision_completed': {
154 const working = { ...prev.working };
155 delete working[event.record.participantId];
156 return {
157 ...prev,
158 rounds: updateRound(prev.rounds, event.round, (r) => ({
159 ...r,
160 revisions: [...r.revisions.filter((x) => x.participantId !== event.record.participantId), event.record],
161 })),
162 working,
163 };
164 }
165
166 case 'convergence_result':
167 return {
168 ...prev,
169 rounds: updateRound(prev.rounds, event.round, (r) => ({ ...r, convergence: event.record })),
170 };
171
172 case 'model_failed': {
173 const working = { ...prev.working };
174 delete working[event.participantId];
175 const streaming = { ...prev.streaming };
176 delete streaming[event.participantId];
177 return {
178 ...prev,
179 working,
180 streaming,
181 failures: [
182 ...prev.failures,
183 {
184 round: event.round,
185 stage: event.stage,
186 participantId: event.participantId,
187 model: event.model,
188 error: event.error,
189 droppedFromDebate: event.droppedFromDebate,
190 },
191 ],
192 droppedParticipants: event.droppedFromDebate
193 ? [...new Set([...prev.droppedParticipants, event.participantId])]
194 : prev.droppedParticipants,
195 };
196 }
197
198 case 'cost_update':
199 return {
200 ...prev,
201 totals: {
202 costUsd: event.totalCostUsd,
203 promptTokens: event.promptTokens,
204 completionTokens: event.completionTokens,
205 costByModel: event.costByModel,
206 },
207 };
208
209 case 'synthesis_completed':
210 return { ...prev, synthesis: event.record, activeStage: null };
211
212 case 'provenance_completed':
213 return { ...prev, provenance: event.record, activeStage: null };
214
215 case 'budget_reached':
216 return {
217 ...prev,
218 budgetReached: { round: event.round, totalCostUsd: event.totalCostUsd, maxCostUsd: event.maxCostUsd },
219 };
220
221 case 'gavel_struck':
222 return { ...prev, gavelStruck: { round: event.round } };
223
224 case 'debate_completed':
225 return {
226 ...prev,
227 status: event.status,
228 activeStage: null,
229 working: {},
230 totals: { ...prev.totals, costUsd: event.totalCostUsd },
231 };
232
233 case 'debate_failed':
234 return { ...prev, status: 'failed', error: event.error, activeStage: null, working: {} };
235
236 default:
237 return prev;
238 }
239 }
240
241 /** Lift a persisted result into a view for replay (no live streaming state). */
242 export function fromResult(result: DebateResult): DebateView {
243 const initialAnswers: Record<string, AnswerRecord> = {};
244 for (const a of result.initialAnswers) initialAnswers[a.participantId] = a;
245 return {
246 debateId: result.debateId,
247 status: result.status,
248 question: result.config.question,
249 config: result.config,
250 participants: result.participants,
251 chairmanModel: result.config.chairmanModel,
252 convergenceModel: result.config.convergenceModel,
253 chairmanProviderConflict: chairmanSharesProvider(result.config.chairmanModel, result.config.models),
254 initialAnswers,
255 rounds: result.rounds,
256 synthesis: result.synthesis,
257 provenance: result.provenance,
258 failures: result.failures,
259 totals: {
260 costUsd: result.totals.costUsd,
261 promptTokens: result.totals.promptTokens,
262 completionTokens: result.totals.completionTokens,
263 costByModel: result.totals.costByModel,
264 },
265 streaming: {},
266 activeStage: null,
267 working: {},
268 droppedParticipants: result.failures.filter((f) => f.droppedFromDebate).map((f) => f.participantId),
269 // Not persisted as an event; derivable: the cap was crossed and the debate
270 // stopped short of its configured rounds.
271 budgetReached:
272 result.config.maxCostUsd !== undefined &&
273 result.totals.costUsd >= result.config.maxCostUsd &&
274 result.totals.rounds < result.config.maxRounds
275 ? {
276 round: result.totals.rounds,
277 totalCostUsd: result.totals.costUsd,
278 maxCostUsd: result.config.maxCostUsd,
279 }
280 : null,
281 gavelStruck: null,
282 ...(result.error ? { error: result.error } : {}),
283 };
284 }
285
286 /** Current answer text per participant (latest revision, else initial). */
287 export function currentAnswers(view: DebateView): Record<string, AnswerRecord> {
288 const out: Record<string, AnswerRecord> = { ...view.initialAnswers };
289 for (const round of view.rounds) {
290 for (const rev of round.revisions) {
291 out[rev.participantId] = {
292 participantId: rev.participantId,
293 model: rev.model,
294 round: rev.round,
295 content: rev.content,
296 usage: rev.usage,
297 latencyMs: rev.latencyMs,
298 };
299 }
300 }
301 return out;
302 }
303
304 /** All critique records across rounds, useful for the aggregate matrix. */
305 export function allCritiques(view: DebateView): CritiqueRecord[] {
306 return view.rounds.flatMap((r) => r.critiques);
307 }
308
309 export type { RevisionRecord, ConvergenceRecord, SynthesisRecord, FailureRecord };
310