diff --git a/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx index dcdcc210d..4ff0c106f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx @@ -1,481 +1,525 @@ "use client"; -import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react'; -import { useChat } from '@ai-sdk/react'; -import { useParams } from 'next/navigation'; +import React, { + useRef, + useEffect, + useState, + useMemo, + useCallback, +} from "react"; +import { useChat } from "@ai-sdk/react"; +import { useParams } from "next/navigation"; import { - Loader2, - X, - Search, - ExternalLink, - ChevronLeft, - ChevronRight, - Check, - ArrowDown, - CircleUser, - Database, - SendHorizontal, - FileText, - Grid3x3, - FolderOpen, - Upload, - ChevronDown, - Filter, - Brain, - Zap -} from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + Loader2, + X, + Search, + ExternalLink, + ChevronLeft, + ChevronRight, + Check, + ArrowDown, + CircleUser, + Database, + SendHorizontal, + FileText, + Grid3x3, + FolderOpen, + Upload, + ChevronDown, + Filter, + Brain, + Zap, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogFooter + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { - ConnectorButton as ConnectorButtonComponent, - getConnectorIcon, - getFilteredSources as getFilteredSourcesUtil, - getPaginatedDialogSources as getPaginatedDialogSourcesUtil, - useScrollToBottom, - updateScrollIndicators as updateScrollIndicatorsUtil, - useScrollIndicators, - scrollTabsLeft as scrollTabsLeftUtil, - scrollTabsRight as scrollTabsRightUtil, - Source, - ResearchMode, - ResearchModeControl -} from '@/components/chat'; -import { MarkdownViewer } from '@/components/markdown-viewer'; -import { Logo } from '@/components/Logo'; -import { useSearchSourceConnectors } from '@/hooks'; -import { useDocuments } from '@/hooks/use-documents'; -import { useLLMConfigs, useLLMPreferences } from '@/hooks/use-llm-configs'; + ConnectorButton as ConnectorButtonComponent, + getConnectorIcon, + getFilteredSources as getFilteredSourcesUtil, + getPaginatedDialogSources as getPaginatedDialogSourcesUtil, + useScrollToBottom, + updateScrollIndicators as updateScrollIndicatorsUtil, + useScrollIndicators, + scrollTabsLeft as scrollTabsLeftUtil, + scrollTabsRight as scrollTabsRightUtil, + Source, + ResearchMode, + ResearchModeControl, +} from "@/components/chat"; +import { MarkdownViewer } from "@/components/markdown-viewer"; +import { Logo } from "@/components/Logo"; +import { useSearchSourceConnectors } from "@/hooks"; +import { useDocuments } from "@/hooks/use-documents"; +import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; interface SourceItem { - id: number; - title: string; - description: string; - url: string; - connectorType?: string; + id: number; + title: string; + description: string; + url: string; + connectorType?: string; } interface ConnectorSource { - id: number; - name: string; - type: string; - sources: SourceItem[]; + id: number; + name: string; + type: string; + sources: SourceItem[]; } -type DocumentType = "EXTENSION" | "CRAWLED_URL" | "SLACK_CONNECTOR" | "NOTION_CONNECTOR" | "FILE" | "YOUTUBE_VIDEO" | "GITHUB_CONNECTOR" | "LINEAR_CONNECTOR" | "DISCORD_CONNECTOR"; - +type DocumentType = + | "EXTENSION" + | "CRAWLED_URL" + | "SLACK_CONNECTOR" + | "NOTION_CONNECTOR" + | "FILE" + | "YOUTUBE_VIDEO" + | "GITHUB_CONNECTOR" + | "LINEAR_CONNECTOR" + | "DISCORD_CONNECTOR"; /** * Skeleton loader for document items */ const DocumentSkeleton = () => ( -
- -
- - - -
- -
+
+ +
+ + + +
+ +
); /** * Enhanced document type filter dropdown */ -const DocumentTypeFilter = ({ - value, - onChange, - counts -}: { - value: DocumentType | "ALL"; - onChange: (value: DocumentType | "ALL") => void; - counts: Record; +const DocumentTypeFilter = ({ + value, + onChange, + counts, +}: { + value: DocumentType | "ALL"; + onChange: (value: DocumentType | "ALL") => void; + counts: Record; }) => { - const getTypeLabel = (type: DocumentType | "ALL") => { - if (type === "ALL") return "All Types"; - return type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); - }; + const getTypeLabel = (type: DocumentType | "ALL") => { + if (type === "ALL") return "All Types"; + return type + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()); + }; - const getTypeIcon = (type: DocumentType | "ALL") => { - if (type === "ALL") return ; - return getConnectorIcon(type); - }; + const getTypeIcon = (type: DocumentType | "ALL") => { + if (type === "ALL") return ; + return getConnectorIcon(type); + }; - return ( - - - - - - Document Types - - {Object.entries(counts).map(([type, count]) => ( - onChange(type as DocumentType | "ALL")} - className="flex items-center justify-between" - > -
- {getTypeIcon(type as DocumentType | "ALL")} - {getTypeLabel(type as DocumentType | "ALL")} -
- - {count} - -
- ))} -
-
- ); + return ( + + + + + + Document Types + + {Object.entries(counts).map(([type, count]) => ( + onChange(type as DocumentType | "ALL")} + className="flex items-center justify-between" + > +
+ {getTypeIcon(type as DocumentType | "ALL")} + {getTypeLabel(type as DocumentType | "ALL")} +
+ + {count} + +
+ ))} +
+
+ ); }; /** * Button that displays selected connectors and opens connector selection dialog */ -const ConnectorButton = ({ selectedConnectors, onClick }: { selectedConnectors: string[], onClick: () => void }) => { - const { connectorSourceItems } = useSearchSourceConnectors(); - - return ( - - ); +const ConnectorButton = ({ + selectedConnectors, + onClick, +}: { + selectedConnectors: string[]; + onClick: () => void; +}) => { + const { connectorSourceItems } = useSearchSourceConnectors(); + + return ( + + ); }; /** * Button that displays selected documents count and opens document selection dialog */ -const DocumentSelectorButton = ({ - selectedDocuments, - onClick, - documentsCount -}: { - selectedDocuments: number[], - onClick: () => void, - documentsCount: number +const DocumentSelectorButton = ({ + selectedDocuments, + onClick, + documentsCount, +}: { + selectedDocuments: number[]; + onClick: () => void; + documentsCount: number; }) => { - return ( -
- - {selectedDocuments.length > 0 && ( - - {selectedDocuments.length > 99 ? '99+' : selectedDocuments.length} - - )} - {selectedDocuments.length === 0 && ( - - 0 - - )} -
- ); + return ( +
+ + {selectedDocuments.length > 0 && ( + + {selectedDocuments.length > 99 ? "99+" : selectedDocuments.length} + + )} + {selectedDocuments.length === 0 && ( + + 0 + + )} +
+ ); }; // Create a wrapper component for the sources dialog content -const SourcesDialogContent = ({ - connector, - sourceFilter, - expandedSources, - sourcesPage, - setSourcesPage, - setSourceFilter, - setExpandedSources, - isLoadingMore -}: { - connector: any; - sourceFilter: string; - expandedSources: boolean; - sourcesPage: number; - setSourcesPage: React.Dispatch>; - setSourceFilter: React.Dispatch>; - setExpandedSources: React.Dispatch>; - isLoadingMore: boolean; +const SourcesDialogContent = ({ + connector, + sourceFilter, + expandedSources, + sourcesPage, + setSourcesPage, + setSourceFilter, + setExpandedSources, + isLoadingMore, +}: { + connector: any; + sourceFilter: string; + expandedSources: boolean; + sourcesPage: number; + setSourcesPage: React.Dispatch>; + setSourceFilter: React.Dispatch>; + setExpandedSources: React.Dispatch>; + isLoadingMore: boolean; }) => { - // Safely access sources with fallbacks - const sources = connector?.sources || []; - - // Safe versions of utility functions - const getFilteredSourcesSafe = () => { - if (!sources.length) return []; - return getFilteredSourcesUtil(connector, sourceFilter); - }; - - const getPaginatedSourcesSafe = () => { - if (!sources.length) return []; - return getPaginatedDialogSourcesUtil( - connector, - sourceFilter, - expandedSources, - sourcesPage, - 5 // SOURCES_PER_PAGE - ); - }; - - const filteredSources = getFilteredSourcesSafe() || []; - const paginatedSources = getPaginatedSourcesSafe() || []; - - // Description text - const descriptionText = sourceFilter - ? `Found ${filteredSources.length} sources matching "${sourceFilter}"` - : `Viewing ${paginatedSources.length} of ${sources.length} sources`; - - if (paginatedSources.length === 0) { - return ( -
- -

No sources found matching "{sourceFilter}"

- -
- ); - } - - return ( - <> - - - {getConnectorIcon(connector.type)} - {connector.name} Sources - - - {descriptionText} - - + // Safely access sources with fallbacks + const sources = connector?.sources || []; -
- - { - setSourceFilter(e.target.value); - setSourcesPage(1); - setExpandedSources(false); - }} - /> - {sourceFilter && ( - - )} -
+ // Safe versions of utility functions + const getFilteredSourcesSafe = () => { + if (!sources.length) return []; + return getFilteredSourcesUtil(connector, sourceFilter); + }; -
- {paginatedSources.map((source: any, index: number) => ( - -
-
- {getConnectorIcon(connector.type)} -
-
-

{source.title}

-

{source.description}

-
- -
-
- ))} + const getPaginatedSourcesSafe = () => { + if (!sources.length) return []; + return getPaginatedDialogSourcesUtil( + connector, + sourceFilter, + expandedSources, + sourcesPage, + 5, // SOURCES_PER_PAGE + ); + }; - {!expandedSources && paginatedSources.length < filteredSources.length && ( - - )} + const filteredSources = getFilteredSourcesSafe() || []; + const paginatedSources = getPaginatedSourcesSafe() || []; - {expandedSources && filteredSources.length > 10 && ( -
- Showing all {filteredSources.length} sources -
- )} -
- - ); + // Description text + const descriptionText = sourceFilter + ? `Found ${filteredSources.length} sources matching "${sourceFilter}"` + : `Viewing ${paginatedSources.length} of ${sources.length} sources`; + + if (paginatedSources.length === 0) { + return ( +
+ +

No sources found matching "{sourceFilter}"

+ +
+ ); + } + + return ( + <> + + + {getConnectorIcon(connector.type)} + {connector.name} Sources + + + {descriptionText} + + + +
+ + { + setSourceFilter(e.target.value); + setSourcesPage(1); + setExpandedSources(false); + }} + /> + {sourceFilter && ( + + )} +
+ +
+ {paginatedSources.map((source: any, index: number) => ( + +
+
+ {getConnectorIcon(connector.type)} +
+
+

{source.title}

+

+ {source.description} +

+
+ +
+
+ ))} + + {!expandedSources && + paginatedSources.length < filteredSources.length && ( + + )} + + {expandedSources && filteredSources.length > 10 && ( +
+ Showing all {filteredSources.length} sources +
+ )} +
+ + ); }; const ChatPage = () => { - const [token, setToken] = React.useState(null); - const [dialogOpenId, setDialogOpenId] = useState(null); - const [sourcesPage, setSourcesPage] = useState(1); - const [expandedSources, setExpandedSources] = useState(false); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); - const [sourceFilter, setSourceFilter] = useState(""); - const tabsListRef = useRef(null); - const [terminalExpanded, setTerminalExpanded] = useState(false); - const [selectedConnectors, setSelectedConnectors] = useState([]); - const [searchMode, setSearchMode] = useState<'DOCUMENTS' | 'CHUNKS'>('DOCUMENTS'); - const [researchMode, setResearchMode] = useState("QNA"); - const [currentTime, setCurrentTime] = useState(''); - const [currentDate, setCurrentDate] = useState(''); - const terminalMessagesRef = useRef(null); - const { connectorSourceItems, isLoading: isLoadingConnectors } = useSearchSourceConnectors(); - const { llmConfigs } = useLLMConfigs(); - const { preferences, updatePreferences } = useLLMPreferences(); + const [token, setToken] = React.useState(null); + const [dialogOpenId, setDialogOpenId] = useState(null); + const [sourcesPage, setSourcesPage] = useState(1); + const [expandedSources, setExpandedSources] = useState(false); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + const [sourceFilter, setSourceFilter] = useState(""); + const tabsListRef = useRef(null); + const [terminalExpanded, setTerminalExpanded] = useState(false); + const [selectedConnectors, setSelectedConnectors] = useState([]); + const [searchMode, setSearchMode] = useState<"DOCUMENTS" | "CHUNKS">( + "DOCUMENTS", + ); + const [researchMode, setResearchMode] = useState("QNA"); + const [currentTime, setCurrentTime] = useState(""); + const [currentDate, setCurrentDate] = useState(""); + const terminalMessagesRef = useRef(null); + const { connectorSourceItems, isLoading: isLoadingConnectors } = + useSearchSourceConnectors(); + const { llmConfigs } = useLLMConfigs(); + const { preferences, updatePreferences } = useLLMPreferences(); - const INITIAL_SOURCES_DISPLAY = 3; + const INITIAL_SOURCES_DISPLAY = 3; - const { search_space_id, chat_id } = useParams(); - - // Document selection state - const [selectedDocuments, setSelectedDocuments] = useState([]); - const [documentFilter, setDocumentFilter] = useState(""); - const [debouncedDocumentFilter, setDebouncedDocumentFilter] = useState(""); - const [documentTypeFilter, setDocumentTypeFilter] = useState("ALL"); - const [documentsPage, setDocumentsPage] = useState(1); - const [documentsPerPage] = useState(10); - const { documents, loading: isLoadingDocuments, error: documentsError } = useDocuments(Number(search_space_id)); + const { search_space_id, chat_id } = useParams(); - // Debounced search effect (proper implementation) - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedDocumentFilter(documentFilter); - setDocumentsPage(1); // Reset page when search changes - }, 300); + // Document selection state + const [selectedDocuments, setSelectedDocuments] = useState([]); + const [documentFilter, setDocumentFilter] = useState(""); + const [debouncedDocumentFilter, setDebouncedDocumentFilter] = useState(""); + const [documentTypeFilter, setDocumentTypeFilter] = useState< + DocumentType | "ALL" + >("ALL"); + const [documentsPage, setDocumentsPage] = useState(1); + const [documentsPerPage] = useState(10); + const { + documents, + loading: isLoadingDocuments, + error: documentsError, + } = useDocuments(Number(search_space_id)); - return () => { - clearTimeout(handler); - }; - }, [documentFilter]); + // Debounced search effect (proper implementation) + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedDocumentFilter(documentFilter); + setDocumentsPage(1); // Reset page when search changes + }, 300); - // Memoized filtered and paginated documents - const filteredDocuments = useMemo(() => { - if (!documents) return []; - - return documents.filter(doc => { - const matchesSearch = doc.title.toLowerCase().includes(debouncedDocumentFilter.toLowerCase()) || - doc.content.toLowerCase().includes(debouncedDocumentFilter.toLowerCase()); - const matchesType = documentTypeFilter === "ALL" || doc.document_type === documentTypeFilter; - return matchesSearch && matchesType; - }); - }, [documents, debouncedDocumentFilter, documentTypeFilter]); + return () => { + clearTimeout(handler); + }; + }, [documentFilter]); - const paginatedDocuments = useMemo(() => { - const startIndex = (documentsPage - 1) * documentsPerPage; - return filteredDocuments.slice(startIndex, startIndex + documentsPerPage); - }, [filteredDocuments, documentsPage, documentsPerPage]); + // Memoized filtered and paginated documents + const filteredDocuments = useMemo(() => { + if (!documents) return []; - const totalPages = Math.ceil(filteredDocuments.length / documentsPerPage); + return documents.filter((doc) => { + const matchesSearch = + doc.title + .toLowerCase() + .includes(debouncedDocumentFilter.toLowerCase()) || + doc.content + .toLowerCase() + .includes(debouncedDocumentFilter.toLowerCase()); + const matchesType = + documentTypeFilter === "ALL" || + doc.document_type === documentTypeFilter; + return matchesSearch && matchesType; + }); + }, [documents, debouncedDocumentFilter, documentTypeFilter]); - // Document type counts for filter dropdown - const documentTypeCounts = useMemo(() => { - if (!documents) return {}; - - const counts: Record = { ALL: documents.length }; - documents.forEach(doc => { - counts[doc.document_type] = (counts[doc.document_type] || 0) + 1; - }); - return counts; - }, [documents]); + const paginatedDocuments = useMemo(() => { + const startIndex = (documentsPage - 1) * documentsPerPage; + return filteredDocuments.slice(startIndex, startIndex + documentsPerPage); + }, [filteredDocuments, documentsPage, documentsPerPage]); - // Callback to handle document selection - const handleDocumentToggle = useCallback((documentId: number) => { - setSelectedDocuments(prev => - prev.includes(documentId) - ? prev.filter(id => id !== documentId) - : [...prev, documentId] - ); - }, []); + const totalPages = Math.ceil(filteredDocuments.length / documentsPerPage); - // Function to scroll terminal to bottom - const scrollTerminalToBottom = () => { - if (terminalMessagesRef.current) { - terminalMessagesRef.current.scrollTop = terminalMessagesRef.current.scrollHeight; - } - }; + // Document type counts for filter dropdown + const documentTypeCounts = useMemo(() => { + if (!documents) return {}; - // Get token from localStorage on client side only - React.useEffect(() => { - setToken(localStorage.getItem('surfsense_bearer_token')); - }, []); + const counts: Record = { ALL: documents.length }; + documents.forEach((doc) => { + counts[doc.document_type] = (counts[doc.document_type] || 0) + 1; + }); + return counts; + }, [documents]); - // Set the current time only on the client side after initial render - useEffect(() => { - setCurrentDate(new Date().toISOString().split('T')[0]); - setCurrentTime(new Date().toTimeString().split(' ')[0]); - }, []); + // Callback to handle document selection + const handleDocumentToggle = useCallback((documentId: number) => { + setSelectedDocuments((prev) => + prev.includes(documentId) + ? prev.filter((id) => id !== documentId) + : [...prev, documentId], + ); + }, []); + // Function to scroll terminal to bottom + const scrollTerminalToBottom = () => { + if (terminalMessagesRef.current) { + terminalMessagesRef.current.scrollTop = + terminalMessagesRef.current.scrollHeight; + } + }; + // Get token from localStorage on client side only + React.useEffect(() => { + setToken(localStorage.getItem("surfsense_bearer_token")); + }, []); - // Add this CSS to remove input shadow and improve the UI - useEffect(() => { - if (typeof document !== 'undefined') { - const style = document.createElement('style'); - style.innerHTML = ` + // Set the current time only on the client side after initial render + useEffect(() => { + setCurrentDate(new Date().toISOString().split("T")[0]); + setCurrentTime(new Date().toTimeString().split(" ")[0]); + }, []); + + // Add this CSS to remove input shadow and improve the UI + useEffect(() => { + if (typeof document !== "undefined") { + const style = document.createElement("style"); + style.innerHTML = ` .no-shadow-input { box-shadow: none !important; } @@ -546,1122 +590,1362 @@ const ChatPage = () => { overflow: hidden; } `; - document.head.appendChild(style); - - return () => { - document.head.removeChild(style); - }; - } - }, []); - - const { messages, input, handleInputChange, handleSubmit: handleChatSubmit, status, setMessages } = useChat({ - api: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chat`, - streamProtocol: 'data', - headers: { - ...(token && { Authorization: `Bearer ${token}` }), - }, - body: { - data: { - search_space_id: search_space_id, - selected_connectors: selectedConnectors, - research_mode: researchMode, - search_mode: searchMode, - document_ids_to_add_in_context: selectedDocuments - } - }, - onError: (error) => { - console.error("Chat error:", error); - // You can add additional error handling here if needed - } - }); - - // Fetch chat details when component mounts - useEffect(() => { - const fetchChatDetails = async () => { - try { - if (!token) return; // Wait for token to be set - - // console.log('Fetching chat details for chat ID:', chat_id); - - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chat_id)}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch chat details: ${response.statusText}`); - } - - const chatData = await response.json(); - // console.log('Chat details fetched:', chatData); - - // Set research mode from chat data - if (chatData.type) { - setResearchMode(chatData.type as ResearchMode); - } - - // Set connectors from chat data - if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) { - setSelectedConnectors(chatData.initial_connectors); - } - - // Set messages from chat data - if (chatData.messages && Array.isArray(chatData.messages)) { - setMessages(chatData.messages); - } - } catch (err) { - console.error('Error fetching chat details:', err); - } - }; - - if (token) { - fetchChatDetails(); - } - }, [token, chat_id, setMessages]); - - // Update chat when a conversation exchange is complete - useEffect(() => { - const updateChat = async () => { - try { - // Only update when: - // 1. Status is ready (not loading) - // 2. We have messages - // 3. Last message is from assistant (completed response) - if ( - status === 'ready' && - messages.length > 0 && - messages[messages.length - 1]?.role === 'assistant' - ) { - const token = localStorage.getItem('surfsense_bearer_token'); - if (!token) return; - - // Find the first user message to use as title - const userMessages = messages.filter(msg => msg.role === 'user'); - if (userMessages.length === 0) return; - - // Use the first user message as the title - const title = userMessages[0].content; - - - // console.log('Updating chat with title:', title); - - // Update the chat - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chat_id)}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - type: researchMode, - title: title, - initial_connectors: selectedConnectors, - messages: messages, - search_space_id: Number(search_space_id) - }) - }); - - if (!response.ok) { - throw new Error(`Failed to update chat: ${response.statusText}`); - } - - // console.log('Chat updated successfully'); - } - } catch (err) { - console.error('Error updating chat:', err); - } - }; - - updateChat(); - }, [messages, status, chat_id, researchMode, selectedConnectors, search_space_id]); - - // Check and scroll terminal when terminal info is available - useEffect(() => { - // Modified to trigger during streaming as well (removed status check) - if (messages.length === 0) return; - - // Find the latest assistant message - const assistantMessages = messages.filter(msg => msg.role === 'assistant'); - if (assistantMessages.length === 0) return; - - const latestAssistantMessage = assistantMessages[assistantMessages.length - 1]; - if (!latestAssistantMessage?.annotations) return; - - // Check for terminal info annotations - const annotations = latestAssistantMessage.annotations as any[]; - const terminalInfoAnnotations = annotations.filter(a => a.type === 'TERMINAL_INFO'); - - if (terminalInfoAnnotations.length > 0) { - // Always scroll to bottom when terminal info is updated, even during streaming - scrollTerminalToBottom(); - } - }, [messages]); // Removed status from dependencies to ensure it triggers during streaming - - // Pure function to get connector sources for a specific message - const getMessageConnectorSources = (message: any): any[] => { - if (!message || message.role !== 'assistant' || !message.annotations) return []; - - // Find all SOURCES annotations - const annotations = message.annotations as any[]; - const sourcesAnnotations = annotations.filter(a => a.type === 'SOURCES'); - - // Get the latest SOURCES annotation - if (sourcesAnnotations.length === 0) return []; - const latestSourcesAnnotation = sourcesAnnotations[sourcesAnnotations.length - 1]; - - if (!latestSourcesAnnotation.content) return []; - - return latestSourcesAnnotation.content; - }; - - // Custom handleSubmit function to include selected connectors and answer type - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - if (!input.trim() || status !== 'ready') return; - - // Validation: require at least one connector OR at least one document - // Note: Fast LLM selection updates user preferences automatically - // if (selectedConnectors.length === 0 && selectedDocuments.length === 0) { - // alert("Please select at least one connector or document"); - // return; - // } - - // Call the original handleSubmit from useChat - handleChatSubmit(e); - }; - - // Reference to the messages container for auto-scrolling - const messagesEndRef = useRef(null); - - // Function to scroll to bottom - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - // Scroll to bottom when messages change - useEffect(() => { - scrollToBottom(); - }, [messages]); - - // Reset sources page when new messages arrive - useEffect(() => { - // Reset pagination when we get new messages - setSourcesPage(1); - setExpandedSources(false); - }, [messages]); - - // Scroll terminal to bottom when expanded - useEffect(() => { - if (terminalExpanded) { - setTimeout(scrollTerminalToBottom, 300); // Wait for transition to complete - } - }, [terminalExpanded]); - - // Function to check scroll position and update indicators - const updateScrollIndicators = () => { - updateScrollIndicatorsUtil(tabsListRef as React.RefObject, setCanScrollLeft, setCanScrollRight); - }; - - // Initialize scroll indicators - const updateIndicators = useScrollIndicators( - tabsListRef as React.RefObject, - setCanScrollLeft, - setCanScrollRight - ); - - // Function to scroll tabs list left - const scrollTabsLeft = () => { - scrollTabsLeftUtil(tabsListRef as React.RefObject, updateIndicators); - }; - - // Function to scroll tabs list right - const scrollTabsRight = () => { - scrollTabsRightUtil(tabsListRef as React.RefObject, updateIndicators); - }; - - // Use the scroll to bottom hook - useScrollToBottom(messagesEndRef as React.RefObject, [messages]); - - // Function to get a citation source by ID - const getCitationSource = React.useCallback((citationId: number, messageIndex?: number): Source | null => { - if (!messages || messages.length === 0) return null; - - // If no specific message index is provided, use the latest assistant message - if (messageIndex === undefined) { - // Find the latest assistant message - const assistantMessages = messages.filter(msg => msg.role === 'assistant'); - if (assistantMessages.length === 0) return null; - - const latestAssistantMessage = assistantMessages[assistantMessages.length - 1]; - - // Use our helper function to get sources - const sources = getMessageConnectorSources(latestAssistantMessage); - if (sources.length === 0) return null; - - // Flatten all sources from all connectors - const allSources: Source[] = []; - sources.forEach((connector: ConnectorSource) => { - if (connector.sources && Array.isArray(connector.sources)) { - connector.sources.forEach((source: SourceItem) => { - allSources.push({ - id: source.id, - title: source.title, - description: source.description, - url: source.url, - connectorType: connector.type - }); - }); - } - }); - - // Find the source with the matching ID - const foundSource = allSources.find(source => source.id === citationId); - - return foundSource || null; - } else { - // Use the specific message by index - const message = messages[messageIndex]; - - // Use our helper function to get sources - const sources = getMessageConnectorSources(message); - if (sources.length === 0) return null; - - // Flatten all sources from all connectors - const allSources: Source[] = []; - sources.forEach((connector: ConnectorSource) => { - if (connector.sources && Array.isArray(connector.sources)) { - connector.sources.forEach((source: SourceItem) => { - allSources.push({ - id: source.id, - title: source.title, - description: source.description, - url: source.url, - connectorType: connector.type - }); - }); - } - }); - - // Find the source with the matching ID - const foundSource = allSources.find(source => source.id === citationId); - - return foundSource || null; - } - }, [messages]); - - // Pure function for rendering terminal content - no hooks allowed here - const renderTerminalContent = (message: any) => { - if (!message.annotations) return null; - - // Get all TERMINAL_INFO annotations - const terminalInfoAnnotations = (message.annotations as any[]) - .filter(a => a.type === 'TERMINAL_INFO'); - - // Get the latest TERMINAL_INFO annotation - const latestTerminalInfo = terminalInfoAnnotations.length > 0 - ? terminalInfoAnnotations[terminalInfoAnnotations.length - 1] - : null; - - // Render the content of the latest TERMINAL_INFO annotation - return latestTerminalInfo?.content.map((item: any, idx: number) => ( -
- [{String(idx).padStart(2, '0')}:{String(Math.floor(idx * 2)).padStart(2, '0')}] - {'>'} - {item.text} -
- )); - }; - - return ( - <> -
- {messages.length === 0 && ( -

- -
- Surf{""} -
-
- Sense -
-
-
-

- )} - {messages?.map((message, index) => { - if (message.role === 'user') { - return ( -
- -
- - - getCitationSource(id, index)} - className="text-sm" - /> - - -
-
- ); - } - - if (message.role === 'assistant') { - return ( -
- - - Answer - - - {/* Status Messages Section */} - -
-
-
-
setTerminalExpanded(false)}>
-
-
setTerminalExpanded(true)}>
-
- surfsense-research-terminal -
-
- -
-
Last login: {currentDate} {currentTime}
-
- researcher@surfsense - : - ~/research - $ - surfsense-researcher -
- - {renderTerminalContent(message)} - -
- [00:13] - researcher@surfsense - : - ~/research - $ -
-
- - {/* Terminal scroll button */} -
- -
-
-
- - {/* Sources Section with Connector Tabs */} -
-
- - Sources -
- - {(() => { - // Get sources for this specific message - const messageConnectorSources = getMessageConnectorSources(message); - - if (messageConnectorSources.length === 0) { - return ( -
- -
- ); - } - - // Use these message-specific sources for the Tabs component - return ( - 0 ? messageConnectorSources[0].type : undefined} - className="w-full" - > -
-
- - -
-
- - {messageConnectorSources.map((connector) => ( - - {getConnectorIcon(connector.type)} - {connector.name.split(' ')[0]} - - {connector.sources?.length || 0} - - - ))} - -
-
- - -
-
- - {messageConnectorSources.map(connector => ( - -
- {connector.sources?.slice(0, INITIAL_SOURCES_DISPLAY)?.map((source: any, index: number) => ( - -
-
- {getConnectorIcon(connector.type)} -
-
-

{source.title}

-

{source.description}

-
- -
-
- ))} - - {connector.sources?.length > INITIAL_SOURCES_DISPLAY && ( - setDialogOpenId(open ? connector.id : null)}> - - - - - - - - )} -
-
- ))} -
- ); - })()} -
- - {/* Answer Section */} -
- { -
- {message.annotations && (() => { - // Get all ANSWER annotations - const answerAnnotations = (message.annotations as any[]) - .filter(a => a.type === 'ANSWER'); - - // Get the latest ANSWER annotation - const latestAnswer = answerAnnotations.length > 0 - ? answerAnnotations[answerAnnotations.length - 1] - : null; - - // If we have a latest ANSWER annotation with content, render it - if (latestAnswer?.content && latestAnswer.content.length > 0) { - return ( - getCitationSource(id, index)} - /> - ); - } - - // Fallback to the message content if no ANSWER annotation is available - return getCitationSource(id, index)} - />; - })()} -
- } -
- {/* Scroll to bottom button */} -
- -
-
-
-
- ); - } - - return null; - })} - - {/* New Chat Input Form */} -
-
- - {/* Send button */} - -
-
-
- {/* Enhanced Document Selection Dialog */} - - - { }} - documentsCount={documents?.length || 0} - /> - - - - -
- - Select Documents - - {selectedDocuments.length} selected - -
- -
- - Choose documents to include in your research context. Use filters and search to find specific documents. - -
- - {/* Enhanced Search and Filter Controls */} -
-
- {/* Search Input */} -
- - setDocumentFilter(e.target.value)} - /> - {documentFilter && ( - - )} -
- - {/* Document Type Filter */} - { - setDocumentTypeFilter(newType); - setDocumentsPage(1); // Reset to page 1 when filter changes - }} - counts={documentTypeCounts} - /> -
- - {/* Results Summary */} -
- - {isLoadingDocuments ? ( - "Loading documents..." - ) : ( - `Showing ${paginatedDocuments.length} of ${filteredDocuments.length} documents` - )} - - {filteredDocuments.length > 0 && ( - - Page {documentsPage} of {totalPages} - - )} -
-
- - {/* Document List with Proper Scrolling */} -
-
- {isLoadingDocuments ? ( - // Enhanced skeleton loading - Array.from({ length: 6 }, (_, i) => ( - - )) - ) : documentsError ? ( -
-
- -
-

Error loading documents

-

Please try refreshing the page

-
- ) : filteredDocuments.length === 0 ? ( -
-
- -
-

No documents found

-

- {documentFilter || documentTypeFilter !== "ALL" - ? "Try adjusting your search or filters" - : "Upload documents to get started"} -

- {(!documentFilter && documentTypeFilter === "ALL") && ( - - )} -
- ) : ( - // Enhanced document list - paginatedDocuments.map((document) => { - const isSelected = selectedDocuments.includes(document.id); - const typeLabel = document.document_type.replace(/_/g, ' ').toLowerCase(); - - return ( -
handleDocumentToggle(document.id)} - > -
-
- {getConnectorIcon(document.document_type)} -
-
-
-
-

- {document.title} -

- {isSelected && ( -
-
- -
-
- )} -
-
- - {typeLabel} - - - {new Date(document.created_at).toLocaleDateString()} - -
-

- {document.content.substring(0, 200)}... -

-
-
- ); - }) - )} -
-
- - {/* Enhanced Pagination Controls */} - {totalPages > 1 && ( -
-
- -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const page = documentsPage <= 3 ? i + 1 : documentsPage - 2 + i; - if (page > totalPages) return null; - return ( - - ); - })} - {totalPages > 5 && documentsPage < totalPages - 2 && ( - <> - ... - - - )} -
- -
-
- )} - - {/* Enhanced Footer */} - -
- - {selectedDocuments.length} of {filteredDocuments.length} document{selectedDocuments.length !== 1 ? 's' : ''} selected - -
-
- - - -
-
-
-
- - {/* Connector Selection Dialog */} - - - { }} - /> - - - - Select Connectors - - Choose which data sources to include in your research - - - - {/* Connector selection grid */} -
- {isLoadingConnectors ? ( -
- -
- ) : ( - connectorSourceItems.map((connector) => { - const isSelected = selectedConnectors.includes(connector.type); - - return ( -
{ - setSelectedConnectors( - isSelected - ? selectedConnectors.filter((type) => type !== connector.type) - : [...selectedConnectors, connector.type] - ); - }} - role="checkbox" - aria-checked={isSelected} - tabIndex={0} - > -
- {getConnectorIcon(connector.type)} -
- {connector.name} - {isSelected && } -
- ); - }) - )} -
- - -
- - -
-
-
-
- - {/* Search Mode Control */} -
- - -
- - {/* Research Mode Control */} -
- -
- - {/* Fast LLM Selector */} -
- -
-
-
-
- - {/* Reference for auto-scrolling */} -
-
- - ); + document.head.appendChild(style); + + return () => { + document.head.removeChild(style); + }; + } + }, []); + + const { + messages, + input, + handleInputChange, + handleSubmit: handleChatSubmit, + status, + setMessages, + } = useChat({ + api: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chat`, + streamProtocol: "data", + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: { + data: { + search_space_id: search_space_id, + selected_connectors: selectedConnectors, + research_mode: researchMode, + search_mode: searchMode, + document_ids_to_add_in_context: selectedDocuments, + }, + }, + onError: (error) => { + console.error("Chat error:", error); + // You can add additional error handling here if needed + }, + }); + + // Fetch chat details when component mounts + useEffect(() => { + const fetchChatDetails = async () => { + try { + if (!token) return; // Wait for token to be set + + // console.log('Fetching chat details for chat ID:', chat_id); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chat_id)}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch chat details: ${response.statusText}`, + ); + } + + const chatData = await response.json(); + // console.log('Chat details fetched:', chatData); + + // Set research mode from chat data + if (chatData.type) { + setResearchMode(chatData.type as ResearchMode); + } + + // Set connectors from chat data + if ( + chatData.initial_connectors && + Array.isArray(chatData.initial_connectors) + ) { + setSelectedConnectors(chatData.initial_connectors); + } + + // Set messages from chat data + if (chatData.messages && Array.isArray(chatData.messages)) { + setMessages(chatData.messages); + } + } catch (err) { + console.error("Error fetching chat details:", err); + } + }; + + if (token) { + fetchChatDetails(); + } + }, [token, chat_id, setMessages]); + + // Update chat when a conversation exchange is complete + useEffect(() => { + const updateChat = async () => { + try { + // Only update when: + // 1. Status is ready (not loading) + // 2. We have messages + // 3. Last message is from assistant (completed response) + if ( + status === "ready" && + messages.length > 0 && + messages[messages.length - 1]?.role === "assistant" + ) { + const token = localStorage.getItem("surfsense_bearer_token"); + if (!token) return; + + // Find the first user message to use as title + const userMessages = messages.filter((msg) => msg.role === "user"); + if (userMessages.length === 0) return; + + // Use the first user message as the title + const title = userMessages[0].content; + + // console.log('Updating chat with title:', title); + + // Update the chat + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chat_id)}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + type: researchMode, + title: title, + initial_connectors: selectedConnectors, + messages: messages, + search_space_id: Number(search_space_id), + }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to update chat: ${response.statusText}`); + } + + // console.log('Chat updated successfully'); + } + } catch (err) { + console.error("Error updating chat:", err); + } + }; + + updateChat(); + }, [ + messages, + status, + chat_id, + researchMode, + selectedConnectors, + search_space_id, + ]); + + // Check and scroll terminal when terminal info is available + useEffect(() => { + // Modified to trigger during streaming as well (removed status check) + if (messages.length === 0) return; + + // Find the latest assistant message + const assistantMessages = messages.filter( + (msg) => msg.role === "assistant", + ); + if (assistantMessages.length === 0) return; + + const latestAssistantMessage = + assistantMessages[assistantMessages.length - 1]; + if (!latestAssistantMessage?.annotations) return; + + // Check for terminal info annotations + const annotations = latestAssistantMessage.annotations as any[]; + const terminalInfoAnnotations = annotations.filter( + (a) => a.type === "TERMINAL_INFO", + ); + + if (terminalInfoAnnotations.length > 0) { + // Always scroll to bottom when terminal info is updated, even during streaming + scrollTerminalToBottom(); + } + }, [messages]); // Removed status from dependencies to ensure it triggers during streaming + + // Pure function to get connector sources for a specific message + const getMessageConnectorSources = (message: any): any[] => { + if (!message || message.role !== "assistant" || !message.annotations) + return []; + + // Find all SOURCES annotations + const annotations = message.annotations as any[]; + const sourcesAnnotations = annotations.filter((a) => a.type === "SOURCES"); + + // Get the latest SOURCES annotation + if (sourcesAnnotations.length === 0) return []; + const latestSourcesAnnotation = + sourcesAnnotations[sourcesAnnotations.length - 1]; + + if (!latestSourcesAnnotation.content) return []; + + return latestSourcesAnnotation.content; + }; + + // Custom handleSubmit function to include selected connectors and answer type + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!input.trim() || status !== "ready") return; + + // Validation: require at least one connector OR at least one document + // Note: Fast LLM selection updates user preferences automatically + // if (selectedConnectors.length === 0 && selectedDocuments.length === 0) { + // alert("Please select at least one connector or document"); + // return; + // } + + // Call the original handleSubmit from useChat + handleChatSubmit(e); + }; + + // Reference to the messages container for auto-scrolling + const messagesEndRef = useRef(null); + + // Function to scroll to bottom + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + // Scroll to bottom when messages change + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // Reset sources page when new messages arrive + useEffect(() => { + // Reset pagination when we get new messages + setSourcesPage(1); + setExpandedSources(false); + }, [messages]); + + // Scroll terminal to bottom when expanded + useEffect(() => { + if (terminalExpanded) { + setTimeout(scrollTerminalToBottom, 300); // Wait for transition to complete + } + }, [terminalExpanded]); + + // Function to check scroll position and update indicators + const updateScrollIndicators = () => { + updateScrollIndicatorsUtil( + tabsListRef as React.RefObject, + setCanScrollLeft, + setCanScrollRight, + ); + }; + + // Initialize scroll indicators + const updateIndicators = useScrollIndicators( + tabsListRef as React.RefObject, + setCanScrollLeft, + setCanScrollRight, + ); + + // Function to scroll tabs list left + const scrollTabsLeft = () => { + scrollTabsLeftUtil( + tabsListRef as React.RefObject, + updateIndicators, + ); + }; + + // Function to scroll tabs list right + const scrollTabsRight = () => { + scrollTabsRightUtil( + tabsListRef as React.RefObject, + updateIndicators, + ); + }; + + // Use the scroll to bottom hook + useScrollToBottom(messagesEndRef as React.RefObject, [ + messages, + ]); + + // Function to get a citation source by ID + const getCitationSource = React.useCallback( + (citationId: number, messageIndex?: number): Source | null => { + if (!messages || messages.length === 0) return null; + + // If no specific message index is provided, use the latest assistant message + if (messageIndex === undefined) { + // Find the latest assistant message + const assistantMessages = messages.filter( + (msg) => msg.role === "assistant", + ); + if (assistantMessages.length === 0) return null; + + const latestAssistantMessage = + assistantMessages[assistantMessages.length - 1]; + + // Use our helper function to get sources + const sources = getMessageConnectorSources(latestAssistantMessage); + if (sources.length === 0) return null; + + // Flatten all sources from all connectors + const allSources: Source[] = []; + sources.forEach((connector: ConnectorSource) => { + if (connector.sources && Array.isArray(connector.sources)) { + connector.sources.forEach((source: SourceItem) => { + allSources.push({ + id: source.id, + title: source.title, + description: source.description, + url: source.url, + connectorType: connector.type, + }); + }); + } + }); + + // Find the source with the matching ID + const foundSource = allSources.find( + (source) => source.id === citationId, + ); + + return foundSource || null; + } else { + // Use the specific message by index + const message = messages[messageIndex]; + + // Use our helper function to get sources + const sources = getMessageConnectorSources(message); + if (sources.length === 0) return null; + + // Flatten all sources from all connectors + const allSources: Source[] = []; + sources.forEach((connector: ConnectorSource) => { + if (connector.sources && Array.isArray(connector.sources)) { + connector.sources.forEach((source: SourceItem) => { + allSources.push({ + id: source.id, + title: source.title, + description: source.description, + url: source.url, + connectorType: connector.type, + }); + }); + } + }); + + // Find the source with the matching ID + const foundSource = allSources.find( + (source) => source.id === citationId, + ); + + return foundSource || null; + } + }, + [messages], + ); + + // Pure function for rendering terminal content - no hooks allowed here + const renderTerminalContent = (message: any) => { + if (!message.annotations) return null; + + // Get all TERMINAL_INFO annotations + const terminalInfoAnnotations = (message.annotations as any[]).filter( + (a) => a.type === "TERMINAL_INFO", + ); + + // Get the latest TERMINAL_INFO annotation + const latestTerminalInfo = + terminalInfoAnnotations.length > 0 + ? terminalInfoAnnotations[terminalInfoAnnotations.length - 1] + : null; + + // Render the content of the latest TERMINAL_INFO annotation + return latestTerminalInfo?.content.map((item: any, idx: number) => ( +
+ + [{String(idx).padStart(2, "0")}: + {String(Math.floor(idx * 2)).padStart(2, "0")}] + + {">"} + + {item.text} + +
+ )); + }; + + return ( + <> +
+ {messages.length === 0 && ( +

+ +
+ Surf{""} +
+
+ Sense +
+
+
+

+ )} + {messages?.map((message, index) => { + if (message.role === "user") { + return ( +
+ +
+ + + getCitationSource(id, index)} + className="text-sm" + /> + + +
+
+ ); + } + + if (message.role === "assistant") { + return ( +
+ + + + Answer + + + + {/* Status Messages Section */} + +
+
+
+
setTerminalExpanded(false)} + >
+
+
setTerminalExpanded(true)} + >
+
+ + surfsense-research-terminal + +
+
+ +
+
+ Last login: {currentDate} {currentTime} +
+
+ + researcher@surfsense + + : + ~/research + $ + surfsense-researcher +
+ + {renderTerminalContent(message)} + +
+ + [00:13] + + + researcher@surfsense + + : + ~/research + $ +
+
+ + {/* Terminal scroll button */} +
+ +
+
+
+ + {/* Sources Section with Connector Tabs */} +
+
+ + Sources +
+ + {(() => { + // Get sources for this specific message + const messageConnectorSources = + getMessageConnectorSources(message); + + if (messageConnectorSources.length === 0) { + return ( +
+ +
+ ); + } + + // Use these message-specific sources for the Tabs component + return ( + 0 + ? messageConnectorSources[0].type + : undefined + } + className="w-full" + > +
+
+ + +
+
+ + {messageConnectorSources.map( + (connector) => ( + + {getConnectorIcon(connector.type)} + + {connector.name.split(" ")[0]} + + + {connector.sources?.length || 0} + + + ), + )} + +
+
+ + +
+
+ + {messageConnectorSources.map((connector) => ( + +
+ {connector.sources + ?.slice(0, INITIAL_SOURCES_DISPLAY) + ?.map((source: any, index: number) => ( + +
+
+ {getConnectorIcon(connector.type)} +
+
+

+ {source.title} +

+

+ {source.description} +

+
+ +
+
+ ))} + + {connector.sources?.length > + INITIAL_SOURCES_DISPLAY && ( + + setDialogOpenId( + open ? connector.id : null, + ) + } + > + + + + + + + + )} +
+
+ ))} +
+ ); + })()} +
+ + {/* Answer Section */} +
+ { +
+ {message.annotations && + (() => { + // Get all ANSWER annotations + const answerAnnotations = ( + message.annotations as any[] + ).filter((a) => a.type === "ANSWER"); + + // Get the latest ANSWER annotation + const latestAnswer = + answerAnnotations.length > 0 + ? answerAnnotations[ + answerAnnotations.length - 1 + ] + : null; + + // If we have a latest ANSWER annotation with content, render it + if ( + latestAnswer?.content && + latestAnswer.content.length > 0 + ) { + return ( + + getCitationSource(id, index) + } + type="ai" + /> + ); + } + + // Fallback to the message content if no ANSWER annotation is available + return ( + + getCitationSource(id, index) + } + type="ai" + /> + ); + })()} +
+ } +
+ {/* Scroll to bottom button */} +
+ +
+
+
+
+ ); + } + + return null; + })} + + {/* New Chat Input Form */} +
+
+ + {/* Send button */} + +
+
+
+ {/* Enhanced Document Selection Dialog */} + + + {}} + documentsCount={documents?.length || 0} + /> + + + + +
+ + Select Documents + + {selectedDocuments.length} selected + +
+ +
+ + Choose documents to include in your research context. Use + filters and search to find specific documents. + +
+ + {/* Enhanced Search and Filter Controls */} +
+
+ {/* Search Input */} +
+ + setDocumentFilter(e.target.value)} + /> + {documentFilter && ( + + )} +
+ + {/* Document Type Filter */} + { + setDocumentTypeFilter(newType); + setDocumentsPage(1); // Reset to page 1 when filter changes + }} + counts={documentTypeCounts} + /> +
+ + {/* Results Summary */} +
+ + {isLoadingDocuments + ? "Loading documents..." + : `Showing ${paginatedDocuments.length} of ${filteredDocuments.length} documents`} + + {filteredDocuments.length > 0 && ( + + Page {documentsPage} of {totalPages} + + )} +
+
+ + {/* Document List with Proper Scrolling */} +
+
+ {isLoadingDocuments ? ( + // Enhanced skeleton loading + Array.from({ length: 6 }, (_, i) => ( + + )) + ) : documentsError ? ( +
+
+ +
+

+ Error loading documents +

+

+ Please try refreshing the page +

+
+ ) : filteredDocuments.length === 0 ? ( +
+
+ +
+

+ No documents found +

+

+ {documentFilter || documentTypeFilter !== "ALL" + ? "Try adjusting your search or filters" + : "Upload documents to get started"} +

+ {!documentFilter && documentTypeFilter === "ALL" && ( + + )} +
+ ) : ( + // Enhanced document list + paginatedDocuments.map((document) => { + const isSelected = selectedDocuments.includes( + document.id, + ); + const typeLabel = document.document_type + .replace(/_/g, " ") + .toLowerCase(); + + return ( +
handleDocumentToggle(document.id)} + > +
+
+ {getConnectorIcon(document.document_type)} +
+
+
+
+

+ {document.title} +

+ {isSelected && ( +
+
+ +
+
+ )} +
+
+ + {typeLabel} + + + {new Date( + document.created_at, + ).toLocaleDateString()} + +
+

+ {document.content.substring(0, 200)}... +

+
+
+ ); + }) + )} +
+
+ + {/* Enhanced Pagination Controls */} + {totalPages > 1 && ( +
+
+ +
+ {Array.from( + { length: Math.min(5, totalPages) }, + (_, i) => { + const page = + documentsPage <= 3 + ? i + 1 + : documentsPage - 2 + i; + if (page > totalPages) return null; + return ( + + ); + }, + )} + {totalPages > 5 && documentsPage < totalPages - 2 && ( + <> + + ... + + + + )} +
+ +
+
+ )} + + {/* Enhanced Footer */} + +
+ + {selectedDocuments.length} of {filteredDocuments.length}{" "} + document{selectedDocuments.length !== 1 ? "s" : ""}{" "} + selected + +
+
+ + + +
+
+
+
+ + {/* Connector Selection Dialog */} + + + {}} + /> + + + + Select Connectors + + Choose which data sources to include in your research + + + + {/* Connector selection grid */} +
+ {isLoadingConnectors ? ( +
+ +
+ ) : ( + connectorSourceItems.map((connector) => { + const isSelected = selectedConnectors.includes( + connector.type, + ); + + return ( +
{ + setSelectedConnectors( + isSelected + ? selectedConnectors.filter( + (type) => type !== connector.type, + ) + : [...selectedConnectors, connector.type], + ); + }} + role="checkbox" + aria-checked={isSelected} + tabIndex={0} + > +
+ {getConnectorIcon(connector.type)} +
+ + {connector.name} + + {isSelected && ( + + )} +
+ ); + }) + )} +
+ + +
+ + +
+
+
+
+ + {/* Search Mode Control */} +
+ + +
+ + {/* Research Mode Control */} +
+ +
+ + {/* Fast LLM Selector */} +
+ +
+
+
+
+ + {/* Reference for auto-scrolling */} +
+
+ + ); }; -export default ChatPage; \ No newline at end of file +export default ChatPage; diff --git a/surfsense_web/components/copy-button.tsx b/surfsense_web/components/copy-button.tsx index fd42ca648..7842f6ae6 100644 --- a/surfsense_web/components/copy-button.tsx +++ b/surfsense_web/components/copy-button.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { RefObject } from "react"; import { Button } from "./ui/button"; import { Copy, CopyCheck } from "lucide-react"; @@ -10,6 +10,15 @@ export default function CopyButton({ ref: RefObject; }) { const [copy, setCopy] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); const handleClick = () => { if (ref.current) { @@ -17,9 +26,9 @@ export default function CopyButton({ navigator.clipboard.writeText(text); setCopy(true); - setTimeout(() => { + timeoutRef.current = setTimeout(() => { setCopy(false); - }, 500); + }, 2000); } };