"use client"; import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import ReactMarkdown from "react-markdown"; import { AlertTriangle, Loader2, MessageSquareText, SendHorizonal, X } from "lucide-react"; import { askRag, AiPreStreamError, type RagSource } from "@/lib/ai"; import { cn } from "@/lib/utils"; /** * TASK-0125 — RAG workspace search UI (shared by the search-page card and the * `/ask` full-page route). * * One question input → the cited, streaming answer: * - the FIRST SSE frame is `sources` (the server-side citations) — the * `[n]` chips in the answer + the "Sources" list render from it, so they * are deterministic and independent of the model's output; * - the answer streams as markdown; the model's inline `[n]` markers are * replaced with the matching citation chip (a real link); * - each chip links to the source page/database (deep link by the workspace * slug + the source's `pageId`/`databaseId`/`recordId`) and shows the * source snippet on hover (title tooltip + the Sources row below). * * States: loading skeleton, empty ("No results — try different wording"), * error banner (TASK-0122 semantics: budget / provider / retryable), and the * streamed answer. A11y: the answer is an `aria-live` region; chips are real * links; the form submits on Enter. Mobile: the input + answer stack in the * same column (no fixed widths). */ interface AskAiSearchProps { /** The workspace ID (the RAG endpoint's path param). */ workspaceId: string; /** The workspace slug (citation deep links navigate by slug). */ workspaceSlug: string; /** * The search page's committed query — seeded into the input on first mount * (the card inherits the search context) and kept in sync until the user * types or asks. */ initialQuery?: string; /** Compact (card) vs full (route) layout. */ variant?: "card" | "page"; /** Called with the question as soon as the user asks (URL shareability). */ onAsk?: (q: string) => void; } type Status = "idle" | "loading" | "done" | "error"; /** Build the citation deep link for a source (slug-based, type-aware). */ function sourceHref(slug: string, s: RagSource): string | null { const enc = (id: string) => encodeURIComponent(id); if (s.type === "page") return s.pageId ? `/w/${enc(slug)}/page/${enc(s.pageId)}` : null; if (s.type === "database") return s.id ? `/w/${enc(slug)}/databases/${enc(s.id)}` : null; if (s.type === "record") return s.databaseId ? `/w/${enc(slug)}/databases/${enc(s.databaseId)}` : null; if (s.type === "block") return s.pageId ? `/w/${enc(slug)}/page/${enc(s.pageId)}` : null; return null; } /** Map a pre-stream / in-band error to a human banner (TASK-0122 semantics). */ function errorText(e: unknown): { title: string; hint?: string; retryable?: boolean } { if (e instanceof AiPreStreamError) { if (e.code === "AI_BUDGET_EXCEEDED" || e.status === 402) { return { title: "AI budget exhausted", hint: "Ask your workspace admin to raise the monthly AI limit." }; } if (e.code === "RATE_LIMITED" || e.status === 429) { return { title: "AI call limit reached", hint: e.retryAfterSeconds ? `Try again in ${e.retryAfterSeconds}s.` : "Slow down a little and retry.", retryable: true }; } if (e.code === "SEARCH_UNAVAILABLE" || e.status === 503) { return { title: "Search is temporarily unavailable", hint: "Your content may not be indexed yet — try again shortly.", retryable: true }; } if (e.status === 404) return { title: "Workspace not found" }; if (e.status === 403) return { title: "You do not have access to this workspace" }; } const msg = e instanceof Error && e.message ? e.message : "Something went wrong."; return { title: "The AI request failed", hint: msg, retryable: true }; } export function AskAiSearch({ workspaceId, workspaceSlug, initialQuery = "", variant = "card", onAsk }: AskAiSearchProps) { const [input, setInput] = useState(initialQuery); const [question, setQuestion] = useState(""); const [status, setStatus] = useState("idle"); const [answer, setAnswer] = useState(""); const [sources, setSources] = useState([]); const [err, setErr] = useState<{ title: string; hint?: string; retryable?: boolean } | null>(null); const abortRef = useRef(null); // Keep the input seeded from the search query until the user types/asks. useEffect(() => { if (question === "") setInput(initialQuery); }, [initialQuery, question]); // Abort any in-flight stream on unmount (card removed / route left). useEffect(() => () => abortRef.current?.abort(), []); const ask = (q: string) => { const query = q.trim(); if (!query || status === "loading" || !workspaceId) return; abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setQuestion(query); setAnswer(""); setSources([]); setErr(null); setStatus("loading"); onAsk?.(query); askRag({ workspaceId, query, signal: ac.signal, onFrame: (frame) => { if (frame.type === "sources") setSources(frame.sources ?? []); else if (frame.type === "delta") setAnswer((prev) => prev + frame.text); } }) .then((res) => { setSources((prev) => (res.sources.length > 0 ? res.sources : prev)); setAnswer((prev) => (prev || res.answer)); if (res.reason === "error") { setErr({ title: res.error?.message ?? "The AI request failed", hint: res.error?.code, retryable: true }); } setStatus(res.reason === "error" && !res.answer ? "error" : "done"); }) .catch((e: unknown) => { if (e instanceof Error && e.name === "AbortError") { setStatus((s) => (s === "loading" ? "idle" : s)); return; } setErr(errorText(e)); setStatus("error"); }); }; const stop = () => abortRef.current?.abort(); const hasAnswer = answer.trim().length > 0; // Render the streamed answer as markdown. The model's inline `[n]` // citation markers (the convention the mock + the RAG system prompt both // use) are rewritten into markdown links to the source's deep link and // rendered as citation chips; the `a` override chip-ifies ONLY the // hrefs we injected (a model-supplied link is rendered normally). The // Sources list below carries the same chips, so a model that skips inline // markers still yields links. const renderAnswer = () => { const injected = new Map(); const md = answer.replace(/\[(\d+)\]/g, (tok, nStr: string) => { const n = Number(nStr); const s = sources.find((x) => x.n === n); if (!s) return tok; const href = sourceHref(workspaceSlug, s); if (!href) return tok; injected.set(href, s); return `[${n}](${href})`; }); return (

{children}

, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , h1: ({ children }) =>

    {children}

    , h2: ({ children }) =>

    {children}

    , h3: ({ children }) =>

    {children}

    , blockquote: ({ children }) => (
    {children}
    ), pre: ({ children }) =>
    {children}
    , code: ({ children }) => {children}, a: ({ href, children }) => { if (href) { const s = injected.get(href); if (s) { return ( [{s.n}] ); } return ( {children} ); } return <>{children}; } }} > {md}
    ); }; return (

    Ask AI about this workspace

    setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(input); } }} placeholder='Ask AI about this workspace… e.g. "What is the launch code word?"' aria-label="Ask AI about this workspace" disabled={status === "loading"} className="w-full rounded-lg border border-input bg-background py-2.5 pl-3 pr-24 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60" />
    {status === "loading" ? ( ) : ( )}
    {status === "loading" && (
    Retrieving workspace content and generating an answer…
    )} {err !== null && status !== "loading" && (

    {err.title}

    {err.hint ?

    {err.hint}

    : null} {err.retryable ?

    Press Ask to retry.

    : null}
    )} {status !== "loading" && err === null && !question && (

    Ask a question about anything in this workspace — pages, databases, records and comments.

    )} {status !== "loading" && err === null && question && sources.length === 0 && !hasAnswer && (
    No results — try different wording.
    )} {hasAnswer && (
    {renderAnswer()} {status === "loading" ? ( ) : null}
    )} {sources.length > 0 && (

    Sources

      {sources.map((s) => { const href = sourceHref(workspaceSlug, s); const row = ( <> [{s.n}] {s.title || "(untitled)"} · {s.type} {s.snippet ? — {s.snippet} : null} ); return (
    • {href ? ( {row} ) : ( {row} )}
    • ); })}
    )}
    ); }