"use client"; import { useQuery } from "@tanstack/react-query"; import { useSetAtom } from "jotai"; import { ExternalLink, FileText } from "lucide-react"; import dynamic from "next/dynamic"; import type { FC } from "react"; import { useState } from "react"; import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom"; import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context"; import { Citation } from "@/components/tool-ui/citation"; import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useMediaQuery } from "@/hooks/use-media-query"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; // Lazily load MarkdownViewer here to break the static import cycle: // `markdown-viewer.tsx` → `citation-renderer.tsx` → `inline-citation.tsx` // would otherwise pull `markdown-viewer.tsx` back in at module-init time. // Only `SurfsenseDocCitation` (popover body) ever renders this viewer, so // the lazy boundary is invisible to most call paths. const MarkdownViewer = dynamic( () => import("@/components/markdown-viewer").then((m) => m.MarkdownViewer), { ssr: false, loading: () => } ); interface InlineCitationProps { chunkId: number; isDocsChunk?: boolean; } /** * Inline citation badge for knowledge-base chunks (numeric chunk IDs) and * Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as * a static "doc" pill (anonymous/synthetic uploads). * * Numeric KB chunks: clicking opens the citation panel in the right * sidebar (alongside the chat — does not replace it). The panel shows * the cited chunk surrounded by adjacent chunks (via the API's * `chunk_window`), with the cited one highlighted and an option to * expand the window or jump into the full document via the editor panel. * * Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that * lazily fetches and previews the cited chunk inline, since those docs aren't * indexed into the user's search space and have no tab to open. */ export const InlineCitation: FC = ({ chunkId, isDocsChunk = false }) => { if (chunkId < 0) { return ( doc Uploaded document ); } if (isDocsChunk) { return ; } return ; }; const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => { const openCitationPanel = useSetAtom(openCitationPanelAtom); return ( openCitationPanel({ chunkId })} className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline" title={`View source chunk #${chunkId}`} aria-label={`View cited chunk ${chunkId}`} > {chunkId} ); }; const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => { const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)"); const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false); const docQuery = useSurfsenseDocPreviewQuery(chunkId, mobilePreviewOpen); const handleMobileClick = () => { setMobilePreviewOpen(true); }; return ( <> ( doc )} > Surfsense documentation > ); }; function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) { return useQuery({ queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`), queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId), staleTime: 5 * 60 * 1000, enabled, }); } type SurfsenseDocPreviewQuery = ReturnType; const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => { const query = useSurfsenseDocPreviewQuery(chunkId); return ; }; const SurfsenseDocPreviewContent: FC<{ chunkId: number; query: SurfsenseDocPreviewQuery; contentClassName?: string; }> = ({ chunkId, query, contentClassName = "max-h-72" }) => { const { data, isLoading, error } = query; const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0]; return ( <> {data?.title ?? "Surfsense documentation"} Chunk #{chunkId} {data?.public_url && ( Open )} {isLoading && ( Loading… )} {error && ( {error instanceof Error ? error.message : "Failed to load chunk"} )} {!isLoading && !error && citedChunk?.content && ( )} {!isLoading && !error && !citedChunk?.content && ( No content available. )} > ); }; function extractDomain(url: string): string { try { const hostname = new URL(url).hostname; return hostname.replace(/^www\./, ""); } catch { return url; } } interface UrlCitationProps { url: string; } /** * Inline citation for live web search results (URL-based chunk IDs). * Renders a compact chip with favicon + domain and a hover popover showing the * page title and snippet (extracted deterministically from web_search tool results). */ export const UrlCitation: FC = ({ url }) => { const domain = extractDomain(url); const meta = useCitationMetadata(url); return ( ); };
{data?.title ?? "Surfsense documentation"}
Chunk #{chunkId}
{error instanceof Error ? error.message : "Failed to load chunk"}
No content available.