SPB Git

spb/search-box Public

Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.

TypeScript 76.9% CSS 18.7% SQL 2.1% JavaScript 1.8% Shell 0.5%
14.8 KB · 466 lines tsx
Raw Blame History
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/SessionView.tsx6 * Description: Live research view — event-sourced UI (bloom graph, pastille timeline, claims, answer).7 */89"use client";1011import { useEffect, useMemo, useReducer, useRef, useState } from "react";12import type { ResearchEventPayload, CitationMapEntry } from "@search-box/events";13import type { Claim, Contradiction, SessionStatus, Source } from "@search-box/shared";14import { AnswerPanel } from "./AnswerPanel";15import { ResearchGraph } from "./ResearchGraph";16import { Glyph, Pastille, type PastilleKind } from "./Pastille";1718/* ------------------------------- view state ------------------------------- */1920interface FeedItem {21  key: string;22  kind: PastilleKind;23  label: string;24  title: string;25  detail?: string;26  quote?: string;27  stance?: string;28  status?: string;29  confidence?: number;30  objectives?: string[];31  pending?: boolean;32}3334interface ViewState {35  status: SessionStatus;36  feed: FeedItem[];37  objectives: string[];38  answer: string;39  answerDone: boolean;40  citations: CitationMapEntry[];41  sources: Map<string, Source>;42  claims: Map<string, Claim>;43  contradictions: Contradiction[];44  error: string | null;45  seq: number;46}4748function initialState(status: SessionStatus): ViewState {49  return {50    status,51    feed: [],52    objectives: [],53    answer: "",54    answerDone: false,55    citations: [],56    sources: new Map(),57    claims: new Map(),58    contradictions: [],59    error: null,60    seq: 061  };62}6364function reduce(state: ViewState, ev: { seq: number; payload: ResearchEventPayload }): ViewState {65  const p = ev.payload;66  const next: ViewState = { ...state, seq: ev.seq };67  const push = (item: FeedItem) => {68    next.feed = [...next.feed, item];69  };7071  switch (p.type) {72    case "session.status":73      next.status = p.status;74      break;75    case "session.failed":76      next.error = p.error;77      push({ key: `e${ev.seq}`, kind: "failed", label: "failed", title: p.error });78      break;79    case "session.completed":80      push({ key: `e${ev.seq}`, kind: "done", label: "research complete", title: "Answer delivered with full provenance." });81      break;82    case "plan.updated":83      next.objectives = p.objectives;84      push({ key: `e${ev.seq}`, kind: "plan", label: "plan", title: p.publicReason, objectives: p.objectives });85      break;86    case "thought":87      push({ key: `e${ev.seq}`, kind: "thought", label: "reasoning", title: p.publicReason });88      break;89    case "action.started":90      push({91        key: p.actionId,92        kind: p.kind === "search" ? "search" : "fetch",93        label: p.kind === "search" ? "searching" : "reading",94        title: p.label,95        pending: true96      });97      break;98    case "action.completed":99      next.feed = next.feed.map((item) =>100        item.key === p.actionId101          ? {102              ...item,103              pending: false,104              label: p.ok ? (item.kind === "search" ? "searched" : "read source") : `${item.kind} failed`,105              kind: p.ok && item.kind === "fetch" ? "read" : item.kind,106              detail: p.summary107            }108          : item109      );110      break;111    case "source.added":112    case "source.updated": {113      const sources = new Map(next.sources);114      sources.set(p.source.id, p.source);115      next.sources = sources;116      break;117    }118    case "claim.added": {119      const claims = new Map(next.claims);120      claims.set(p.claim.id, p.claim);121      next.claims = claims;122      push({ key: `e${ev.seq}`, kind: "claim", label: "new claim", title: p.claim.text });123      break;124    }125    case "claim.updated": {126      const claims = new Map(next.claims);127      claims.set(p.claim.id, p.claim);128      next.claims = claims;129      push({130        key: `e${ev.seq}`,131        kind: "belief",132        label: "belief update",133        title: p.claim.text,134        status: p.claim.status,135        confidence: p.claim.confidence,136        detail: p.claim.publicReason ?? undefined137      });138      break;139    }140    case "evidence.added":141      push({142        key: `e${ev.seq}`,143        kind: "evidence",144        label: `evidence · ${p.evidence.stance}`,145        title: p.sourceTitle ?? p.sourceUrl,146        quote: p.evidence.quote,147        stance: p.evidence.stance148      });149      break;150    case "contradiction.added":151      next.contradictions = [...next.contradictions, p.contradiction];152      push({ key: `e${ev.seq}`, kind: "contradiction", label: "contradiction", title: p.contradiction.description });153      break;154    case "synthesis.started":155      push({ key: `e${ev.seq}`, kind: "synthesis", label: "writing", title: "Evidence base closed — writing the answer." });156      break;157    case "answer.delta":158      next.answer = state.answer + p.delta;159      break;160    case "answer.completed":161      next.answer = p.answer;162      next.answerDone = true;163      next.citations = p.citations;164      break;165    default:166      break;167  }168  return next;169}170171/* --------------------------------- view ---------------------------------- */172173type Tab = "live" | "answer" | "evidence";174175export function SessionView({176  id,177  question,178  initialStatus179}: {180  id: string;181  question: string;182  initialStatus: SessionStatus;183}) {184  const [state, dispatch] = useReducer(185    (s: ViewState, ev: { seq: number; payload: ResearchEventPayload }) => reduce(s, ev),186    initialStatus,187    initialState188  );189  const [tab, setTab] = useState<Tab>("live");190  const feedEndRef = useRef<HTMLDivElement>(null);191  const autoTabbed = useRef(false);192193  // Event-sourced: replay from seq 0; EventSource reconnects with Last-Event-ID.194  useEffect(() => {195    const es = new EventSource(`/api/research/${id}/stream?from=0`);196    es.onmessage = (msg) => {197      try {198        const payload = JSON.parse(msg.data) as ResearchEventPayload;199        dispatch({ seq: Number(msg.lastEventId || 0), payload });200      } catch {201        // ignore malformed frames202      }203    };204    es.addEventListener("end", () => es.close());205    return () => es.close();206  }, [id]);207208  useEffect(() => {209    if (state.answer.length > 0 && !autoTabbed.current) {210      autoTabbed.current = true;211      if (window.innerWidth < 1024) setTab("answer");212    }213  }, [state.answer]);214215  useEffect(() => {216    if (tab === "live") feedEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });217  }, [state.feed.length, tab]);218219  const claims = useMemo(() => [...state.claims.values()], [state.claims]);220221  const stats = useMemo(() => {222    const fetched = [...state.sources.values()].filter((s) => s.status === "fetched").length;223    const avg = claims.length > 0 ? claims.reduce((a, c) => a + c.confidence, 0) / claims.length : null;224    return { sources: fetched, claims: claims.length, contradictions: state.contradictions.length, confidence: avg };225  }, [state.sources, claims, state.contradictions]);226227  const live = state.status === "running" || state.status === "synthesizing" || state.status === "pending";228229  const citedSources = useMemo(() => {230    if (state.citations.length > 0) return state.citations;231    return [...state.sources.values()]232      .filter((s) => s.citationIndex !== null)233      .sort((a, b) => (a.citationIndex ?? 0) - (b.citationIndex ?? 0))234      .map((s) => ({ index: s.citationIndex as number, sourceId: s.id, url: s.url, title: s.title }));235  }, [state.citations, state.sources]);236237  const phaseLabel =238    state.status === "failed"239      ? "failed"240      : state.status === "completed"241        ? "complete"242        : state.status === "synthesizing"243          ? "writing answer"244          : "researching";245246  return (247    <main>248      <div className="question-head">249        <div className="kicker">research session</div>250        <h1>{question}</h1>251      </div>252253      <div className="statsbar" role="status">254        <span className={`stat-seg phase ${state.status === "completed" ? "done" : ""} ${state.status === "failed" ? "dead" : ""}`}>255          <span className={`pulse ${live ? "" : "still"}`} />256          <b>{phaseLabel}</b>257        </span>258        <span className="stat-seg">259          <Pastille kind="source" size="sm" />260          sources <b>{stats.sources}</b>261        </span>262        <span className="stat-seg">263          <Pastille kind="claim" size="sm" />264          claims <b>{stats.claims}</b>265        </span>266        <span className="stat-seg">267          <Pastille kind="contradiction" size="sm" />268          contradictions <b>{stats.contradictions}</b>269        </span>270        <span className="stat-seg">271          <Pastille kind="confidence" size="sm" />272          confidence <b>{stats.confidence === null ? "—" : `${Math.round(stats.confidence * 100)}%`}</b>273        </span>274      </div>275276      {(state.objectives.length > 0 || claims.length > 0) && (277        <ResearchGraph278          objectives={state.objectives}279          claims={claims}280          live={live}281          hasContradiction={state.contradictions.length > 0}282        />283      )}284285      {state.error && (286        <div className="error-box">287          <Pastille kind="failed" />288          research failed: {state.error}289        </div>290      )}291292      <div className="tabbar">293        {(294          [295            ["live", "plan"],296            ["answer", "synthesis"],297            ["evidence", "evidence"]298          ] as Array<[Tab, PastilleKind]>299        ).map(([t, icon]) => (300          <button key={t} className={tab === t ? "active" : ""} onClick={() => setTab(t)}>301            <Glyph kind={icon} size={13} />302            {t}303          </button>304        ))}305      </div>306307      <div className="panes">308        <section className={`pane ${tab === "live" ? "visible" : ""}`} aria-label="Live research feed">309          <h2>310            <Glyph kind="plan" size={13} /> Live research311          </h2>312          <div className="tl">313            {state.feed.map((item) => (314              <TimelineRow key={item.key} item={item} />315            ))}316            {state.feed.length === 0 && (317              <div className="tl-empty">318                <Pastille kind="search" spinning={live} />319                waiting for the first research event…320              </div>321            )}322            <div ref={feedEndRef} />323          </div>324        </section>325326        <section className={`pane ${tab === "answer" ? "visible" : ""}`} aria-label="Answer">327          <h2>328            <Glyph kind="synthesis" size={13} /> Answer329          </h2>330          <AnswerPanel markdown={state.answer} done={state.answerDone} live={live} />331        </section>332333        <section className={`pane evidence-pane ${tab === "evidence" ? "visible" : ""}`} aria-label="Claims and sources">334          {claims.length > 0 && (335            <div className="board">336              <h2>337                <Glyph kind="claim" size={13} /> Claims under investigation338              </h2>339              <div className="claims-grid">340                {claims.map((c) => (341                  <ClaimCard key={c.id} claim={c} />342                ))}343              </div>344            </div>345          )}346347          <div className="board">348            <h2>349              <Glyph kind="source" size={13} /> Sources{" "}350              {citedSources.length > 0 ? `— ${citedSources.length} cited` : `— ${state.sources.size} seen`}351            </h2>352            <ul className="source-list">353              {(citedSources.length > 0354                ? citedSources.map((c) => ({355                    key: c.sourceId,356                    idx: String(c.index),357                    title: c.title ?? c.url,358                    url: c.url,359                    anchor: `src-${c.index}`360                  }))361                : [...state.sources.values()]362                    .filter((s) => s.status === "fetched")363                    .map((s) => ({364                      key: s.id,365                      idx: null as string | null,366                      title: s.title ?? s.url,367                      url: s.url,368                      anchor: undefined as string | undefined369                    }))370              ).map((s) => (371                <li key={s.key} id={s.anchor}>372                  <a className="source-card" href={s.url} target="_blank" rel="noreferrer noopener">373                    <DomainTile url={s.url} />374                    <span className="meta">375                      <span className="title">{s.title}</span>376                      <span className="domain">{hostname(s.url)}</span>377                    </span>378                    {s.idx && <span className="cite-idx">[{s.idx}]</span>}379                  </a>380                </li>381              ))}382            </ul>383          </div>384        </section>385      </div>386    </main>387  );388}389390function TimelineRow({ item }: { item: FeedItem }) {391  return (392    <div className={`tl-row ${item.kind === "contradiction" ? "is-contradiction" : ""}`}>393      <Pastille kind={item.kind} spinning={item.pending === true} />394      <div className="tl-card">395        <div className="tl-head">396          {item.label}397          {item.status && (398            <span className={`status-chip ${item.status}`}>399              {item.status}400              {item.confidence !== undefined ? ` · ${Math.round(item.confidence * 100)}%` : ""}401            </span>402          )}403        </div>404        <div className="tl-body">405          {item.title}406          {item.detail && (407            <>408              {" "}409              <span className="mono">— {item.detail}</span>410            </>411          )}412          {item.objectives && (413            <ul className="tl-objectives">414              {item.objectives.map((o, i) => (415                <li key={i}>{o}</li>416              ))}417            </ul>418          )}419          {item.quote && (420            <div className={`tl-quote ${item.stance === "contradicts" ? "contradicts" : ""}`}>“{item.quote}”</div>421          )}422        </div>423      </div>424    </div>425  );426}427428function ClaimCard({ claim }: { claim: Claim }) {429  return (430    <div className="claim-card">431      <div className="tl-head">432        <span className={`status-chip ${claim.status}`}>{claim.status}</span>433      </div>434      <div className="txt">{claim.text}</div>435      {claim.publicReason && <div className="why">{claim.publicReason}</div>}436      <div className={`meter ${claim.status}`}>437        <span className="bar">438          <span className="fill" style={{ width: `${Math.round(claim.confidence * 100)}%` }} />439        </span>440        <span className="pct">{Math.round(claim.confidence * 100)}%</span>441      </div>442    </div>443  );444}445446/** Home-made favicon substitute: deterministic hue tile from the domain. */447function DomainTile({ url }: { url: string }) {448  const domain = hostname(url);449  let hash = 0;450  for (let i = 0; i < domain.length; i++) hash = (hash * 31 + domain.charCodeAt(i)) | 0;451  const hue = Math.abs(hash) % 360;452  return (453    <span className="tile" style={{ background: `hsl(${hue} 42% 42%)` }}>454      {domain.replace(/^www\./, "").charAt(0)}455    </span>456  );457}458459function hostname(url: string): string {460  try {461    return new URL(url).hostname;462  } catch {463    return url;464  }465}466