"use client"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { Components } from "streamdown"; import { MessageResponse } from "@/components/ai-elements/message"; import { Badge } from "@/components/ui/badge"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger, } from "@/components/ui/preview-card"; import type { SourceData } from "@/lib/ai-chat"; // The agent cites a retrieved record inline with a [[src:]] marker. We turn // each marker into a hash link ([n](#cite-)) — hash links survive // react-markdown's URL sanitization — then override the link renderer to show a // numbered inline citation chip whose hover card names the source. const CITATION_RE = /\[\[\s*src:\s*([a-zA-Z0-9_-]+)\s*\]\]/g; const CITE_HREF_PREFIX = "#cite-"; export function hasCitationMarkers(text: string): boolean { CITATION_RE.lastIndex = 0; return CITATION_RE.test(text); } // Rewrites [[src:id]] → [n](#cite-id) for known sources; strips unknown markers. // Each source is cited at most once (its first occurrence) so an over-eager model // that tags every word with the same source collapses to a single chip. function withCitationLinks( text: string, numberById: Map, ): string { const seen = new Set(); return text.replace(CITATION_RE, (_match, id: string) => { const num = numberById.get(id); if (!num || seen.has(id)) return ""; seen.add(id); return `[${num}](${CITE_HREF_PREFIX}${id})`; }); } function CitationChip({ num, source }: { num: number; source?: SourceData }) { const { t } = useTranslation(); if (!source) return null; const kindKey = `chat.sources.kind.${source.kind}`; const kindLabel = t(kindKey); return ( } > {num}

{kindLabel === kindKey ? t("chat.sources.title") : kindLabel}

{source.title}

); } // Renders assistant markdown with inline citation chips resolved from the // message's streamed sources. export function CitedResponse({ text, sources, }: { text: string; sources: SourceData[]; }) { const { numberById, sourceById, processed } = useMemo(() => { const numberById = new Map(); const sourceById = new Map(); sources.forEach((s, i) => { numberById.set(s.id, i + 1); sourceById.set(s.id, s); }); return { numberById, sourceById, processed: withCitationLinks(text, numberById), }; }, [text, sources]); const components = useMemo( () => ({ a({ href, children, ...props }) { if (typeof href === "string" && href.startsWith(CITE_HREF_PREFIX)) { const id = href.slice(CITE_HREF_PREFIX.length); const num = numberById.get(id) ?? 0; return ; } return ( {children} ); }, }), [numberById, sourceById], ); return {processed}; } // Provenance footer shown beneath a message that has sources — primarily a // fallback when the model didn't place inline markers, so retrieved records are // always attributed. export function SourcesFooter({ sources }: { sources: SourceData[] }) { const { t } = useTranslation(); if (sources.length === 0) return null; return (
{t("chat.sources.title")} {sources.map((s, i) => ( {i + 1} {s.title} ))}
); }