diff --git a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx index d982804..7c80382 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx @@ -966,14 +966,14 @@ export default function PodcastsPageClient({ -
+
( -
- -
- - - -
- -
+
+ +
+ + + +
+ +
); /** * 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; } @@ -573,588 +617,724 @@ const ChatPage = () => { background: hsl(var(--muted-foreground) / 0.5); } `; - document.head.appendChild(style); + document.head.appendChild(style); - return () => { - document.head.removeChild(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 - } - }); + 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 + // 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); + // 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}` - } - }); + 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}`); - } + if (!response.ok) { + throw new Error( + `Failed to fetch chat details: ${response.statusText}`, + ); + } - const chatData = await response.json(); - // console.log('Chat details fetched:', chatData); + 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 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 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); - } - }; + // 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]); + 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; + // 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; + // 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; + // Use the first user message as the title + const title = userMessages[0].content; + // console.log('Updating chat with title:', title); - // 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), + }), + }, + ); - // 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}`); + } - 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); + } + }; - // console.log('Chat updated successfully'); - } - } catch (err) { - console.error('Error updating chat:', err); - } - }; + updateChat(); + }, [ + messages, + status, + chat_id, + researchMode, + selectedConnectors, + search_space_id, + ]); - 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; - // 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 + // Find the latest assistant message + const assistantMessages = messages.filter( + (msg) => msg.role === "assistant", + ); + if (assistantMessages.length === 0) return; - // Pure function to get connector sources for a specific message - const getMessageConnectorSources = (message: any): any[] => { - if (!message || message.role !== 'assistant' || !message.annotations) return []; + const latestAssistantMessage = + assistantMessages[assistantMessages.length - 1]; + if (!latestAssistantMessage?.annotations) return; - // Find all SOURCES annotations - const annotations = message.annotations as any[]; - const sourcesAnnotations = annotations.filter(a => a.type === 'SOURCES'); + // Check for terminal info annotations + const annotations = latestAssistantMessage.annotations as any[]; + const terminalInfoAnnotations = annotations.filter( + (a) => a.type === "TERMINAL_INFO", + ); - // Get the latest SOURCES annotation - if (sourcesAnnotations.length === 0) return []; - const latestSourcesAnnotation = sourcesAnnotations[sourcesAnnotations.length - 1]; - - if (!latestSourcesAnnotation.content) return []; - - return latestSourcesAnnotation.content; - }; + 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 - // Custom handleSubmit function to include selected connectors and answer type - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + // Pure function to get connector sources for a specific message + const getMessageConnectorSources = (message: any): any[] => { + if (!message || message.role !== "assistant" || !message.annotations) + return []; - if (!input.trim() || status !== 'ready') return; + // Find all SOURCES annotations + const annotations = message.annotations as any[]; + const sourcesAnnotations = annotations.filter((a) => a.type === "SOURCES"); - // 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; - // } + // Get the latest SOURCES annotation + if (sourcesAnnotations.length === 0) return []; + const latestSourcesAnnotation = + sourcesAnnotations[sourcesAnnotations.length - 1]; - // Call the original handleSubmit from useChat - handleChatSubmit(e); - }; + if (!latestSourcesAnnotation.content) return []; - // Reference to the messages container for auto-scrolling - const messagesEndRef = useRef(null); + return latestSourcesAnnotation.content; + }; - // Function to scroll to bottom - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; + // Custom handleSubmit function to include selected connectors and answer type + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); - // Scroll to bottom when messages change - useEffect(() => { - scrollToBottom(); - }, [messages]); + if (!input.trim() || status !== "ready") return; - // Reset sources page when new messages arrive - useEffect(() => { - // Reset pagination when we get new messages - setSourcesPage(1); - setExpandedSources(false); - }, [messages]); + // 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; + // } - // Scroll terminal to bottom when expanded - useEffect(() => { - if (terminalExpanded) { - setTimeout(scrollTerminalToBottom, 300); // Wait for transition to complete - } - }, [terminalExpanded]); + // Call the original handleSubmit from useChat + handleChatSubmit(e); + }; - // Function to check scroll position and update indicators - const updateScrollIndicators = () => { - updateScrollIndicatorsUtil(tabsListRef as React.RefObject, setCanScrollLeft, setCanScrollRight); - }; + // Reference to the messages container for auto-scrolling + const messagesEndRef = useRef(null); - // Initialize scroll indicators - const updateIndicators = useScrollIndicators( - tabsListRef as React.RefObject, - setCanScrollLeft, - setCanScrollRight - ); + // Function to scroll to bottom + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; - // Function to scroll tabs list left - const scrollTabsLeft = () => { - scrollTabsLeftUtil(tabsListRef as React.RefObject, updateIndicators); - }; + // Scroll to bottom when messages change + useEffect(() => { + scrollToBottom(); + }, [messages]); - // Function to scroll tabs list right - const scrollTabsRight = () => { - scrollTabsRightUtil(tabsListRef as React.RefObject, updateIndicators); - }; + // Reset sources page when new messages arrive + useEffect(() => { + // Reset pagination when we get new messages + setSourcesPage(1); + setExpandedSources(false); + }, [messages]); - // Use the scroll to bottom hook - useScrollToBottom(messagesEndRef as React.RefObject, [messages]); + // Scroll terminal to bottom when expanded + useEffect(() => { + if (terminalExpanded) { + setTimeout(scrollTerminalToBottom, 300); // Wait for transition to complete + } + }, [terminalExpanded]); - // 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; + // Function to check scroll position and update indicators + const updateScrollIndicators = () => { + updateScrollIndicatorsUtil( + tabsListRef as React.RefObject, + setCanScrollLeft, + setCanScrollRight, + ); + }; - // 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; + // Initialize scroll indicators + const updateIndicators = useScrollIndicators( + tabsListRef as React.RefObject, + setCanScrollLeft, + setCanScrollRight, + ); - const latestAssistantMessage = assistantMessages[assistantMessages.length - 1]; - - // Use our helper function to get sources - const sources = getMessageConnectorSources(latestAssistantMessage); - if (sources.length === 0) return null; + // Function to scroll tabs list left + const scrollTabsLeft = () => { + scrollTabsLeftUtil( + tabsListRef as React.RefObject, + updateIndicators, + ); + }; - // 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 - }); - }); - } - }); + // Function to scroll tabs list right + const scrollTabsRight = () => { + scrollTabsRightUtil( + tabsListRef as React.RefObject, + updateIndicators, + ); + }; - // Find the source with the matching ID - const foundSource = allSources.find(source => source.id === citationId); + // Use the scroll to bottom hook + useScrollToBottom(messagesEndRef as React.RefObject, [ + messages, + ]); - 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; + // 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; - // 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 - }); - }); - } - }); + // 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; - // Find the source with the matching ID - const foundSource = allSources.find(source => source.id === citationId); + const latestAssistantMessage = + assistantMessages[assistantMessages.length - 1]; - return foundSource || null; - } - }, [messages]); + // Use our helper function to get sources + const sources = getMessageConnectorSources(latestAssistantMessage); + if (sources.length === 0) return null; - // Pure function for rendering terminal content - no hooks allowed here - const renderTerminalContent = (message: any) => { - if (!message.annotations) 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, + }); + }); + } + }); - // Get all TERMINAL_INFO annotations - const terminalInfoAnnotations = (message.annotations as any[]) - .filter(a => a.type === 'TERMINAL_INFO'); + // Find the source with the matching ID + const foundSource = allSources.find( + (source) => source.id === citationId, + ); - // Get the latest TERMINAL_INFO annotation - const latestTerminalInfo = terminalInfoAnnotations.length > 0 - ? terminalInfoAnnotations[terminalInfoAnnotations.length - 1] - : null; + return foundSource || null; + } else { + // Use the specific message by index + const message = messages[messageIndex]; - // 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} -
- )); - }; + // Use our helper function to get sources + const sources = getMessageConnectorSources(message); + if (sources.length === 0) return null; - return ( - <> -
- {messages.length === 0 && ( -

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

- )} - {messages?.map((message, index) => { - if (message.role === 'user') { - return ( -
- -
- - - getCitationSource(id, index)} - className="text-sm" - /> - - -
-
- ); - } + // 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, + }); + }); + } + }); - if (message.role === 'assistant') { - return ( -
- - - Answer - - - {/* Status Messages Section */} - -
-
-
-
setTerminalExpanded(false)}>
-
-
setTerminalExpanded(true)}>
-
- surfsense-research-terminal -
-
+ // Find the source with the matching ID + const foundSource = allSources.find( + (source) => source.id === citationId, + ); -
-
Last login: {currentDate} {currentTime}
-
- researcher@surfsense - : - ~/research - $ - surfsense-researcher -
- - {renderTerminalContent(message)} - -
- [00:13] - researcher@surfsense - : - ~/research - $ -
-
+ return foundSource || null; + } + }, + [messages], + ); - {/* Terminal scroll button */} -
- -
-
-
+ // Pure function for rendering terminal content - no hooks allowed here + const renderTerminalContent = (message: any) => { + if (!message.annotations) return null; - {/* Sources Section with Connector Tabs */} -
-
- - Sources -
+ // Get all TERMINAL_INFO annotations + const terminalInfoAnnotations = (message.annotations as any[]).filter( + (a) => a.type === "TERMINAL_INFO", + ); - {(() => { - // 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" - > -
-
- + // Get the latest TERMINAL_INFO annotation + const latestTerminalInfo = + terminalInfoAnnotations.length > 0 + ? terminalInfoAnnotations[terminalInfoAnnotations.length - 1] + : null; -
-
- - {messageConnectorSources.map((connector) => ( - - {getConnectorIcon(connector.type)} - {connector.name.split(' ')[0]} - - {connector.sources?.length || 0} - - - ))} - -
-
+ // 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" + /> + + +
+
+ ); + } - {messageConnectorSources.map(connector => ( - -
- {connector.sources?.slice(0, INITIAL_SOURCES_DISPLAY)?.map((source: any, index: number) => ( - -
-
- {getConnectorIcon(connector.type)} -
-
-

{source.title}

-

{source.description}

-
- -
-
- ))} + if (message.role === "assistant") { + return ( +
+ + + + Answer + + + + {/* Status Messages Section */} + +
+
+
+
setTerminalExpanded(false)} + >
+
+
setTerminalExpanded(true)} + >
+
+ + surfsense-research-terminal + +
+
- {connector.sources?.length > INITIAL_SOURCES_DISPLAY && ( - setDialogOpenId(open ? connector.id : null)}> - - - - - - - - )} -
- - ))} - - ); - })()} -
+
+
+ Last login: {currentDate} {currentTime} +
+
+ + researcher@surfsense + + : + ~/research + $ + surfsense-researcher +
- {/* Answer Section */} -
- { -
- {message.annotations && (() => { - // Get all ANSWER annotations - const answerAnnotations = (message.annotations as any[]) - .filter(a => a.type === 'ANSWER'); + {renderTerminalContent(message)} - // Get the latest ANSWER annotation - const latestAnswer = answerAnnotations.length > 0 - ? answerAnnotations[answerAnnotations.length - 1] - : null; +
+ + [00:13] + + + researcher@surfsense + + : + ~/research + $ +
+
- // If we have a latest ANSWER annotation with content, render it - if (latestAnswer?.content && latestAnswer.content.length > 0) { - return ( - getCitationSource(id, index)} - /> - ); - } + {/* 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" />; })()}
@@ -1276,518 +1456,618 @@ const ChatPage = () => { ); } - return null; - })} + 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. - -
+ {/* 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 && ( - - )} -
+ {/* 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} - /> -
+ {/* 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} - - )} -
-
+ {/* 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(); + {/* 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)}... -

-
-
- ); - }) - )} -
-
+ 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 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 - -
-
- - - -
-
-
-
+ {/* Enhanced Footer */} + +
+ + {selectedDocuments.length} of {filteredDocuments.length}{" "} + document{selectedDocuments.length !== 1 ? "s" : ""}{" "} + selected + +
+
+ + + +
+
+ +
- 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 && } -
- ); - }) - )} -
+ {/* 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, + ); - {/* Search Mode Control */} -
- - -
+ 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 && ( + + )} +
+ ); + }) + )} +
- {/* Research Mode Control */} -
- -
+ +
+ + +
+
+ + - {/* Fast LLM Selector */} -
- -
-
-
-
+ {/* Search Mode Control */} +
+ + +
- {/* Reference for auto-scrolling */} -
-
- - ); + {/* 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 new file mode 100644 index 0000000..7842f6a --- /dev/null +++ b/surfsense_web/components/copy-button.tsx @@ -0,0 +1,42 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; +import { Button } from "./ui/button"; +import { Copy, CopyCheck } from "lucide-react"; + +export default function CopyButton({ + ref, +}: { + ref: RefObject; +}) { + const [copy, setCopy] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleClick = () => { + if (ref.current) { + const text = ref.current.innerText; + navigator.clipboard.writeText(text); + + setCopy(true); + timeoutRef.current = setTimeout(() => { + setCopy(false); + }, 2000); + } + }; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index f4bebf9..2e75e77 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from "react"; +import React, { useMemo, useState, useEffect, useRef } from "react"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize from "rehype-sanitize"; @@ -7,267 +7,350 @@ import { cn } from "@/lib/utils"; import { Citation } from "./chat/Citation"; import { Source } from "./chat/types"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { oneLight, oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { + oneLight, + oneDark, +} from "react-syntax-highlighter/dist/cjs/styles/prism"; import { Check, Copy } from "lucide-react"; import { useTheme } from "next-themes"; +import CopyButton from "./copy-button"; interface MarkdownViewerProps { - content: string; - className?: string; - getCitationSource?: (id: number) => Source | null; + content: string; + className?: string; + getCitationSource?: (id: number) => Source | null; + type?: "user" | "ai"; } -export function MarkdownViewer({ content, className, getCitationSource }: MarkdownViewerProps) { - // Memoize the markdown components to prevent unnecessary re-renders - const components = useMemo(() => { - return { - // Define custom components for markdown elements - p: ({node, children, ...props}: any) => { - // If there's no getCitationSource function, just render normally - if (!getCitationSource) { - return

{children}

; - } - - // Process citations within paragraph content - return

{processCitationsInReactChildren(children, getCitationSource)}

; - }, - a: ({node, children, ...props}: any) => { - // Process citations within link content if needed - const processedChildren = getCitationSource - ? processCitationsInReactChildren(children, getCitationSource) - : children; - return {processedChildren}; - }, - li: ({node, children, ...props}: any) => { - // Process citations within list item content - const processedChildren = getCitationSource - ? processCitationsInReactChildren(children, getCitationSource) - : children; - return
  • {processedChildren}
  • ; - }, - ul: ({node, ...props}: any) =>
      , - ol: ({node, ...props}: any) =>
        , - h1: ({node, children, ...props}: any) => { - const processedChildren = getCitationSource - ? processCitationsInReactChildren(children, getCitationSource) - : children; - return

        {processedChildren}

        ; - }, - h2: ({node, children, ...props}: any) => { - const processedChildren = getCitationSource - ? processCitationsInReactChildren(children, getCitationSource) - : children; - return

        {processedChildren}

        ; - }, - h3: ({node, children, ...props}: any) => { - const processedChildren = getCitationSource - ? processCitationsInReactChildren(children, getCitationSource) - : children; - return

        {processedChildren}

        ; - }, - h4: ({node, children, ...props}: any) => { - const processedChildren = getCitationSource - ? processCitationsInReactChildren(children, getCitationSource) - : children; - return

        {processedChildren}

        ; - }, - blockquote: ({node, ...props}: any) =>
        , - hr: ({node, ...props}: any) =>
        , - img: ({node, ...props}: any) => , - table: ({node, ...props}: any) =>
        , - th: ({node, ...props}: any) =>
        , - td: ({node, ...props}: any) => , - code: ({node, className, children, ...props}: any) => { - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : ''; - const isInline = !match; - - if (isInline) { - return {children}; - } - - // For code blocks, add syntax highlighting and copy functionality - return ( - - {String(children).replace(/\n$/, '')} - - ); - } - }; - }, [getCitationSource]); +export function MarkdownViewer({ + content, + className, + getCitationSource, + type = "user", +}: MarkdownViewerProps) { + const ref = useRef(null); + // Memoize the markdown components to prevent unnecessary re-renders + const components = useMemo(() => { + return { + // Define custom components for markdown elements + p: ({ node, children, ...props }: any) => { + // If there's no getCitationSource function, just render normally + if (!getCitationSource) { + return ( +

        + {children} +

        + ); + } - return ( -
        - - {content} - -
        - ); + // Process citations within paragraph content + return ( +

        + {processCitationsInReactChildren(children, getCitationSource)} +

        + ); + }, + a: ({ node, children, ...props }: any) => { + // Process citations within link content if needed + const processedChildren = getCitationSource + ? processCitationsInReactChildren(children, getCitationSource) + : children; + return ( + + {processedChildren} + + ); + }, + li: ({ node, children, ...props }: any) => { + // Process citations within list item content + const processedChildren = getCitationSource + ? processCitationsInReactChildren(children, getCitationSource) + : children; + return
      1. {processedChildren}
      2. ; + }, + ul: ({ node, ...props }: any) => ( +
          + ), + ol: ({ node, ...props }: any) => ( +
            + ), + h1: ({ node, children, ...props }: any) => { + const processedChildren = getCitationSource + ? processCitationsInReactChildren(children, getCitationSource) + : children; + return ( +

            + {processedChildren} +

            + ); + }, + h2: ({ node, children, ...props }: any) => { + const processedChildren = getCitationSource + ? processCitationsInReactChildren(children, getCitationSource) + : children; + return ( +

            + {processedChildren} +

            + ); + }, + h3: ({ node, children, ...props }: any) => { + const processedChildren = getCitationSource + ? processCitationsInReactChildren(children, getCitationSource) + : children; + return ( +

            + {processedChildren} +

            + ); + }, + h4: ({ node, children, ...props }: any) => { + const processedChildren = getCitationSource + ? processCitationsInReactChildren(children, getCitationSource) + : children; + return ( +

            + {processedChildren} +

            + ); + }, + blockquote: ({ node, ...props }: any) => ( +
            + ), + hr: ({ node, ...props }: any) => ( +
            + ), + img: ({ node, ...props }: any) => ( + + ), + table: ({ node, ...props }: any) => ( +
            + + + ), + th: ({ node, ...props }: any) => ( +
            + ), + td: ({ node, ...props }: any) => ( + + ), + code: ({ node, className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : ""; + const isInline = !match; + + if (isInline) { + return ( + + {children} + + ); + } + + // For code blocks, add syntax highlighting and copy functionality + return ( + + {String(children).replace(/\n$/, "")} + + ); + }, + }; + }, [getCitationSource]); + + return ( +
            + + {content} + + {type === "ai" && } +
            + ); } // Code block component with syntax highlighting and copy functionality -const CodeBlock = ({ children, language }: { children: string, language: string }) => { - const [copied, setCopied] = useState(false); - const { resolvedTheme, theme } = useTheme(); - const [mounted, setMounted] = useState(false); +const CodeBlock = ({ + children, + language, +}: { + children: string; + language: string; +}) => { + const [copied, setCopied] = useState(false); + const { resolvedTheme, theme } = useTheme(); + const [mounted, setMounted] = useState(false); - // Prevent hydration issues - useEffect(() => { - setMounted(true); - }, []); + // Prevent hydration issues + useEffect(() => { + setMounted(true); + }, []); - const handleCopy = async () => { - await navigator.clipboard.writeText(children); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; + const handleCopy = async () => { + await navigator.clipboard.writeText(children); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; - // Choose theme based on current system/user preference - const isDarkTheme = mounted && (resolvedTheme === 'dark' || theme === 'dark'); - const syntaxTheme = isDarkTheme ? oneDark : oneLight; + // Choose theme based on current system/user preference + const isDarkTheme = mounted && (resolvedTheme === "dark" || theme === "dark"); + const syntaxTheme = isDarkTheme ? oneDark : oneLight; - return ( -
            -
            - -
            - {mounted ? ( - - {children} - - ) : ( -
            -
            -            {children}
            -          
            -
            - )} -
            - ); + return ( +
            +
            + +
            + {mounted ? ( + + {children} + + ) : ( +
            +
            +						
            +							{children}
            +						
            +					
            +
            + )} +
            + ); }; // Helper function to process citations within React children -const processCitationsInReactChildren = (children: React.ReactNode, getCitationSource: (id: number) => Source | null): React.ReactNode => { - // If children is not an array or string, just return it - if (!children || (typeof children !== 'string' && !Array.isArray(children))) { - return children; - } - - // Handle string content directly - this is where we process citation references - if (typeof children === 'string') { - return processCitationsInText(children, getCitationSource); - } - - // Handle arrays of children recursively - if (Array.isArray(children)) { - return React.Children.map(children, child => { - if (typeof child === 'string') { - return processCitationsInText(child, getCitationSource); - } - return child; - }); - } - - return children; +const processCitationsInReactChildren = ( + children: React.ReactNode, + getCitationSource: (id: number) => Source | null, +): React.ReactNode => { + // If children is not an array or string, just return it + if (!children || (typeof children !== "string" && !Array.isArray(children))) { + return children; + } + + // Handle string content directly - this is where we process citation references + if (typeof children === "string") { + return processCitationsInText(children, getCitationSource); + } + + // Handle arrays of children recursively + if (Array.isArray(children)) { + return React.Children.map(children, (child) => { + if (typeof child === "string") { + return processCitationsInText(child, getCitationSource); + } + return child; + }); + } + + return children; }; // Process citation references in text content -const processCitationsInText = (text: string, getCitationSource: (id: number) => Source | null): React.ReactNode[] => { - // Use improved regex to catch citation numbers more reliably - // This will match patterns like [1], [42], etc. including when they appear at the end of a line or sentence - const citationRegex = /\[(\d+)\]/g; - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match; - let position = 0; - - while ((match = citationRegex.exec(text)) !== null) { - // Add text before the citation - if (match.index > lastIndex) { - parts.push(text.substring(lastIndex, match.index)); - } - - // Add the citation component - const citationId = parseInt(match[1], 10); - const source = getCitationSource(citationId); - - parts.push( - - ); - - lastIndex = match.index + match[0].length; - position++; - } - - // Add any remaining text after the last citation - if (lastIndex < text.length) { - parts.push(text.substring(lastIndex)); - } - - return parts; -}; \ No newline at end of file +const processCitationsInText = ( + text: string, + getCitationSource: (id: number) => Source | null, +): React.ReactNode[] => { + // Use improved regex to catch citation numbers more reliably + // This will match patterns like [1], [42], etc. including when they appear at the end of a line or sentence + const citationRegex = /\[(\d+)\]/g; + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + let position = 0; + + while ((match = citationRegex.exec(text)) !== null) { + // Add text before the citation + if (match.index > lastIndex) { + parts.push(text.substring(lastIndex, match.index)); + } + + // Add the citation component + const citationId = parseInt(match[1], 10); + const source = getCitationSource(citationId); + + parts.push( + , + ); + + lastIndex = match.index + match[0].length; + position++; + } + + // Add any remaining text after the last citation + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + + return parts; +}; diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 6848cb3..5effec5 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -4354,8 +4354,8 @@ packages: tailwind-merge@3.2.0: resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} - tailwind-merge@3.3.0: - resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} @@ -6869,7 +6869,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - tailwind-merge: 3.3.0 + tailwind-merge: 3.3.1 tsup: 6.7.0(postcss@8.5.3)(typescript@5.8.2) transitivePeerDependencies: - '@swc/core' @@ -9411,7 +9411,7 @@ snapshots: tailwind-merge@3.2.0: {} - tailwind-merge@3.3.0: {} + tailwind-merge@3.3.1: {} tailwindcss-animate@1.0.7(tailwindcss@4.0.9): dependencies: