"use client"; // Shared citation rendering for the /ask surfaces (chat answers AND the report). // // A model answer/report cites sources with bracketed markers: the classic `[n]` // and the precise-moment `[n @ mm:ss]` (also tolerated: `[n @ h:mm:ss]` and stray // spaces). `linkifyCitations` rewrites those into in-page Markdown links whose // anchor encodes the source number and, when present, the cited second; // `makeCitationLink` resolves a clicked anchor to `sources[n-1]` and opens the // transcript modal at that moment (snapped to the nearest real line so a slightly // off model timestamp still lands on a transcript cue), falling back to a // scroll-to-source when there's no player (hub /ask). `CitationSources` is the // numbered source list rendered beneath the document. Both the chat and the report // reuse this module so their citations behave identically. import type { AnchorHTMLAttributes } from "react"; import type { RetrievedVideo } from "../lib/askRetrieval"; // Parse a clock string (`mm:ss` or `h:mm:ss`) to whole seconds; null if malformed. export function parseClock(s: string): number | null { const parts = s.split(":"); if (parts.length < 2 || parts.length > 3) return null; let total = 0; for (const p of parts) { const n = parseInt(p, 10); if (!Number.isFinite(n) || !/^\d+$/.test(p)) return null; total = total * 60 + n; } return total; } // One citation marker: a source number, optional `@ mm:ss` moment, tolerating // stray spaces around the number, the `@`, and the time. const CITE_RE = /\[\s*(\d+)\s*(?:@\s*(\d{1,2}(?::\d{2}){1,2})\s*)?\]/g; // Turn `[n]` / `[n @ mm:ss]` citation markers into links to the matching source in // the list below. Skips fenced/inline code, and only links numbers that have a // source (1..maxN). The anchor id is scoped per surface via `cid`; a parsed moment // is encoded as a trailing `-` segment so the click can seek to it. export function linkifyCitations(md: string, maxN: number, cid: string): string { if (maxN <= 0) return md; return md .split(/(```[\s\S]*?```|`[^`]*`)/g) .map((seg, i) => i % 2 === 1 ? seg : seg.replace(CITE_RE, (m, d, t) => { const n = parseInt(d, 10); if (!(n >= 1 && n <= maxN)) return m; const secs = typeof t === "string" ? parseClock(t) : null; const anchor = secs != null ? `#cite-${cid}-${n}-${secs}` : `#cite-${cid}-${n}`; return `[${m}](${anchor})`; }), ) .join(""); } // Scroll to and briefly highlight the source `
  • ` for citation `n` — the // fallback when there's no player (hub /ask) or the source has no timestamp. export function scrollToSource(cid: string, n: number) { const el = document.getElementById(`cite-${cid}-${n}`); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.add("bg-brand-soft"); setTimeout(() => el.classList.remove("bg-brand-soft"), 900); } // Resolve the second a citation should seek to. A parsed `mm:ss` is validated by // snapping to the nearest known snippet second for that source (guards a // hallucinated timestamp); passed through as-is if the source has no snippets. No // parsed time → the source's first matched line (today's behavior). Undefined when // there's nothing to seek to. export function resolveCitationSeconds( src: RetrievedVideo | undefined, parsed: number, ): number | undefined { if (!src) return undefined; const snippets = src.snippets ?? []; if (Number.isFinite(parsed)) { if (snippets.length === 0) return parsed; let best = snippets[0].seconds; let bestDist = Math.abs(best - parsed); for (const s of snippets) { const dist = Math.abs(s.seconds - parsed); if (dist < bestDist) { bestDist = dist; best = s.seconds; } } return best; } return snippets[0]?.seconds; } // Build the link renderer for citation Markdown, closing over this surface's // sources + citation-scope id + the (optional) modal opener. A `#cite--` // (optionally `-`) anchor opens the transcript modal at the cited moment // when a player is available; otherwise it scrolls to the source below. Any other // link opens externally. export function makeCitationLink( cid: string, sources: RetrievedVideo[], onOpenCitation?: (slug: string, seconds?: number) => void, ) { return function CitationLink({ href, children, ...rest }: AnchorHTMLAttributes) { if (typeof href === "string" && href.startsWith("#cite-")) { return ( { e.preventDefault(); // The anchor tail is `-` or `--`; the cid always starts // with a letter, so the dash before it never precedes a digit and this // captures the number (+ optional second) unambiguously. const mm = href.match(/-(\d+)(?:-(\d+))?$/); const n = mm ? parseInt(mm[1], 10) : NaN; const parsed = mm && mm[2] ? parseInt(mm[2], 10) : NaN; const src = Number.isFinite(n) ? sources[n - 1] : undefined; const seconds = resolveCitationSeconds(src, parsed); if (onOpenCitation && src && typeof seconds === "number") { onOpenCitation(src.key, seconds); } else { scrollToSource(cid, n); } }} className="font-mono text-brand no-underline hover:underline" > {children} ); } return ( {children} ); }; } // The numbered source list rendered beneath a cited document — one `
  • ` per // source (its `#cite--` anchor is the scroll-to-source target) with a // per-timestamp button that opens the transcript modal at that moment (plain text // when there's no player). Shared by the chat answer and the report so both lists // look and behave identically. export function CitationSources({ cid, sources, onOpenCitation, truncated, }: { cid: string; sources: RetrievedVideo[]; onOpenCitation?: (slug: string, seconds?: number) => void; truncated?: boolean; }) { if (sources.length === 0) return null; return (
      {sources.map((s, si) => (
    1. [{si + 1}]{" "} {s.url ? ( {s.title} ) : ( s.title )}{" "} — {s.channel} {s.siteTitle ? ` · ${s.siteTitle}` : ""} {/* A post has no timeline, so the per-snippet moment button would render a meaningless 0:00. Show the date instead and let the source title itself be the link. */} {s.isPost ? ( <> · {s.uploadDate} ) : ( <> {" · "} {s.snippets.map((sn, sni) => ( {sni > 0 ? ", " : ""} {onOpenCitation ? ( ) : ( sn.clock )} ))} )}
    2. ))} {truncated && (
    3. (some searches were truncated; ask more specifically for fuller coverage)
    4. )}
    ); }