SPB Git

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%
5.2 KB · 183 lines typescript
Raw Blame History
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