/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/ui/api.ts * Description: Client-side API types and the resilient SSE hook (auto-reconnect with Last-Event-ID replay). */ "use client"; import { useEffect, useRef, useState, useCallback } from "react"; export type UiAgentEvent = { seq: number; investigationId: string; type: string; payload: Record; createdAt: string; }; export type UiBudget = { maxAgentSteps: number; maxSearches: number; maxScrapes: number; maxCrawls: number; maxWallTimeMs: number; }; export type UiBudgetUsed = { agentSteps: number; searches: number; scrapes: number; crawls: number; inputTokens: number; outputTokens: number; costUsd: number; }; export type UiHypothesis = { id: string; title: string; statement: string; status: "proposed" | "investigating" | "supported" | "weakened" | "rejected" | "validated"; confidence: number; rationale: string | null; adversarialChecked: boolean; parentHypothesisId: string | null; }; export type UiOpportunitySummary = { id: string; title: string; summary: string; status: string; worthScore: number | null; evidenceConfidence: number | null; hasReport: boolean; }; export type UiInvestigation = { id: string; objective: string; status: "pending" | "running" | "completed" | "failed" | "cancelled"; phase: "scouting" | "investigating" | "skeptic" | "synthesizing" | "done"; stopReason: string | null; outcome: string | null; conclusion: string | null; budget: UiBudget; budgetUsed: UiBudgetUsed; model: string; createdAt: string; startedAt: string | null; completedAt: string | null; error: string | null; }; export type InvestigationSnapshot = { investigation: UiInvestigation; hypotheses: UiHypothesis[]; opportunities: UiOpportunitySummary[]; evidenceCount: number; searchCount: number; sources: { id: string; canonicalUrl: string; title: string | null }[]; }; export async function fetchSnapshot(id: string): Promise { const res = await fetch(`/api/investigations/${id}`, { cache: "no-store" }); if (!res.ok) throw new Error(`Failed to load investigation (${res.status})`); return res.json(); } /** * Subscribe to the investigation's SSE stream with automatic reconnection. * Reconnects pass the last seen seq so no events are lost across network changes. */ export function useInvestigationEvents( investigationId: string, onEvent: (e: UiAgentEvent) => void, ): { connected: boolean } { const [connected, setConnected] = useState(false); const lastSeqRef = useRef(0); const onEventRef = useRef(onEvent); onEventRef.current = onEvent; useEffect(() => { let es: EventSource | null = null; let retryTimer: ReturnType | null = null; let stopped = false; let backoff = 1000; const connect = () => { if (stopped) return; const url = `/api/investigations/${investigationId}/events${ lastSeqRef.current > 0 ? `?lastEventId=${lastSeqRef.current}` : "" }`; es = new EventSource(url); es.onopen = () => { setConnected(true); backoff = 1000; }; es.onmessage = handle; // Named events: EventSource routes typed events to addEventListener. const types = [ "investigation.started", "agent.plan", "agent.error", "phase.changed", "budget.updated", "search.started", "search.completed", "scrape.started", "scrape.completed", "scrape.failed", "crawl.started", "crawl.completed", "crawl.failed", "extract.started", "extract.completed", "extract.failed", "hypothesis.created", "hypothesis.updated", "hypothesis.rejected", "evidence.saved", "opportunity.created", "opportunity.updated", "report.started", "report.delta", "report.completed", "investigation.completed", "investigation.failed", ]; for (const t of types) es.addEventListener(t, handle); es.onerror = () => { setConnected(false); es?.close(); if (!stopped) { retryTimer = setTimeout(connect, backoff); backoff = Math.min(backoff * 2, 15000); } }; }; const handle = (raw: MessageEvent) => { try { const event = JSON.parse(raw.data) as UiAgentEvent; if (event.seq > lastSeqRef.current) lastSeqRef.current = event.seq; onEventRef.current(event); } catch { // malformed frame — ignore } }; connect(); return () => { stopped = true; if (retryTimer) clearTimeout(retryTimer); es?.close(); }; }, [investigationId]); return { connected }; } /** Poll-once + manual refresh helper for the snapshot. */ export function useSnapshot(id: string): { snapshot: InvestigationSnapshot | null; refresh: () => void; error: string | null; } { const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(() => { fetchSnapshot(id) .then((s) => { setSnapshot(s); setError(null); }) .catch((e: Error) => setError(e.message)); }, [id]); useEffect(() => { refresh(); }, [refresh]); return { snapshot, refresh, error }; }