diff --git a/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx
index dcdcc210d..4ff0c106f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx
@@ -1,481 +1,525 @@
"use client";
-import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react';
-import { useChat } from '@ai-sdk/react';
-import { useParams } from 'next/navigation';
+import React, {
+ useRef,
+ useEffect,
+ useState,
+ useMemo,
+ useCallback,
+} from "react";
+import { useChat } from "@ai-sdk/react";
+import { useParams } from "next/navigation";
import {
- Loader2,
- X,
- Search,
- ExternalLink,
- ChevronLeft,
- ChevronRight,
- Check,
- ArrowDown,
- CircleUser,
- Database,
- SendHorizontal,
- FileText,
- Grid3x3,
- FolderOpen,
- Upload,
- ChevronDown,
- Filter,
- Brain,
- Zap
-} from 'lucide-react';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+ Loader2,
+ X,
+ Search,
+ ExternalLink,
+ ChevronLeft,
+ ChevronRight,
+ Check,
+ ArrowDown,
+ CircleUser,
+ Database,
+ SendHorizontal,
+ FileText,
+ Grid3x3,
+ FolderOpen,
+ Upload,
+ ChevronDown,
+ Filter,
+ Brain,
+ Zap,
+} from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
- DialogFooter
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogFooter,
} from "@/components/ui/dialog";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
- ConnectorButton as ConnectorButtonComponent,
- getConnectorIcon,
- getFilteredSources as getFilteredSourcesUtil,
- getPaginatedDialogSources as getPaginatedDialogSourcesUtil,
- useScrollToBottom,
- updateScrollIndicators as updateScrollIndicatorsUtil,
- useScrollIndicators,
- scrollTabsLeft as scrollTabsLeftUtil,
- scrollTabsRight as scrollTabsRightUtil,
- Source,
- ResearchMode,
- ResearchModeControl
-} from '@/components/chat';
-import { MarkdownViewer } from '@/components/markdown-viewer';
-import { Logo } from '@/components/Logo';
-import { useSearchSourceConnectors } from '@/hooks';
-import { useDocuments } from '@/hooks/use-documents';
-import { useLLMConfigs, useLLMPreferences } from '@/hooks/use-llm-configs';
+ ConnectorButton as ConnectorButtonComponent,
+ getConnectorIcon,
+ getFilteredSources as getFilteredSourcesUtil,
+ getPaginatedDialogSources as getPaginatedDialogSourcesUtil,
+ useScrollToBottom,
+ updateScrollIndicators as updateScrollIndicatorsUtil,
+ useScrollIndicators,
+ scrollTabsLeft as scrollTabsLeftUtil,
+ scrollTabsRight as scrollTabsRightUtil,
+ Source,
+ ResearchMode,
+ ResearchModeControl,
+} from "@/components/chat";
+import { MarkdownViewer } from "@/components/markdown-viewer";
+import { Logo } from "@/components/Logo";
+import { useSearchSourceConnectors } from "@/hooks";
+import { useDocuments } from "@/hooks/use-documents";
+import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
interface SourceItem {
- id: number;
- title: string;
- description: string;
- url: string;
- connectorType?: string;
+ id: number;
+ title: string;
+ description: string;
+ url: string;
+ connectorType?: string;
}
interface ConnectorSource {
- id: number;
- name: string;
- type: string;
- sources: SourceItem[];
+ id: number;
+ name: string;
+ type: string;
+ sources: SourceItem[];
}
-type DocumentType = "EXTENSION" | "CRAWLED_URL" | "SLACK_CONNECTOR" | "NOTION_CONNECTOR" | "FILE" | "YOUTUBE_VIDEO" | "GITHUB_CONNECTOR" | "LINEAR_CONNECTOR" | "DISCORD_CONNECTOR";
-
+type DocumentType =
+ | "EXTENSION"
+ | "CRAWLED_URL"
+ | "SLACK_CONNECTOR"
+ | "NOTION_CONNECTOR"
+ | "FILE"
+ | "YOUTUBE_VIDEO"
+ | "GITHUB_CONNECTOR"
+ | "LINEAR_CONNECTOR"
+ | "DISCORD_CONNECTOR";
/**
* Skeleton loader for document items
*/
const DocumentSkeleton = () => (
-
+
);
/**
* Enhanced document type filter dropdown
*/
-const DocumentTypeFilter = ({
- value,
- onChange,
- counts
-}: {
- value: DocumentType | "ALL";
- onChange: (value: DocumentType | "ALL") => void;
- counts: Record;
+const DocumentTypeFilter = ({
+ value,
+ onChange,
+ counts,
+}: {
+ value: DocumentType | "ALL";
+ onChange: (value: DocumentType | "ALL") => void;
+ counts: Record;
}) => {
- const getTypeLabel = (type: DocumentType | "ALL") => {
- if (type === "ALL") return "All Types";
- return type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
- };
+ const getTypeLabel = (type: DocumentType | "ALL") => {
+ if (type === "ALL") return "All Types";
+ return type
+ .replace(/_/g, " ")
+ .toLowerCase()
+ .replace(/\b\w/g, (l) => l.toUpperCase());
+ };
- const getTypeIcon = (type: DocumentType | "ALL") => {
- if (type === "ALL") return ;
- return getConnectorIcon(type);
- };
+ const getTypeIcon = (type: DocumentType | "ALL") => {
+ if (type === "ALL") return ;
+ return getConnectorIcon(type);
+ };
- return (
-
-
-
-
-
- Document Types
-
- {Object.entries(counts).map(([type, count]) => (
- onChange(type as DocumentType | "ALL")}
- className="flex items-center justify-between"
- >
-
- {getTypeIcon(type as DocumentType | "ALL")}
- {getTypeLabel(type as DocumentType | "ALL")}
-
-
- {count}
-
-
- ))}
-
-
- );
+ return (
+
+
+
+
+
+ Document Types
+
+ {Object.entries(counts).map(([type, count]) => (
+ onChange(type as DocumentType | "ALL")}
+ className="flex items-center justify-between"
+ >
+
+ {getTypeIcon(type as DocumentType | "ALL")}
+ {getTypeLabel(type as DocumentType | "ALL")}
+
+
+ {count}
+
+
+ ))}
+
+
+ );
};
/**
* Button that displays selected connectors and opens connector selection dialog
*/
-const ConnectorButton = ({ selectedConnectors, onClick }: { selectedConnectors: string[], onClick: () => void }) => {
- const { connectorSourceItems } = useSearchSourceConnectors();
-
- return (
-
- );
+const ConnectorButton = ({
+ selectedConnectors,
+ onClick,
+}: {
+ selectedConnectors: string[];
+ onClick: () => void;
+}) => {
+ const { connectorSourceItems } = useSearchSourceConnectors();
+
+ return (
+
+ );
};
/**
* Button that displays selected documents count and opens document selection dialog
*/
-const DocumentSelectorButton = ({
- selectedDocuments,
- onClick,
- documentsCount
-}: {
- selectedDocuments: number[],
- onClick: () => void,
- documentsCount: number
+const DocumentSelectorButton = ({
+ selectedDocuments,
+ onClick,
+ documentsCount,
+}: {
+ selectedDocuments: number[];
+ onClick: () => void;
+ documentsCount: number;
}) => {
- return (
-
-
- {selectedDocuments.length > 0 && (
-
- {selectedDocuments.length > 99 ? '99+' : selectedDocuments.length}
-
- )}
- {selectedDocuments.length === 0 && (
-
- 0
-
- )}
-
- );
+ return (
+
+
+ {selectedDocuments.length > 0 && (
+
+ {selectedDocuments.length > 99 ? "99+" : selectedDocuments.length}
+
+ )}
+ {selectedDocuments.length === 0 && (
+
+ 0
+
+ )}
+
+ );
};
// Create a wrapper component for the sources dialog content
-const SourcesDialogContent = ({
- connector,
- sourceFilter,
- expandedSources,
- sourcesPage,
- setSourcesPage,
- setSourceFilter,
- setExpandedSources,
- isLoadingMore
-}: {
- connector: any;
- sourceFilter: string;
- expandedSources: boolean;
- sourcesPage: number;
- setSourcesPage: React.Dispatch>;
- setSourceFilter: React.Dispatch>;
- setExpandedSources: React.Dispatch>;
- isLoadingMore: boolean;
+const SourcesDialogContent = ({
+ connector,
+ sourceFilter,
+ expandedSources,
+ sourcesPage,
+ setSourcesPage,
+ setSourceFilter,
+ setExpandedSources,
+ isLoadingMore,
+}: {
+ connector: any;
+ sourceFilter: string;
+ expandedSources: boolean;
+ sourcesPage: number;
+ setSourcesPage: React.Dispatch>;
+ setSourceFilter: React.Dispatch>;
+ setExpandedSources: React.Dispatch>;
+ isLoadingMore: boolean;
}) => {
- // Safely access sources with fallbacks
- const sources = connector?.sources || [];
-
- // Safe versions of utility functions
- const getFilteredSourcesSafe = () => {
- if (!sources.length) return [];
- return getFilteredSourcesUtil(connector, sourceFilter);
- };
-
- const getPaginatedSourcesSafe = () => {
- if (!sources.length) return [];
- return getPaginatedDialogSourcesUtil(
- connector,
- sourceFilter,
- expandedSources,
- sourcesPage,
- 5 // SOURCES_PER_PAGE
- );
- };
-
- const filteredSources = getFilteredSourcesSafe() || [];
- const paginatedSources = getPaginatedSourcesSafe() || [];
-
- // Description text
- const descriptionText = sourceFilter
- ? `Found ${filteredSources.length} sources matching "${sourceFilter}"`
- : `Viewing ${paginatedSources.length} of ${sources.length} sources`;
-
- if (paginatedSources.length === 0) {
- return (
-
-
-
No sources found matching "{sourceFilter}"
-
-
- );
- }
-
- return (
- <>
-
-
- {getConnectorIcon(connector.type)}
- {connector.name} Sources
-
-
- {descriptionText}
-
-
+ // Safely access sources with fallbacks
+ const sources = connector?.sources || [];
-
-
- {
- setSourceFilter(e.target.value);
- setSourcesPage(1);
- setExpandedSources(false);
- }}
- />
- {sourceFilter && (
-
- )}
-
+ // Safe versions of utility functions
+ const getFilteredSourcesSafe = () => {
+ if (!sources.length) return [];
+ return getFilteredSourcesUtil(connector, sourceFilter);
+ };
-
- {paginatedSources.map((source: any, index: number) => (
-
-
-
- {getConnectorIcon(connector.type)}
-
-
-
{source.title}
-
{source.description}
-
-
-
-
- ))}
+ const getPaginatedSourcesSafe = () => {
+ if (!sources.length) return [];
+ return getPaginatedDialogSourcesUtil(
+ connector,
+ sourceFilter,
+ expandedSources,
+ sourcesPage,
+ 5, // SOURCES_PER_PAGE
+ );
+ };
- {!expandedSources && paginatedSources.length < filteredSources.length && (
-
- )}
+ const filteredSources = getFilteredSourcesSafe() || [];
+ const paginatedSources = getPaginatedSourcesSafe() || [];
- {expandedSources && filteredSources.length > 10 && (
-
- Showing all {filteredSources.length} sources
-
- )}
-
- >
- );
+ // Description text
+ const descriptionText = sourceFilter
+ ? `Found ${filteredSources.length} sources matching "${sourceFilter}"`
+ : `Viewing ${paginatedSources.length} of ${sources.length} sources`;
+
+ if (paginatedSources.length === 0) {
+ return (
+
+
+
No sources found matching "{sourceFilter}"
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {getConnectorIcon(connector.type)}
+ {connector.name} Sources
+
+
+ {descriptionText}
+
+
+
+
+
+ {
+ setSourceFilter(e.target.value);
+ setSourcesPage(1);
+ setExpandedSources(false);
+ }}
+ />
+ {sourceFilter && (
+
+ )}
+
+
+
+ {paginatedSources.map((source: any, index: number) => (
+
+
+
+ {getConnectorIcon(connector.type)}
+
+
+
{source.title}
+
+ {source.description}
+
+
+
+
+
+ ))}
+
+ {!expandedSources &&
+ paginatedSources.length < filteredSources.length && (
+
+ )}
+
+ {expandedSources && filteredSources.length > 10 && (
+
+ Showing all {filteredSources.length} sources
+
+ )}
+
+ >
+ );
};
const ChatPage = () => {
- const [token, setToken] = React.useState(null);
- const [dialogOpenId, setDialogOpenId] = useState(null);
- const [sourcesPage, setSourcesPage] = useState(1);
- const [expandedSources, setExpandedSources] = useState(false);
- const [canScrollLeft, setCanScrollLeft] = useState(false);
- const [canScrollRight, setCanScrollRight] = useState(true);
- const [sourceFilter, setSourceFilter] = useState("");
- const tabsListRef = useRef(null);
- const [terminalExpanded, setTerminalExpanded] = useState(false);
- const [selectedConnectors, setSelectedConnectors] = useState([]);
- const [searchMode, setSearchMode] = useState<'DOCUMENTS' | 'CHUNKS'>('DOCUMENTS');
- const [researchMode, setResearchMode] = useState("QNA");
- const [currentTime, setCurrentTime] = useState('');
- const [currentDate, setCurrentDate] = useState('');
- const terminalMessagesRef = useRef(null);
- const { connectorSourceItems, isLoading: isLoadingConnectors } = useSearchSourceConnectors();
- const { llmConfigs } = useLLMConfigs();
- const { preferences, updatePreferences } = useLLMPreferences();
+ const [token, setToken] = React.useState(null);
+ const [dialogOpenId, setDialogOpenId] = useState(null);
+ const [sourcesPage, setSourcesPage] = useState(1);
+ const [expandedSources, setExpandedSources] = useState(false);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(true);
+ const [sourceFilter, setSourceFilter] = useState("");
+ const tabsListRef = useRef(null);
+ const [terminalExpanded, setTerminalExpanded] = useState(false);
+ const [selectedConnectors, setSelectedConnectors] = useState([]);
+ const [searchMode, setSearchMode] = useState<"DOCUMENTS" | "CHUNKS">(
+ "DOCUMENTS",
+ );
+ const [researchMode, setResearchMode] = useState("QNA");
+ const [currentTime, setCurrentTime] = useState("");
+ const [currentDate, setCurrentDate] = useState("");
+ const terminalMessagesRef = useRef(null);
+ const { connectorSourceItems, isLoading: isLoadingConnectors } =
+ useSearchSourceConnectors();
+ const { llmConfigs } = useLLMConfigs();
+ const { preferences, updatePreferences } = useLLMPreferences();
- const INITIAL_SOURCES_DISPLAY = 3;
+ const INITIAL_SOURCES_DISPLAY = 3;
- const { search_space_id, chat_id } = useParams();
-
- // Document selection state
- const [selectedDocuments, setSelectedDocuments] = useState([]);
- const [documentFilter, setDocumentFilter] = useState("");
- const [debouncedDocumentFilter, setDebouncedDocumentFilter] = useState("");
- const [documentTypeFilter, setDocumentTypeFilter] = useState("ALL");
- const [documentsPage, setDocumentsPage] = useState(1);
- const [documentsPerPage] = useState(10);
- const { documents, loading: isLoadingDocuments, error: documentsError } = useDocuments(Number(search_space_id));
+ const { search_space_id, chat_id } = useParams();
- // Debounced search effect (proper implementation)
- useEffect(() => {
- const handler = setTimeout(() => {
- setDebouncedDocumentFilter(documentFilter);
- setDocumentsPage(1); // Reset page when search changes
- }, 300);
+ // Document selection state
+ const [selectedDocuments, setSelectedDocuments] = useState([]);
+ const [documentFilter, setDocumentFilter] = useState("");
+ const [debouncedDocumentFilter, setDebouncedDocumentFilter] = useState("");
+ const [documentTypeFilter, setDocumentTypeFilter] = useState<
+ DocumentType | "ALL"
+ >("ALL");
+ const [documentsPage, setDocumentsPage] = useState(1);
+ const [documentsPerPage] = useState(10);
+ const {
+ documents,
+ loading: isLoadingDocuments,
+ error: documentsError,
+ } = useDocuments(Number(search_space_id));
- return () => {
- clearTimeout(handler);
- };
- }, [documentFilter]);
+ // Debounced search effect (proper implementation)
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedDocumentFilter(documentFilter);
+ setDocumentsPage(1); // Reset page when search changes
+ }, 300);
- // Memoized filtered and paginated documents
- const filteredDocuments = useMemo(() => {
- if (!documents) return [];
-
- return documents.filter(doc => {
- const matchesSearch = doc.title.toLowerCase().includes(debouncedDocumentFilter.toLowerCase()) ||
- doc.content.toLowerCase().includes(debouncedDocumentFilter.toLowerCase());
- const matchesType = documentTypeFilter === "ALL" || doc.document_type === documentTypeFilter;
- return matchesSearch && matchesType;
- });
- }, [documents, debouncedDocumentFilter, documentTypeFilter]);
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [documentFilter]);
- const paginatedDocuments = useMemo(() => {
- const startIndex = (documentsPage - 1) * documentsPerPage;
- return filteredDocuments.slice(startIndex, startIndex + documentsPerPage);
- }, [filteredDocuments, documentsPage, documentsPerPage]);
+ // Memoized filtered and paginated documents
+ const filteredDocuments = useMemo(() => {
+ if (!documents) return [];
- const totalPages = Math.ceil(filteredDocuments.length / documentsPerPage);
+ return documents.filter((doc) => {
+ const matchesSearch =
+ doc.title
+ .toLowerCase()
+ .includes(debouncedDocumentFilter.toLowerCase()) ||
+ doc.content
+ .toLowerCase()
+ .includes(debouncedDocumentFilter.toLowerCase());
+ const matchesType =
+ documentTypeFilter === "ALL" ||
+ doc.document_type === documentTypeFilter;
+ return matchesSearch && matchesType;
+ });
+ }, [documents, debouncedDocumentFilter, documentTypeFilter]);
- // Document type counts for filter dropdown
- const documentTypeCounts = useMemo(() => {
- if (!documents) return {};
-
- const counts: Record = { ALL: documents.length };
- documents.forEach(doc => {
- counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
- });
- return counts;
- }, [documents]);
+ const paginatedDocuments = useMemo(() => {
+ const startIndex = (documentsPage - 1) * documentsPerPage;
+ return filteredDocuments.slice(startIndex, startIndex + documentsPerPage);
+ }, [filteredDocuments, documentsPage, documentsPerPage]);
- // Callback to handle document selection
- const handleDocumentToggle = useCallback((documentId: number) => {
- setSelectedDocuments(prev =>
- prev.includes(documentId)
- ? prev.filter(id => id !== documentId)
- : [...prev, documentId]
- );
- }, []);
+ const totalPages = Math.ceil(filteredDocuments.length / documentsPerPage);
- // Function to scroll terminal to bottom
- const scrollTerminalToBottom = () => {
- if (terminalMessagesRef.current) {
- terminalMessagesRef.current.scrollTop = terminalMessagesRef.current.scrollHeight;
- }
- };
+ // Document type counts for filter dropdown
+ const documentTypeCounts = useMemo(() => {
+ if (!documents) return {};
- // Get token from localStorage on client side only
- React.useEffect(() => {
- setToken(localStorage.getItem('surfsense_bearer_token'));
- }, []);
+ const counts: Record = { ALL: documents.length };
+ documents.forEach((doc) => {
+ counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
+ });
+ return counts;
+ }, [documents]);
- // Set the current time only on the client side after initial render
- useEffect(() => {
- setCurrentDate(new Date().toISOString().split('T')[0]);
- setCurrentTime(new Date().toTimeString().split(' ')[0]);
- }, []);
+ // Callback to handle document selection
+ const handleDocumentToggle = useCallback((documentId: number) => {
+ setSelectedDocuments((prev) =>
+ prev.includes(documentId)
+ ? prev.filter((id) => id !== documentId)
+ : [...prev, documentId],
+ );
+ }, []);
+ // Function to scroll terminal to bottom
+ const scrollTerminalToBottom = () => {
+ if (terminalMessagesRef.current) {
+ terminalMessagesRef.current.scrollTop =
+ terminalMessagesRef.current.scrollHeight;
+ }
+ };
+ // Get token from localStorage on client side only
+ React.useEffect(() => {
+ setToken(localStorage.getItem("surfsense_bearer_token"));
+ }, []);
- // Add this CSS to remove input shadow and improve the UI
- useEffect(() => {
- if (typeof document !== 'undefined') {
- const style = document.createElement('style');
- style.innerHTML = `
+ // Set the current time only on the client side after initial render
+ useEffect(() => {
+ setCurrentDate(new Date().toISOString().split("T")[0]);
+ setCurrentTime(new Date().toTimeString().split(" ")[0]);
+ }, []);
+
+ // Add this CSS to remove input shadow and improve the UI
+ useEffect(() => {
+ if (typeof document !== "undefined") {
+ const style = document.createElement("style");
+ style.innerHTML = `
.no-shadow-input {
box-shadow: none !important;
}
@@ -546,1122 +590,1362 @@ const ChatPage = () => {
overflow: hidden;
}
`;
- document.head.appendChild(style);
-
- return () => {
- document.head.removeChild(style);
- };
- }
- }, []);
-
- const { messages, input, handleInputChange, handleSubmit: handleChatSubmit, status, setMessages } = useChat({
- api: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chat`,
- streamProtocol: 'data',
- headers: {
- ...(token && { Authorization: `Bearer ${token}` }),
- },
- body: {
- data: {
- search_space_id: search_space_id,
- selected_connectors: selectedConnectors,
- research_mode: researchMode,
- search_mode: searchMode,
- document_ids_to_add_in_context: selectedDocuments
- }
- },
- onError: (error) => {
- console.error("Chat error:", error);
- // You can add additional error handling here if needed
- }
- });
-
- // Fetch chat details when component mounts
- useEffect(() => {
- const fetchChatDetails = async () => {
- try {
- if (!token) return; // Wait for token to be set
-
- // console.log('Fetching chat details for chat ID:', chat_id);
-
- const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chat_id)}`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
- }
- });
-
- if (!response.ok) {
- throw new Error(`Failed to fetch chat details: ${response.statusText}`);
- }
-
- const chatData = await response.json();
- // console.log('Chat details fetched:', chatData);
-
- // Set research mode from chat data
- if (chatData.type) {
- setResearchMode(chatData.type as ResearchMode);
- }
-
- // Set connectors from chat data
- if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
- setSelectedConnectors(chatData.initial_connectors);
- }
-
- // Set messages from chat data
- if (chatData.messages && Array.isArray(chatData.messages)) {
- setMessages(chatData.messages);
- }
- } catch (err) {
- console.error('Error fetching chat details:', err);
- }
- };
-
- if (token) {
- fetchChatDetails();
- }
- }, [token, chat_id, setMessages]);
-
- // Update chat when a conversation exchange is complete
- useEffect(() => {
- const updateChat = async () => {
- try {
- // Only update when:
- // 1. Status is ready (not loading)
- // 2. We have messages
- // 3. Last message is from assistant (completed response)
- if (
- status === 'ready' &&
- messages.length > 0 &&
- messages[messages.length - 1]?.role === 'assistant'
- ) {
- const token = localStorage.getItem('surfsense_bearer_token');
- if (!token) return;
-
- // Find the first user message to use as title
- const userMessages = messages.filter(msg => msg.role === 'user');
- if (userMessages.length === 0) return;
-
- // Use the first user message as the title
- const title = userMessages[0].content;
-
-
- // console.log('Updating chat with title:', title);
-
- // Update the chat
- const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chat_id)}`, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
- },
- body: JSON.stringify({
- type: researchMode,
- title: title,
- initial_connectors: selectedConnectors,
- messages: messages,
- search_space_id: Number(search_space_id)
- })
- });
-
- if (!response.ok) {
- throw new Error(`Failed to update chat: ${response.statusText}`);
- }
-
- // console.log('Chat updated successfully');
- }
- } catch (err) {
- console.error('Error updating chat:', err);
- }
- };
-
- updateChat();
- }, [messages, status, chat_id, researchMode, selectedConnectors, search_space_id]);
-
- // Check and scroll terminal when terminal info is available
- useEffect(() => {
- // Modified to trigger during streaming as well (removed status check)
- if (messages.length === 0) return;
-
- // Find the latest assistant message
- const assistantMessages = messages.filter(msg => msg.role === 'assistant');
- if (assistantMessages.length === 0) return;
-
- const latestAssistantMessage = assistantMessages[assistantMessages.length - 1];
- if (!latestAssistantMessage?.annotations) return;
-
- // Check for terminal info annotations
- const annotations = latestAssistantMessage.annotations as any[];
- const terminalInfoAnnotations = annotations.filter(a => a.type === 'TERMINAL_INFO');
-
- if (terminalInfoAnnotations.length > 0) {
- // Always scroll to bottom when terminal info is updated, even during streaming
- scrollTerminalToBottom();
- }
- }, [messages]); // Removed status from dependencies to ensure it triggers during streaming
-
- // Pure function to get connector sources for a specific message
- const getMessageConnectorSources = (message: any): any[] => {
- if (!message || message.role !== 'assistant' || !message.annotations) return [];
-
- // Find all SOURCES annotations
- const annotations = message.annotations as any[];
- const sourcesAnnotations = annotations.filter(a => a.type === 'SOURCES');
-
- // Get the latest SOURCES annotation
- if (sourcesAnnotations.length === 0) return [];
- const latestSourcesAnnotation = sourcesAnnotations[sourcesAnnotations.length - 1];
-
- if (!latestSourcesAnnotation.content) return [];
-
- return latestSourcesAnnotation.content;
- };
-
- // Custom handleSubmit function to include selected connectors and answer type
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!input.trim() || status !== 'ready') return;
-
- // Validation: require at least one connector OR at least one document
- // Note: Fast LLM selection updates user preferences automatically
- // if (selectedConnectors.length === 0 && selectedDocuments.length === 0) {
- // alert("Please select at least one connector or document");
- // return;
- // }
-
- // Call the original handleSubmit from useChat
- handleChatSubmit(e);
- };
-
- // Reference to the messages container for auto-scrolling
- const messagesEndRef = useRef(null);
-
- // Function to scroll to bottom
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- };
-
- // Scroll to bottom when messages change
- useEffect(() => {
- scrollToBottom();
- }, [messages]);
-
- // Reset sources page when new messages arrive
- useEffect(() => {
- // Reset pagination when we get new messages
- setSourcesPage(1);
- setExpandedSources(false);
- }, [messages]);
-
- // Scroll terminal to bottom when expanded
- useEffect(() => {
- if (terminalExpanded) {
- setTimeout(scrollTerminalToBottom, 300); // Wait for transition to complete
- }
- }, [terminalExpanded]);
-
- // Function to check scroll position and update indicators
- const updateScrollIndicators = () => {
- updateScrollIndicatorsUtil(tabsListRef as React.RefObject, setCanScrollLeft, setCanScrollRight);
- };
-
- // Initialize scroll indicators
- const updateIndicators = useScrollIndicators(
- tabsListRef as React.RefObject,
- setCanScrollLeft,
- setCanScrollRight
- );
-
- // Function to scroll tabs list left
- const scrollTabsLeft = () => {
- scrollTabsLeftUtil(tabsListRef as React.RefObject, updateIndicators);
- };
-
- // Function to scroll tabs list right
- const scrollTabsRight = () => {
- scrollTabsRightUtil(tabsListRef as React.RefObject, updateIndicators);
- };
-
- // Use the scroll to bottom hook
- useScrollToBottom(messagesEndRef as React.RefObject, [messages]);
-
- // Function to get a citation source by ID
- const getCitationSource = React.useCallback((citationId: number, messageIndex?: number): Source | null => {
- if (!messages || messages.length === 0) return null;
-
- // If no specific message index is provided, use the latest assistant message
- if (messageIndex === undefined) {
- // Find the latest assistant message
- const assistantMessages = messages.filter(msg => msg.role === 'assistant');
- if (assistantMessages.length === 0) return null;
-
- const latestAssistantMessage = assistantMessages[assistantMessages.length - 1];
-
- // Use our helper function to get sources
- const sources = getMessageConnectorSources(latestAssistantMessage);
- if (sources.length === 0) return null;
-
- // Flatten all sources from all connectors
- const allSources: Source[] = [];
- sources.forEach((connector: ConnectorSource) => {
- if (connector.sources && Array.isArray(connector.sources)) {
- connector.sources.forEach((source: SourceItem) => {
- allSources.push({
- id: source.id,
- title: source.title,
- description: source.description,
- url: source.url,
- connectorType: connector.type
- });
- });
- }
- });
-
- // Find the source with the matching ID
- const foundSource = allSources.find(source => source.id === citationId);
-
- return foundSource || null;
- } else {
- // Use the specific message by index
- const message = messages[messageIndex];
-
- // Use our helper function to get sources
- const sources = getMessageConnectorSources(message);
- if (sources.length === 0) return null;
-
- // Flatten all sources from all connectors
- const allSources: Source[] = [];
- sources.forEach((connector: ConnectorSource) => {
- if (connector.sources && Array.isArray(connector.sources)) {
- connector.sources.forEach((source: SourceItem) => {
- allSources.push({
- id: source.id,
- title: source.title,
- description: source.description,
- url: source.url,
- connectorType: connector.type
- });
- });
- }
- });
-
- // Find the source with the matching ID
- const foundSource = allSources.find(source => source.id === citationId);
-
- return foundSource || null;
- }
- }, [messages]);
-
- // Pure function for rendering terminal content - no hooks allowed here
- const renderTerminalContent = (message: any) => {
- if (!message.annotations) return null;
-
- // Get all TERMINAL_INFO annotations
- const terminalInfoAnnotations = (message.annotations as any[])
- .filter(a => a.type === 'TERMINAL_INFO');
-
- // Get the latest TERMINAL_INFO annotation
- const latestTerminalInfo = terminalInfoAnnotations.length > 0
- ? terminalInfoAnnotations[terminalInfoAnnotations.length - 1]
- : null;
-
- // Render the content of the latest TERMINAL_INFO annotation
- return latestTerminalInfo?.content.map((item: any, idx: number) => (
-
- [{String(idx).padStart(2, '0')}:{String(Math.floor(idx * 2)).padStart(2, '0')}]
- {'>'}
- {item.text}
-
- ));
- };
-
- return (
- <>
-
- {messages.length === 0 && (
-
-
-
-
- )}
- {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
index fd42ca648..7842f6ae6 100644
--- a/surfsense_web/components/copy-button.tsx
+++ b/surfsense_web/components/copy-button.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Button } from "./ui/button";
import { Copy, CopyCheck } from "lucide-react";
@@ -10,6 +10,15 @@ export default function CopyButton({
ref: RefObject;
}) {
const [copy, setCopy] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
const handleClick = () => {
if (ref.current) {
@@ -17,9 +26,9 @@ export default function CopyButton({
navigator.clipboard.writeText(text);
setCopy(true);
- setTimeout(() => {
+ timeoutRef.current = setTimeout(() => {
setCopy(false);
- }, 500);
+ }, 2000);
}
};