profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
use-debate-stream.ts 4,079 bytes
1 'use client';
2
3 import { useCallback, useRef, useState } from 'react';
4 import type { DebateEvent } from '@/core/events';
5 import type { DebateConfigInput } from '@/core/schemas';
6 import { applyEvent, initialDebateView, type DebateView } from '@/lib/debate-view';
7
8 export type StreamPhase = 'idle' | 'connecting' | 'streaming' | 'done' | 'error';
9
10 /**
11 * Drives a live debate: POSTs the config, reads the SSE body, and folds every
12 * frame through the shared reducer. The debate continues server-side even if
13 * this component unmounts (see debate-runner) - the returned `debateId` can be
14 * used to reload state on reconnect.
15 */
16 export function useDebateStream() {
17 const [view, setView] = useState<DebateView>(() => initialDebateView());
18 const [phase, setPhase] = useState<StreamPhase>('idle');
19 const [errorMsg, setErrorMsg] = useState<string | null>(null);
20 /** Machine-readable error code from the API (e.g. 'NO_KEY'), when present. */
21 const [errorCode, setErrorCode] = useState<string | null>(null);
22 const abortRef = useRef<AbortController | null>(null);
23
24 const handleFrame = useCallback((frame: string) => {
25 let eventName = '';
26 let dataLine = '';
27 for (const line of frame.split('\n')) {
28 if (line.startsWith('event:')) eventName = line.slice(6).trim();
29 else if (line.startsWith('data:')) dataLine += line.slice(5).trim();
30 }
31 if (!dataLine) return;
32
33 if (eventName === 'ready') {
34 try {
35 const d = JSON.parse(dataLine) as { debateId: string };
36 setView((v) => ({ ...v, debateId: d.debateId }));
37 } catch {
38 /* ignore */
39 }
40 return;
41 }
42 if (eventName === 'error') {
43 try {
44 setErrorMsg((JSON.parse(dataLine) as { message?: string }).message ?? 'Debate failed');
45 } catch {
46 setErrorMsg('Debate failed');
47 }
48 setPhase('error');
49 return;
50 }
51 try {
52 const ev = JSON.parse(dataLine) as DebateEvent;
53 setView((v) => applyEvent(v, ev));
54 } catch {
55 /* skip malformed frame */
56 }
57 }, []);
58
59 const start = useCallback(
60 async (config: DebateConfigInput) => {
61 abortRef.current?.abort();
62 const controller = new AbortController();
63 abortRef.current = controller;
64 setView(initialDebateView(config.question));
65 setErrorMsg(null);
66 setErrorCode(null);
67 setPhase('connecting');
68
69 try {
70 const res = await fetch('/api/debates/run', {
71 method: 'POST',
72 headers: { 'Content-Type': 'application/json' },
73 body: JSON.stringify(config),
74 signal: controller.signal,
75 });
76
77 if (!res.ok || !res.body) {
78 const err = (await res.json().catch(() => null)) as { error?: string; code?: string } | null;
79 setErrorMsg(err?.error ?? `Request failed (${res.status})`);
80 setErrorCode(err?.code ?? null);
81 setPhase('error');
82 return;
83 }
84
85 setPhase('streaming');
86 const reader = res.body.getReader();
87 const decoder = new TextDecoder();
88 let buffer = '';
89 for (;;) {
90 const { done, value } = await reader.read();
91 if (done) break;
92 buffer += decoder.decode(value, { stream: true });
93 const frames = buffer.split('\n\n');
94 buffer = frames.pop() ?? '';
95 for (const frame of frames) if (frame.trim() && !frame.startsWith(':')) handleFrame(frame);
96 }
97 setPhase((p) => (p === 'error' ? p : 'done'));
98 } catch (err) {
99 if ((err as Error).name === 'AbortError') return;
100 setErrorMsg((err as Error).message);
101 setPhase('error');
102 }
103 },
104 [handleFrame],
105 );
106
107 const cancel = useCallback(() => {
108 abortRef.current?.abort();
109 setPhase((p) => (p === 'streaming' || p === 'connecting' ? 'idle' : p));
110 }, []);
111
112 const reset = useCallback(() => {
113 abortRef.current?.abort();
114 setView(initialDebateView());
115 setErrorMsg(null);
116 setErrorCode(null);
117 setPhase('idle');
118 }, []);
119
120 return { view, phase, errorMsg, errorCode, start, cancel, reset };
121 }
122