"use client"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createIndex, search } from "./bm25"; import type { AskAnswer, Citation, PortfolioDocument, SearchHit } from "./types"; export const ASK_EVENT = "agentic-search:ask"; /** Programmatically open the search in Ask mode with a question (e.g. from a CTA). */ export function askAgenticSearch(question: string) { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(ASK_EVENT, { detail: { question } })); } type Mode = "search" | "ask"; export interface AgenticPortfolioSearchProps { /** Corpus for zero-latency client-side instant search. */ documents?: PortfolioDocument[]; /** Used for instant search when `documents` is not provided. */ searchEndpoint?: string; askEndpoint?: string; prompts?: string[]; kicker?: string; title?: ReactNode; placeholder?: string; askPlaceholder?: string; defaultMode?: Mode; /** Render a link (e.g. wrap in a hover-preview component). */ renderLink?: (target: Citation, children: ReactNode, className: string) => ReactNode; /** Slot under the panel (e.g. an open-source badge). */ footer?: ReactNode; /** Global hotkey that focuses the input (default "/"; pass null to disable). */ hotkey?: string | null; /** Read `?ask=` from the URL on mount and answer it (default true). */ readAskParam?: boolean; id?: string; className?: string; } const host = (url?: string) => (url ? url.replace(/^https?:\/\//, "").replace(/\/$/, "") : ""); function defaultLink(target: Citation, children: ReactNode, className: string) { if (!target.url) return {children}; const external = /^https?:\/\//.test(target.url); return ( {children} ); } function withCitations(text: string, citations: Citation[], link: NonNullable) { const parts = text.split(/(\[\d+\])/g); return parts.map((part, i) => { const m = part.match(/^\[(\d+)\]$/); const c = m ? citations[Number(m[1]) - 1] : undefined; if (!m || !c) return {part}; return {link(c, m[1], "aps-cite-ref")}; }); } export function AgenticPortfolioSearch({ documents, searchEndpoint = "/api/search", askEndpoint = "/api/ask", prompts = [], kicker = "Agentic Search", title, placeholder = "Search projects, roles, skills, writing…", askPlaceholder = "Ask a question — answers cite their sources", defaultMode = "search", renderLink = defaultLink, footer, hotkey = "/", readAskParam = true, id = "ask", className = "", }: AgenticPortfolioSearchProps) { const [mode, setMode] = useState(defaultMode); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const [remoteHits, setRemoteHits] = useState([]); const [ask, setAsk] = useState<{ status: "idle" | "loading" | "done" | "error"; data?: AskAnswer }>({ status: "idle" }); const inputRef = useRef(null); const rootRef = useRef(null); const askSeq = useRef(0); const index = useMemo(() => (documents?.length ? createIndex(documents) : null), [documents]); const localHits = useMemo(() => (index && query.trim() ? search(index, query, { limit: 8 }) : []), [index, query]); const hits = index ? localHits : remoteHits; useEffect(() => { if (index || mode !== "search" || !query.trim()) return setRemoteHits([]); const ctrl = new AbortController(); const t = setTimeout(() => { fetch(searchEndpoint, { method: "POST", signal: ctrl.signal, headers: { "content-type": "application/json" }, body: JSON.stringify({ query }) }) .then((r) => r.json()) .then((j: { hits?: SearchHit[] }) => setRemoteHits(j.hits || [])) .catch(() => {}); }, 90); return () => { clearTimeout(t); ctrl.abort(); }; }, [index, mode, query, searchEndpoint]); useEffect(() => setActive(0), [query, mode]); const runAsk = useCallback( async (question: string) => { const q = question.trim(); if (!q) return; const seq = ++askSeq.current; setAsk({ status: "loading" }); try { const res = await fetch(askEndpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ question: q }) }); const data = (await res.json()) as AskAnswer; if (seq === askSeq.current) setAsk({ status: res.ok ? "done" : "error", data }); } catch { if (seq === askSeq.current) setAsk({ status: "error" }); } }, [askEndpoint], ); const askNow = useCallback( (q: string) => { setMode("ask"); setQuery(q); void runAsk(q); }, [runAsk], ); // External triggers: CTA events + ?ask= deep links. useEffect(() => { const onAsk = (e: Event) => { const q = (e as CustomEvent<{ question?: string }>).detail?.question || ""; rootRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); askNow(q); window.setTimeout(() => inputRef.current?.focus({ preventScroll: true }), 350); }; window.addEventListener(ASK_EVENT, onAsk); if (readAskParam) { const q = new URLSearchParams(window.location.search).get("ask"); if (q) { askNow(q); window.setTimeout(() => rootRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 120); } } return () => window.removeEventListener(ASK_EVENT, onAsk); }, [askNow, readAskParam]); // Global hotkey focuses the input. useEffect(() => { if (!hotkey) return; const onKey = (e: KeyboardEvent) => { if (e.key !== hotkey || e.metaKey || e.ctrlKey || e.altKey) return; const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return; e.preventDefault(); inputRef.current?.focus(); rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [hotkey]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (query) setQuery(""); else inputRef.current?.blur(); setAsk({ status: "idle" }); return; } if (e.key === "Enter" && (e.metaKey || e.ctrlKey || mode === "ask")) { e.preventDefault(); askNow(query); return; } if (mode !== "search" || !hits.length) return; if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(hits.length - 1, a + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === "Enter") { e.preventDefault(); const url = hits[active]?.doc.url; if (url) window.open(url, /^https?:/.test(url) ? "_blank" : "_self", "noopener"); } }; const answer = ask.data; return (
{kicker}
{(["search", "ask"] as const).map((m) => ( ))}
{title ?

{title}

: null}
{ e.preventDefault(); if (mode === "ask") askNow(query); }} > {mode === "ask" ? : <>} setQuery(e.target.value)} onKeyDown={onKeyDown} placeholder={mode === "ask" ? askPlaceholder : placeholder} aria-label={mode === "ask" ? "Ask a question" : "Search"} aria-controls={`${id}-results`} autoComplete="off" spellCheck={false} enterKeyHint={mode === "ask" ? "send" : "search"} /> {mode === "ask" ? ( ) : ( {hotkey || "⏎"} )}
{prompts.length ? (
{prompts.map((p) => ( ))}
) : null}
{mode === "search" && query.trim() ? ( hits.length ? (
    {hits.map((h, i) => (
  • setActive(i)}> {renderLink( { id: h.doc.id, title: h.doc.title, url: h.doc.url, category: h.doc.category, previewKey: h.doc.previewKey }, <> {h.doc.title} {h.doc.summary} {h.doc.category} {host(h.doc.url) ? {host(h.doc.url)} : null} , "aps-hit", )}
  • ))}
  • ↑↓ to move · ⏎ to open ·{" "}
) : (

No direct matches.{" "}

) ) : null} {mode === "ask" && ask.status === "loading" ? (
) : null} {mode === "ask" && ask.status === "error" ?

Couldn’t reach the answer service — try again.

: null} {mode === "ask" && ask.status === "done" && answer ? (

{withCitations(answer.answer, answer.citations, renderLink)}

{answer.points.length ? (
    {answer.points.map((p) => (
  • {withCitations(p, answer.citations, renderLink)}
  • ))}
) : null} {answer.citations.length ? (
    {answer.citations.map((c, i) => (
  1. {renderLink( c, <> {i + 1} {c.title} {c.category} {host(c.url) ? {host(c.url)} : null} , "aps-cite", )}
  2. ))}
) : null}

{answer.mode === "llm" ? `Grounded LLM answer (${answer.model})` : "Deterministic grounded answer · BM25 + verified facts"} ·{" "} {answer.tookMs}ms · cites only indexed sources

) : null}
{footer ?
{footer}
: null}
); }