feat: add frontend support for surfsense docs citations

This commit is contained in:
CREDO23 2026-01-09 18:21:59 +02:00
parent 3539b2a83d
commit abd3bace53
4 changed files with 43 additions and 15 deletions

View file

@ -7,13 +7,15 @@ import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
interface InlineCitationProps { interface InlineCitationProps {
chunkId: number; chunkId: number;
citationNumber: number; citationNumber: number;
isDocsChunk?: boolean;
} }
/** /**
* Inline citation component for the new chat. * Inline citation component for the new chat.
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details. * Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
* Supports both regular knowledge base chunks and Surfsense documentation chunks.
*/ */
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => { export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber, isDocsChunk = false }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
@ -21,10 +23,11 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumbe
open={isOpen} open={isOpen}
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
chunkId={chunkId} chunkId={chunkId}
sourceType="" sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
title="Source" title={isDocsChunk ? "Surfsense Documentation" : "Source"}
description="" description=""
url="" url=""
isDocsChunk={isDocsChunk}
> >
<span <span
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}

View file

@ -15,8 +15,8 @@ import { InlineCitation } from "@/components/assistant-ui/inline-citation";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID] // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
const CITATION_REGEX = /\[citation:(\d+)\]/g; const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering // Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering // This map is reset when a new message starts rendering
@ -33,16 +33,20 @@ export function resetCitationCounter() {
/** /**
* Gets or assigns a citation number for a chunk ID * Gets or assigns a citation number for a chunk ID
* Uses string key to differentiate between doc and regular chunks
*/ */
function getCitationNumber(chunkId: number): number { function getCitationNumber(chunkId: number, isDocsChunk: boolean): number {
if (!chunkIdToCitationNumber.has(chunkId)) { const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId);
chunkIdToCitationNumber.set(chunkId, nextCitationNumber++); const existingNumber = chunkIdToCitationNumber.get(key as unknown as number);
if (existingNumber === undefined) {
chunkIdToCitationNumber.set(key as unknown as number, nextCitationNumber++);
} }
return chunkIdToCitationNumber.get(chunkId)!; return chunkIdToCitationNumber.get(key as unknown as number)!;
} }
/** /**
* Parses text and replaces [citation:XXX] patterns with InlineCitation components * Parses text and replaces [citation:XXX] patterns with InlineCitation components
* Supports both regular chunks [citation:123] and docs chunks [citation:doc-123]
*/ */
function parseTextWithCitations(text: string): ReactNode[] { function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = []; const parts: ReactNode[] = [];
@ -59,14 +63,16 @@ function parseTextWithCitations(text: string): ReactNode[] {
parts.push(text.substring(lastIndex, match.index)); parts.push(text.substring(lastIndex, match.index));
} }
// Add the citation component // Check if this is a docs chunk (has "doc-" prefix)
const chunkId = Number.parseInt(match[1], 10); const isDocsChunk = match[1] === "doc-";
const citationNumber = getCitationNumber(chunkId); const chunkId = Number.parseInt(match[2], 10);
const citationNumber = getCitationNumber(chunkId, isDocsChunk);
parts.push( parts.push(
<InlineCitation <InlineCitation
key={`citation-${chunkId}-${instanceIndex}`} key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
chunkId={chunkId} chunkId={chunkId}
citationNumber={citationNumber} citationNumber={citationNumber}
isDocsChunk={isDocsChunk}
/> />
); );

View file

@ -34,6 +34,7 @@ interface SourceDetailPanelProps {
description?: string; description?: string;
url?: string; url?: string;
children?: ReactNode; children?: ReactNode;
isDocsChunk?: boolean;
} }
const formatDocumentType = (type: string) => { const formatDocumentType = (type: string) => {
@ -114,6 +115,7 @@ export function SourceDetailPanel({
description, description,
url, url,
children, children,
isDocsChunk = false,
}: SourceDetailPanelProps) { }: SourceDetailPanelProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
@ -132,8 +134,13 @@ export function SourceDetailPanel({
isLoading: isDocumentByChunkFetching, isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError, error: documentByChunkFetchingError,
} = useQuery({ } = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()), queryKey: isDocsChunk
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }), ? cacheKeys.documents.byChunk(`doc-${chunkId}`)
: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () =>
isDocsChunk
? documentsApiService.getSurfsenseDocByChunk(chunkId)
: documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
enabled: !!chunkId && open, enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });

View file

@ -209,6 +209,18 @@ class DocumentsApiService {
); );
}; };
/**
* Get Surfsense documentation by chunk ID
* Used for resolving [citation:doc-XXX] citations
*/
getSurfsenseDocByChunk = async (chunkId: number) => {
// Response shape matches getDocumentByChunkResponse structure
return baseApiService.get(
`/api/v1/surfsense-docs/by-chunk/${chunkId}`,
getDocumentByChunkResponse
);
};
/** /**
* Update a document * Update a document
*/ */