import { useCallback, useEffect, useRef, useState } from "react" import { isAbortError } from "./abort" /** * 컴포넌트 로컬 스트림 세션 hook. * * AbortController 생성·관리·중단·정리까지 한 묶음으로. 컴포넌트가 언마운트되면 진행 중인 스트림도 자동 abort. * * zustand 같은 글로벌 store 패턴을 쓰는 경우 직접 controller를 들고 다니는 게 더 자연스러움 — 이 훅은 * 컴포넌트 단독으로 스트림 시작/중단 할 때 쓰기 편함. (자세한 store 패턴은 README 참고) * * 사용 예: * ```tsx * const { run, stop, isRunning } = useStreamSession() * * const onSubmit = (q: string) => { * void run(async (signal) => { * await streamLLM({ * path: "/chat/stream", * body: { messages: [{ role: "user", content: q }] }, * signal, * handlers: { onToken: (d) => setText((t) => t + d), onDone: () => {} }, * }) * }) * } * * return isRunning * ? * : * ``` */ export interface StreamSessionResult { ok: boolean /** 사용자가 stop()을 눌러 중단된 경우 true. ok=false일 때만 의미 있음. */ aborted: boolean /** ok=true면 fn의 반환값, ok=false면 throw된 에러 */ value?: T error?: unknown } export function useStreamSession() { const ctrlRef = useRef(null) const [isRunning, setIsRunning] = useState(false) // 언마운트 시 진행 중인 스트림 abort (메모리/네트워크 누수 방지) useEffect(() => { return () => { ctrlRef.current?.abort() ctrlRef.current = null } }, []) const run = useCallback( async (fn: (signal: AbortSignal) => Promise): Promise> => { // 이전 진행 중인 스트림이 있으면 자동 취소 (사용자가 새 쿼리 보낸 경우) ctrlRef.current?.abort() const ctrl = new AbortController() ctrlRef.current = ctrl setIsRunning(true) try { const value = await fn(ctrl.signal) return { ok: true, aborted: false, value } } catch (error) { const aborted = ctrl.signal.aborted || isAbortError(error) return { ok: false, aborted, error } } finally { if (ctrlRef.current === ctrl) { ctrlRef.current = null setIsRunning(false) } } }, [] ) const stop = useCallback(() => { ctrlRef.current?.abort() }, []) return { run, stop, isRunning } }