"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Citation } from "../lib/sessionStorage";
interface CitationBlockProps {
citations: Citation[];
}
interface VisualGroundingState {
isOpen: boolean;
chunkId: string | null;
images: string[];
loading: boolean;
error: string | null;
}
function CitationItem({
citation,
onViewInDocument,
}: {
citation: Citation;
onViewInDocument: (chunkId: string, refs?: string[]) => void;
}) {
const [expanded, setExpanded] = useState(false);
const title = citation.document_title || citation.document_uri || "Unknown";
const pageInfo =
citation.page_numbers.length > 0
? `p. ${citation.page_numbers.join(", ")}`
: null;
return (
{expanded && (
{citation.headings && citation.headings.length > 0 && (
{citation.headings.join(" › ")}
)}
{citation.content}
)}
);
}
export default function CitationBlock({ citations }: CitationBlockProps) {
const [visualGrounding, setVisualGrounding] = useState({
isOpen: false,
chunkId: null,
images: [],
loading: false,
error: null,
});
// AbortController for the in-flight visualize fetch so a rapid close/reopen
// doesn't let a stale response overwrite the new request's state.
const abortRef = useRef(null);
// Abort any in-flight request on unmount.
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const fetchVisualGrounding = useCallback(
async (chunkId: string, refs?: string[]) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setVisualGrounding({
isOpen: true,
chunkId,
images: [],
loading: true,
error: null,
});
const query = refs?.length
? `?refs=${encodeURIComponent(JSON.stringify(refs))}`
: "";
try {
const response = await fetch(
`/api/visualize/${encodeURIComponent(chunkId)}${query}`,
{
signal: controller.signal,
},
);
const data = await response.json();
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error(data.error || "Failed to fetch visual grounding");
}
setVisualGrounding((prev) => ({
...prev,
images: data.images || [],
loading: false,
error: data.images?.length === 0 ? data.message : null,
}));
} catch (err) {
if (controller.signal.aborted) return;
setVisualGrounding((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Unknown error",
}));
}
},
[],
);
const closeVisualGrounding = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setVisualGrounding({
isOpen: false,
chunkId: null,
images: [],
loading: false,
error: null,
});
}, []);
if (!citations || citations.length === 0) {
return null;
}
return (
<>
Sources ({citations.length})
{citations.map((citation) => (
))}
{visualGrounding.isOpen && (
e.key === "Escape" && closeVisualGrounding()}
role="dialog"
aria-modal="true"
aria-label="Visual grounding"
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */}
e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
Visual Grounding
{visualGrounding.loading && (
Loading...
)}
{visualGrounding.error && (
{visualGrounding.error}
)}
{!visualGrounding.loading &&
!visualGrounding.error &&
visualGrounding.images.length > 0 && (
{visualGrounding.images.map((img, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: images have no stable id
Page {idx + 1} of {visualGrounding.images.length}
{/* biome-ignore lint/performance/noImgElement: base64 data URLs require img element */}
))}
)}
)}
>
);
}