spb/worthdoing Public
Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL
TypeScript 91.5%
SQL 5.8%
CSS 2.2%
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/lib/ui/api.ts6 * Description: Client-side API types and the resilient SSE hook (auto-reconnect with Last-Event-ID replay).7 */8"use client";910import { useEffect, useRef, useState, useCallback } from "react";1112export type UiAgentEvent = {13 seq: number;14 investigationId: string;15 type: string;16 payload: Record<string, unknown>;17 createdAt: string;18};1920export type UiBudget = {21 maxAgentSteps: number;22 maxSearches: number;23 maxScrapes: number;24 maxCrawls: number;25 maxWallTimeMs: number;26};2728export type UiBudgetUsed = {29 agentSteps: number;30 searches: number;31 scrapes: number;32 crawls: number;33 inputTokens: number;34 outputTokens: number;35 costUsd: number;36};3738export type UiHypothesis = {39 id: string;40 title: string;41 statement: string;42 status: "proposed" | "investigating" | "supported" | "weakened" | "rejected" | "validated";43 confidence: number;44 rationale: string | null;45 adversarialChecked: boolean;46 parentHypothesisId: string | null;47};4849export type UiOpportunitySummary = {50 id: string;51 title: string;52 summary: string;53 status: string;54 worthScore: number | null;55 evidenceConfidence: number | null;56 hasReport: boolean;57};5859export type UiInvestigation = {60 id: string;61 objective: string;62 status: "pending" | "running" | "completed" | "failed" | "cancelled";63 phase: "scouting" | "investigating" | "skeptic" | "synthesizing" | "done";64 stopReason: string | null;65 outcome: string | null;66 conclusion: string | null;67 budget: UiBudget;68 budgetUsed: UiBudgetUsed;69 model: string;70 createdAt: string;71 startedAt: string | null;72 completedAt: string | null;73 error: string | null;74};7576export type InvestigationSnapshot = {77 investigation: UiInvestigation;78 hypotheses: UiHypothesis[];79 opportunities: UiOpportunitySummary[];80 evidenceCount: number;81 searchCount: number;82 sources: { id: string; canonicalUrl: string; title: string | null }[];83};8485export async function fetchSnapshot(id: string): Promise<InvestigationSnapshot> {86 const res = await fetch(`/api/investigations/${id}`, { cache: "no-store" });87 if (!res.ok) throw new Error(`Failed to load investigation (${res.status})`);88 return res.json();89}9091/**92 * Subscribe to the investigation's SSE stream with automatic reconnection.93 * Reconnects pass the last seen seq so no events are lost across network changes.94 */95export function useInvestigationEvents(96 investigationId: string,97 onEvent: (e: UiAgentEvent) => void,98): { connected: boolean } {99 const [connected, setConnected] = useState(false);100 const lastSeqRef = useRef(0);101 const onEventRef = useRef(onEvent);102 onEventRef.current = onEvent;103104 useEffect(() => {105 let es: EventSource | null = null;106 let retryTimer: ReturnType<typeof setTimeout> | null = null;107 let stopped = false;108 let backoff = 1000;109110 const connect = () => {111 if (stopped) return;112 const url = `/api/investigations/${investigationId}/events${113 lastSeqRef.current > 0 ? `?lastEventId=${lastSeqRef.current}` : ""114 }`;115 es = new EventSource(url);116 es.onopen = () => {117 setConnected(true);118 backoff = 1000;119 };120 es.onmessage = handle;121 // Named events: EventSource routes typed events to addEventListener.122 const types = [123 "investigation.started", "agent.plan", "agent.error", "phase.changed", "budget.updated",124 "search.started", "search.completed", "scrape.started", "scrape.completed", "scrape.failed",125 "crawl.started", "crawl.completed", "crawl.failed", "extract.started", "extract.completed",126 "extract.failed", "hypothesis.created", "hypothesis.updated", "hypothesis.rejected",127 "evidence.saved", "opportunity.created", "opportunity.updated", "report.started",128 "report.delta", "report.completed", "investigation.completed", "investigation.failed",129 ];130 for (const t of types) es.addEventListener(t, handle);131 es.onerror = () => {132 setConnected(false);133 es?.close();134 if (!stopped) {135 retryTimer = setTimeout(connect, backoff);136 backoff = Math.min(backoff * 2, 15000);137 }138 };139 };140141 const handle = (raw: MessageEvent) => {142 try {143 const event = JSON.parse(raw.data) as UiAgentEvent;144 if (event.seq > lastSeqRef.current) lastSeqRef.current = event.seq;145 onEventRef.current(event);146 } catch {147 // malformed frame — ignore148 }149 };150151 connect();152 return () => {153 stopped = true;154 if (retryTimer) clearTimeout(retryTimer);155 es?.close();156 };157 }, [investigationId]);158159 return { connected };160}161162/** Poll-once + manual refresh helper for the snapshot. */163export function useSnapshot(id: string): {164 snapshot: InvestigationSnapshot | null;165 refresh: () => void;166 error: string | null;167} {168 const [snapshot, setSnapshot] = useState<InvestigationSnapshot | null>(null);169 const [error, setError] = useState<string | null>(null);170 const refresh = useCallback(() => {171 fetchSnapshot(id)172 .then((s) => {173 setSnapshot(s);174 setError(null);175 })176 .catch((e: Error) => setError(e.message));177 }, [id]);178 useEffect(() => {179 refresh();180 }, [refresh]);181 return { snapshot, refresh, error };182}183