>;
+ 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 && (
-
-
-
-
- )}
- {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 && (
-
- )}
-
-
- ))}
-
- );
- })()}
-
-
- {/* 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 */}
-
-
-
-
- {/* Enhanced Document Selection Dialog */}
-
-
- {/* Connector Selection Dialog */}
-
-
- {/* 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 && (
+
+
+
+
+ )}
+ {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 && (
+
+ )}
+
+
+ ))}
+
+ );
+ })()}
+
+
+ {/* 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 */}
+
+
+
+
+ {/* Enhanced Document Selection Dialog */}
+
+
+ {/* Connector Selection Dialog */}
+
+
+ {/* 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
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 {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]);
+
+ 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}
-
- ) : (
-
- )}
-
- );
+ return (
+
+
+
+
+ {mounted ? (
+
+ {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: