// ========================================== // ChatPane Kit — useStream Hook // ========================================== // SSE ReadableStream parser with rAF-batched state updates. // Independently importable — no ChatPane dependency. // // Usage: // const { streamContent, streaming, startStream, stopStream } = useStream(); // // Pass a Response from sw.api.channels.complete(): // startStream(response); const { useState, useRef, useCallback } = window.hooks; /** * Hook that parses an SSE stream from a fetch Response. * * @returns {{ streamContent: string, streaming: boolean, startStream: (resp: Response) => void, stopStream: () => void }} */ export function useStream() { const [streamContent, setStreamContent] = useState(''); const [streamThinking, setStreamThinking] = useState(''); const [streaming, setStreaming] = useState(false); const contentRef = useRef(''); const thinkingRef = useRef(''); const abortRef = useRef(null); const rafRef = useRef(null); const _flush = useCallback(() => { setStreamContent(contentRef.current); setStreamThinking(thinkingRef.current); rafRef.current = null; }, []); const _scheduleFlush = useCallback(() => { if (!rafRef.current) { rafRef.current = requestAnimationFrame(_flush); } }, [_flush]); const stopStream = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } setStreamContent(contentRef.current); setStreamThinking(thinkingRef.current); setStreaming(false); }, []); const startStream = useCallback(async (response) => { // Reset contentRef.current = ''; thinkingRef.current = ''; setStreamContent(''); setStreamThinking(''); setStreaming(true); const controller = new AbortController(); abortRef.current = controller; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (controller.signal.aborted) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data).choices?.[0]?.delta; if (parsed?.content) { let chunk = parsed.content; // Run pipe stream filters (extensions can transform or suppress chunks) if (window.sw?.pipe?._runStream) { const pipeCtx = window.sw.pipe._runStream({ content: chunk }); if (pipeCtx === null) continue; chunk = pipeCtx.content; } contentRef.current += chunk; _scheduleFlush(); } if (parsed?.reasoning_content) { thinkingRef.current += parsed.reasoning_content; _scheduleFlush(); } } catch (_) { /* skip malformed JSON */ } } } } catch (e) { if (e.name !== 'AbortError') { contentRef.current += '\n\n**[Stream error: ' + e.message + ']**'; } } // Final flush if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } setStreamContent(contentRef.current); setStreamThinking(thinkingRef.current); setStreaming(false); abortRef.current = null; }, [_scheduleFlush]); return { streamContent, streamThinking, streaming, startStream, stopStream }; }