"use client"; /** * TASK-0124 — the shared state hook for the two streaming database-AI flows * (generate + extract). Both are the same shape — stream SSE → terminal * `done` frame carrying the result (`databaseId` / `recordIds` / * `ignoredColumns`) — so one hook serves both dialogs. * * Error model (mirrors TASK-0122/0123 semantics): * - pre-stream failure (403/402/429/413/400) → `AiPreStreamError` with the * canonical `code` (the dialog renders the banner); * - in-band `error` frame after the stream started (invalid structure after * the retry-once, provider outage) → `reason: "error"` + the `done` frame * (the dialog shows the generic banner). * * Cancellation: `abort()` (dialog close / Stop) aborts the fetch; the stream * resolves with `reason: "aborted"` and the server persists a truncated * assistant row (no write is in flight — the write only happens after the * validated tool call). */ import { useCallback, useEffect, useRef, useState } from "react"; import { AiPreStreamError } from "@/lib/ai"; import { sendDbExtract, sendDbGenerate, type DbAiResult } from "@/lib/ai-database"; export type DbAiStreamPhase = "idle" | "streaming" | "done" | "error"; export interface DbAiStreamError { code: string; message: string; retryable?: boolean; /** True when the failure is the monthly-token budget (the budget banner). */ budget?: boolean; } export interface DbAiStreamState { phase: DbAiStreamPhase; /** The terminal `done` frame (present when phase === "done" and reason ok). */ result: DbAiResult | null; error: DbAiStreamError | null; } export interface UseDbAiStreamParams { workspaceId: string; databaseId?: string; signal?: AbortSignal; } export function useDbAiStream({ workspaceId }: UseDbAiStreamParams) { const [phase, setPhase] = useState("idle"); const [result, setResult] = useState(null); const [error, setError] = useState(null); const abortRef = useRef(null); const run = useCallback( async ( call: (signal: AbortSignal) => Promise ): Promise => { setPhase("streaming"); setResult(null); setError(null); const controller = new AbortController(); abortRef.current = controller; try { const res = await call(controller.signal); if (res.reason === "aborted") { setPhase("idle"); return null; } if (res.reason !== "ok" || !res.done) { setPhase("error"); setResult(res); // carry the terminal frame (its `errorFrame`) for the banner const ef = res.errorFrame; setError( ef ? { code: ef.code, message: ef.message } : { code: "AI_STREAM_ERROR", message: "The stream ended unexpectedly. Nothing was created." } ); return null; } setResult(res); setPhase("done"); return res; } catch (err) { if (err instanceof AiPreStreamError) { setPhase("error"); setError({ code: err.code, message: err.message, retryable: err.retryAfterSeconds != null, budget: err.code === "AI_BUDGET_EXCEEDED" || err.code === "AI_BUDGET_MONTHLY_EXCEEDED" }); return null; } setPhase("error"); setError({ code: "NETWORK_ERROR", message: "Could not reach the server." }); return null; } finally { abortRef.current = null; } }, [] ); const runGenerate = useCallback( (prompt: string, parentId?: string) => run((signal) => sendDbGenerate({ workspaceId, prompt, ...(parentId ? { parentId } : {}), signal })), [run, workspaceId] ); const runExtract = useCallback( (databaseId: string, text: string) => run((signal) => sendDbExtract({ workspaceId, databaseId, text, signal })), [run, workspaceId] ); const abort = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; }, []); // Abort on unmount (the dialog unmounts when closed). useEffect(() => () => abortRef.current?.abort(), []); return { phase, result, error, runGenerate, runExtract, abort }; }