Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
122 lines
4.3 KiB
JavaScript
122 lines
4.3 KiB
JavaScript
// ==========================================
|
|
// 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 };
|
|
}
|