Merge pull request #682 from CREDO23/sur-77-impr-add-surfsense-documentation-to-new-default-search

[Feature] Onboarding | Built-in documentation search tool
This commit is contained in:
Rohan Verma 2026-01-12 12:45:52 -08:00 committed by GitHub
commit 33586f1cb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 874 additions and 20 deletions

View file

@ -7,13 +7,15 @@ import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
interface InlineCitationProps {
chunkId: number;
citationNumber: number;
isDocsChunk?: boolean;
}
/**
* Inline citation component for the new chat.
* 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);
return (
@ -21,10 +23,11 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumbe
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType=""
title="Source"
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
description=""
url=""
isDocsChunk={isDocsChunk}
>
<span
onClick={() => setIsOpen(true)}

View file

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

View file

@ -21,10 +21,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
} from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
interface SourceDetailPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -34,6 +40,7 @@ interface SourceDetailPanelProps {
description?: string;
url?: string;
children?: ReactNode;
isDocsChunk?: boolean;
}
const formatDocumentType = (type: string) => {
@ -114,6 +121,7 @@ export function SourceDetailPanel({
description,
url,
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
@ -131,9 +139,16 @@ export function SourceDetailPanel({
data: documentData,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
} = useQuery<DocumentData>({
queryKey: isDocsChunk
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: async () => {
if (isDocsChunk) {
return documentsApiService.getSurfsenseDocByChunk(chunkId);
}
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
},
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
@ -325,7 +340,7 @@ export function SourceDetailPanel({
{documentData?.title || title || "Source Document"}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{documentData
{documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
@ -491,7 +506,8 @@ export function SourceDetailPanel({
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
{/* Document Metadata */}
{documentData.document_metadata &&
{"document_metadata" in documentData &&
documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}