diff --git a/surfsense_web/components/chat/ChatCitation.tsx b/surfsense_web/components/chat/ChatCitation.tsx index fe3c2b993..d8c681781 100644 --- a/surfsense_web/components/chat/ChatCitation.tsx +++ b/surfsense_web/components/chat/ChatCitation.tsx @@ -1,233 +1,30 @@ "use client"; -import { ChevronDown, ChevronUp, ExternalLink, FileText, Loader2 } from "lucide-react"; import type React from "react"; -import { useEffect, useRef, useState } from "react"; -import { MarkdownViewer } from "@/components/markdown-viewer"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useDocumentByChunk } from "@/hooks/use-document-by-chunk"; -import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { SheetTrigger } from "@/components/ui/sheet"; +import { SourceDetailSheet } from "./SourceDetailSheet"; export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => { const chunkId = Number(node?.id); const sourceType = node?.metadata?.source_type; const [isOpen, setIsOpen] = useState(false); - const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk(); - const chunksContainerRef = useRef(null); - const highlightedChunkRef = useRef(null); - const [summaryOpen, setSummaryOpen] = useState(false); - - // Check if this is a source type that should render directly from node - const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API"; - - const handleOpenChange = async (open: boolean) => { - setIsOpen(open); - if (open && chunkId && !isDirectRenderSource) { - await fetchDocumentByChunk(chunkId); - } else if (!open && !isDirectRenderSource) { - clearDocument(); - } - }; - - useEffect(() => { - // Scroll to highlighted chunk when document loads - if (document && highlightedChunkRef.current && chunksContainerRef.current) { - setTimeout(() => { - highlightedChunkRef.current?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }, 100); - } - }, [document]); - - const handleUrlClick = (e: React.MouseEvent, url: string) => { - e.preventDefault(); - e.stopPropagation(); - window.open(url, "_blank", "noopener,noreferrer"); - }; - - const formatDocumentType = (type: string) => { - return type - .split("_") - .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) - .join(" "); - }; return ( - + {index + 1} - - - - {getConnectorIcon(sourceType)} - {document?.title || node?.metadata?.title || node?.metadata?.group_name || "Source"} - - - {document - ? formatDocumentType(document.document_type) - : sourceType && formatDocumentType(sourceType)} - - - - {!isDirectRenderSource && loading && ( -
- -
- )} - - {!isDirectRenderSource && error && ( -
-

{error}

-
- )} - - {/* Direct render for TAVILY_API and LINEAR_API */} - {isDirectRenderSource && ( - -
- {/* External Link */} - {node?.url && ( -
- -
- )} - - {/* Source Information */} -
-

Source Information

-
- {node?.metadata?.title || "Untitled"} -
-
- {node?.text || "No content available"} -
-
-
-
- )} - - {/* API-fetched document content */} - {!isDirectRenderSource && document && ( - -
- {/* Document Metadata */} - {document.document_metadata && Object.keys(document.document_metadata).length > 0 && ( -
-

Document Information

-
- {Object.entries(document.document_metadata).map(([key, value]) => ( -
-
- {key.replace(/_/g, " ")}: -
-
{String(value)}
-
- ))} -
-
- )} - - {/* External Link */} - {node?.url && ( -
- -
- )} - - {/* Chunks */} -
-
- {/* Header row: header and button side by side */} -
-

Document Content

- {document.content && ( - - - Summary - {summaryOpen ? ( - - ) : ( - - )} - - - )} -
- {/* Expanded summary content: always full width, below the row */} - {document.content && ( - - -
- -
-
-
- )} -
- - {document.chunks.map((chunk, idx) => ( -
-
- - Chunk {idx + 1} of {document.chunks.length} - - {chunk.id === chunkId && ( - - Referenced Chunk - - )} -
-
- -
-
- ))} -
-
-
- )} -
-
+ ); }; diff --git a/surfsense_web/components/chat/ChatSources.tsx b/surfsense_web/components/chat/ChatSources.tsx index 625a81fa1..5f205d005 100644 --- a/surfsense_web/components/chat/ChatSources.tsx +++ b/surfsense_web/components/chat/ChatSources.tsx @@ -9,12 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { SourceDetailSheet } from "./SourceDetailSheet"; interface Source { id: string; title: string; description: string; url: string; + sourceType: string; } interface SourceGroup { @@ -48,6 +50,9 @@ function getSourceIcon(type: string) { function SourceCard({ source }: { source: Source }) { const hasUrl = source.url && source.url.trim() !== ""; + const chunkId = Number(source.id); + const sourceType = source.sourceType; + const [isOpen, setIsOpen] = useState(false); // Clean up the description for better display const cleanDescription = source.description @@ -55,31 +60,54 @@ function SourceCard({ source }: { source: Source }) { .replace(/\n+/g, " ") .trim(); + const handleUrlClick = (e: React.MouseEvent, url: string) => { + e.preventDefault(); + e.stopPropagation(); + window.open(url, "_blank", "noopener,noreferrer"); + }; + return ( - - -
- - {source.title} - - {hasUrl && ( - - )} -
-
- - - {cleanDescription} - - -
+ + + + +
+ + {source.title} + +
+ + #{chunkId} + + {hasUrl && ( + + )} +
+
+
+ + + {cleanDescription} + + +
+
+
); } @@ -126,6 +154,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) { title: node.metadata.title, description: node.text, url: node.url || "", + sourceType: sourceType, })), }); } diff --git a/surfsense_web/components/chat/SourceDetailSheet.tsx b/surfsense_web/components/chat/SourceDetailSheet.tsx new file mode 100644 index 000000000..4f7d129e4 --- /dev/null +++ b/surfsense_web/components/chat/SourceDetailSheet.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react"; +import type React from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { MarkdownViewer } from "@/components/markdown-viewer"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { useDocumentByChunk } from "@/hooks/use-document-by-chunk"; +import { cn } from "@/lib/utils"; + +interface SourceDetailSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + chunkId: number; + sourceType: string; + title: string; + description?: string; + url?: string; + children?: ReactNode; +} + +const formatDocumentType = (type: string) => { + return type + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +}; + +export function SourceDetailSheet({ + open, + onOpenChange, + chunkId, + sourceType, + title, + description, + url, + children, +}: SourceDetailSheetProps) { + const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk(); + const chunksContainerRef = useRef(null); + const highlightedChunkRef = useRef(null); + const [summaryOpen, setSummaryOpen] = useState(false); + + // Check if this is a source type that should render directly from node + const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API"; + + useEffect(() => { + if (open && chunkId && !isDirectRenderSource) { + fetchDocumentByChunk(chunkId); + } else if (!open && !isDirectRenderSource) { + clearDocument(); + } + }, [open, chunkId, isDirectRenderSource, fetchDocumentByChunk, clearDocument]); + + useEffect(() => { + // Scroll to highlighted chunk when document loads + if (document && highlightedChunkRef.current && chunksContainerRef.current) { + setTimeout(() => { + highlightedChunkRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + } + }, [document]); + + const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => { + e.preventDefault(); + e.stopPropagation(); + window.open(clickUrl, "_blank", "noopener,noreferrer"); + }; + + return ( + + {children} + + + + {getConnectorIcon(sourceType)} + {document?.title || title} + + + {document + ? formatDocumentType(document.document_type) + : sourceType && formatDocumentType(sourceType)} + + + + {!isDirectRenderSource && loading && ( +
+ +
+ )} + + {!isDirectRenderSource && error && ( +
+

{error}

+
+ )} + + {/* Direct render for TAVILY_API and LINKUP_API */} + {isDirectRenderSource && ( + +
+ {/* External Link */} + {url && ( +
+ +
+ )} + + {/* Source Information */} +
+

Source Information

+
+ {title || "Untitled"} +
+
+ {description || "No content available"} +
+
+
+
+ )} + + {/* API-fetched document content */} + {!isDirectRenderSource && document && ( + +
+ {/* Document Metadata */} + {document.document_metadata && Object.keys(document.document_metadata).length > 0 && ( +
+

Document Information

+
+ {Object.entries(document.document_metadata).map(([key, value]) => ( +
+
+ {key.replace(/_/g, " ")}: +
+
{String(value)}
+
+ ))} +
+
+ )} + + {/* External Link */} + {url && ( +
+ +
+ )} + + {/* Chunks */} +
+
+ {/* Header row: header and button side by side */} +
+

Document Content

+ {document.content && ( + + + Summary + {summaryOpen ? ( + + ) : ( + + )} + + + )} +
+ {/* Expanded summary content: always full width, below the row */} + {document.content && ( + + +
+ +
+
+
+ )} +
+ + {document.chunks.map((chunk, idx) => ( +
+
+ + Chunk {idx + 1} of {document.chunks.length} + + {chunk.id === chunkId && ( + + Referenced Chunk + + )} +
+
+ +
+
+ ))} +
+
+
+ )} +
+
+ ); +}