diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 7550d7c8c..f5cfff02a 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -138,6 +138,7 @@ interface ChatBoxProps { selectedSources?: string[], agentId?: string | null, toolsList?: ToolsListItem[], + enableWebSearch?: boolean, ) => void // Expects agentId string and optional fileIds isStreaming?: boolean retryIsStreaming?: boolean @@ -262,2345 +263,2423 @@ export interface ChatBoxRef { sendMessage: (message: string) => void } -export const ChatBox = React.forwardRef((props, ref) => { - const { - role, - query, - setQuery, - handleSend, - isStreaming = false, - retryIsStreaming = false, - allCitations, - handleStop, - chatId, - agentIdFromChatData, // Destructure new prop - isReasoningActive, - setIsReasoningActive, - user, // Destructure user prop - setIsAgenticMode, - isAgenticMode = false, - overrideIsRagOn, - } = props - // Interface for fetched tools - interface FetchedTool { - id: number - workspaceId: number - connectorId: number - toolName: string - toolSchema: string // Assuming schema is a JSON string - description: string | null - enabled: boolean // Added enabled field - createdAt: string - updatedAt: string - externalId: string // This is the externalId from the backend - } +export const ChatBox = React.forwardRef( + (props, ref) => { + const { + role, + query, + setQuery, + handleSend, + isStreaming = false, + retryIsStreaming = false, + allCitations, + handleStop, + chatId, + agentIdFromChatData, // Destructure new prop + isReasoningActive, + setIsReasoningActive, + user, // Destructure user prop + setIsAgenticMode, + isAgenticMode = false, + overrideIsRagOn, + } = props + // Interface for fetched tools + interface FetchedTool { + id: number + workspaceId: number + connectorId: number + toolName: string + toolSchema: string // Assuming schema is a JSON string + description: string | null + enabled: boolean // Added enabled field + createdAt: string + updatedAt: string + externalId: string // This is the externalId from the backend + } - // Interface for fetched connectors - interface FetchedConnector { - id: string // externalId from backend - app: Apps | string - authType: AuthType | string - type: ConnectorType | string - status: ConnectorStatus | string - createdAt: string // Assuming ISO string date - config: Record - connectorId: number // internal DB id - displayName?: string // For UI - } + // Interface for fetched connectors + interface FetchedConnector { + id: string // externalId from backend + app: Apps | string + authType: AuthType | string + type: ConnectorType | string + status: ConnectorStatus | string + createdAt: string // Assuming ISO string date + config: Record + connectorId: number // internal DB id + displayName?: string // For UI + } - const { toast } = useToast() - const inputRef = useRef(null) - const referenceBoxRef = useRef(null) - const referenceItemsRef = useRef< - (HTMLDivElement | HTMLButtonElement | null)[] - >([]) - const scrollContainerRef = useRef(null) - const referenceSearchInputRef = useRef(null) - const debounceTimeout = useRef(null) - const scrollPositionRef = useRef(0) - const navigate = useNavigate() - const fileInputRef = useRef(null) - - const [showReferenceBox, setShowReferenceBox] = useState(false) - const [searchMode, setSearchMode] = useState<"citations" | "global">( - "citations", - ) - const [globalResults, setGlobalResults] = useState([]) - - // Unified function to enhance Google Sheets items with dummy "whole sheet" options - const enhanceGoogleSheetsResults = useCallback( - < - T extends { - app?: string - entity?: string - docId: string - title?: string - name?: string - subject?: string - filename?: string - }, - >( - items: T[], - ): (T & { isWholeSheetDummy?: boolean })[] => { - const enhanced: (T & { isWholeSheetDummy?: boolean })[] = [] - const seenWholeSheets = new Set() - - items.forEach((item) => { - // If this is a Google Sheet with a specific tab (contains " / " in title) - const isGoogleSheet = - item.app === Apps.GoogleDrive && item.entity === DriveEntity.Sheets - if (isGoogleSheet) { - const displayTitle = - item.title || - item.name || - item.subject || - item.filename || - "Untitled" - const isSpecificSheetTab = displayTitle.includes(" / ") - - if (isSpecificSheetTab) { - // Extract the spreadsheet name (before " / ") - const sheetName = displayTitle.split(" / ")[0] - - // Extract the base docId (remove the "_X" suffix) - const baseDocId = item.docId.replace(/_\d+$/, "") - - // Only add the whole sheet dummy if we haven't seen this spreadsheet yet - if (!seenWholeSheets.has(baseDocId)) { - seenWholeSheets.add(baseDocId) - - // Create a dummy item for the whole sheet and add it BEFORE the specific tab - const wholeSheetItem: T & { isWholeSheetDummy?: boolean } = { - ...item, - docId: baseDocId, - title: sheetName, - ...(item.name !== undefined && { name: sheetName }), - isWholeSheetDummy: true, - } + const { toast } = useToast() + const inputRef = useRef(null) + const referenceBoxRef = useRef(null) + const referenceItemsRef = useRef< + (HTMLDivElement | HTMLButtonElement | null)[] + >([]) + const scrollContainerRef = useRef(null) + const referenceSearchInputRef = useRef(null) + const debounceTimeout = useRef(null) + const scrollPositionRef = useRef(0) + const navigate = useNavigate() + const fileInputRef = useRef(null) + + const [showReferenceBox, setShowReferenceBox] = useState(false) + const [searchMode, setSearchMode] = useState<"citations" | "global">( + "citations", + ) + const [globalResults, setGlobalResults] = useState([]) + + // Unified function to enhance Google Sheets items with dummy "whole sheet" options + const enhanceGoogleSheetsResults = useCallback( + < + T extends { + app?: string + entity?: string + docId: string + title?: string + name?: string + subject?: string + filename?: string + }, + >( + items: T[], + ): (T & { isWholeSheetDummy?: boolean })[] => { + const enhanced: (T & { isWholeSheetDummy?: boolean })[] = [] + const seenWholeSheets = new Set() + + items.forEach((item) => { + // If this is a Google Sheet with a specific tab (contains " / " in title) + const isGoogleSheet = + item.app === Apps.GoogleDrive && item.entity === DriveEntity.Sheets + if (isGoogleSheet) { + const displayTitle = + item.title || + item.name || + item.subject || + item.filename || + "Untitled" + const isSpecificSheetTab = displayTitle.includes(" / ") + + if (isSpecificSheetTab) { + // Extract the spreadsheet name (before " / ") + const sheetName = displayTitle.split(" / ")[0] + + // Extract the base docId (remove the "_X" suffix) + const baseDocId = item.docId.replace(/_\d+$/, "") + + // Only add the whole sheet dummy if we haven't seen this spreadsheet yet + if (!seenWholeSheets.has(baseDocId)) { + seenWholeSheets.add(baseDocId) + + // Create a dummy item for the whole sheet and add it BEFORE the specific tab + const wholeSheetItem: T & { isWholeSheetDummy?: boolean } = { + ...item, + docId: baseDocId, + title: sheetName, + ...(item.name !== undefined && { name: sheetName }), + isWholeSheetDummy: true, + } - // Insert the whole sheet option BEFORE the current item - enhanced.push(wholeSheetItem) + // Insert the whole sheet option BEFORE the current item + enhanced.push(wholeSheetItem) + } } } - } - // Add the original item after checking for whole sheet - enhanced.push(item) - }) + // Add the original item after checking for whole sheet + enhanced.push(item) + }) - return enhanced - }, - [], - ) - - // Create enhanced results that include dummy "whole sheet" options for specific sheet tabs - const enhancedGlobalResults: (SearchResult & { - isWholeSheetDummy?: boolean - })[] = useMemo(() => { - return enhanceGoogleSheetsResults(globalResults) - }, [globalResults, enhanceGoogleSheetsResults]) - - const [selectedRefIndex, setSelectedRefIndex] = useState(-1) - const [selectedSources, setSelectedSources] = useState< - Record - >({}) - const [isSourceMenuOpen, setIsSourceMenuOpen] = useState(false) - const [isGlobalLoading, setIsGlobalLoading] = useState(false) - const [globalError, setGlobalError] = useState(null) - const [page, setPage] = useState(1) - const [totalCount, setTotalCount] = useState(0) - const [activeAtMentionIndex, setActiveAtMentionIndex] = useState(-1) - const [referenceSearchTerm, setReferenceSearchTerm] = useState("") - const [referenceBoxLeft, setReferenceBoxLeft] = useState(0) - const [isPlaceholderVisible, setIsPlaceholderVisible] = useState(true) - const [showSourcesButton, _] = useState(false) // Added this line - const [persistedAgentId, setPersistedAgentId] = useState(null) - const [displayAgentName, setDisplayAgentName] = useState(null) - const [selectedAgent, setSelectedAgent] = useState( - null, - ) - const [allConnectors, setAllConnectors] = useState([]) - const [selectedConnectorIds, setSelectedConnectorIds] = useState>( - new Set(), - ) - const [isConnectorsMenuOpen, setIsConnectorsMenuOpen] = useState(false) - const [connectorTools, setConnectorTools] = useState([]) - const [isLoadingTools, setIsLoadingTools] = useState(false) - const [isToolSelectionModalOpen, setIsToolSelectionModalOpen] = - useState(false) - const [toolSearchTerm, setToolSearchTerm] = useState("") - const [activeToolConnectorId, setActiveToolConnectorId] = useState< - string | null - >(null) // Track which connector's tools are being shown - const connectorsDropdownTriggerRef = useRef(null) - const toolModalRef = useRef(null) // Ref for the tool modal itself - const [toolModalPosition, setToolModalPosition] = useState<{ - top: number - left: number - } | null>(null) - const [initialLoadComplete, setInitialLoadComplete] = useState(false) - const [selectedFiles, setSelectedFiles] = useState([]) - const [isUploadingFiles, setIsUploadingFiles] = useState(false) - const showAdvancedOptions = - overrideIsRagOn ?? - (!selectedAgent || (selectedAgent && selectedAgent.isRagOn)) - - // localStorage keys for tool selection persistence - const SELECTED_CONNECTOR_TOOLS_KEY = "selectedConnectorTools" - const SELECTED_MCP_CONNECTOR_ID_KEY = "selectedMcpConnectorId" - - // File upload utility functions - const showToast = createToastNotifier(toast) - - const processFiles = useCallback( - (files: FileList | File[]) => { - // Check attachment limit - if (selectedFiles.length >= MAX_ATTACHMENTS) { - showToast( - "Attachment limit reached", - `You can only attach up to ${MAX_ATTACHMENTS} files at a time.`, - true, - ) - return - } + return enhanced + }, + [], + ) - const validFiles = validateAndDeduplicateFiles(files, showToast) - if (validFiles.length === 0) return + // Create enhanced results that include dummy "whole sheet" options for specific sheet tabs + const enhancedGlobalResults: (SearchResult & { + isWholeSheetDummy?: boolean + })[] = useMemo(() => { + return enhanceGoogleSheetsResults(globalResults) + }, [globalResults, enhanceGoogleSheetsResults]) + + const [selectedRefIndex, setSelectedRefIndex] = useState(-1) + const [selectedSources, setSelectedSources] = useState< + Record + >({}) + const [isSourceMenuOpen, setIsSourceMenuOpen] = useState(false) + const [isGlobalLoading, setIsGlobalLoading] = useState(false) + const [globalError, setGlobalError] = useState(null) + const [page, setPage] = useState(1) + const [totalCount, setTotalCount] = useState(0) + const [activeAtMentionIndex, setActiveAtMentionIndex] = useState(-1) + const [referenceSearchTerm, setReferenceSearchTerm] = useState("") + const [referenceBoxLeft, setReferenceBoxLeft] = useState(0) + const [isPlaceholderVisible, setIsPlaceholderVisible] = useState(true) + const [showSourcesButton, _] = useState(false) // Added this line + const [persistedAgentId, setPersistedAgentId] = useState( + null, + ) + const [displayAgentName, setDisplayAgentName] = useState( + null, + ) + const [selectedAgent, setSelectedAgent] = + useState(null) + const [allConnectors, setAllConnectors] = useState([]) + const [selectedConnectorIds, setSelectedConnectorIds] = useState< + Set + >(new Set()) + const [isConnectorsMenuOpen, setIsConnectorsMenuOpen] = useState(false) + const [connectorTools, setConnectorTools] = useState([]) + const [isLoadingTools, setIsLoadingTools] = useState(false) + const [isToolSelectionModalOpen, setIsToolSelectionModalOpen] = + useState(false) + const [toolSearchTerm, setToolSearchTerm] = useState("") + const [activeToolConnectorId, setActiveToolConnectorId] = useState< + string | null + >(null) // Track which connector's tools are being shown + const connectorsDropdownTriggerRef = useRef(null) + const toolModalRef = useRef(null) // Ref for the tool modal itself + const [toolModalPosition, setToolModalPosition] = useState<{ + top: number + left: number + } | null>(null) + const [initialLoadComplete, setInitialLoadComplete] = useState(false) + const [selectedFiles, setSelectedFiles] = useState([]) + const [isUploadingFiles, setIsUploadingFiles] = useState(false) + const [enableWebSearch, setEnableWebSearch] = useState(() => { + const saved = localStorage.getItem("enableWebSearch") + return saved !== null ? JSON.parse(saved) : false + }) + const showAdvancedOptions = + overrideIsRagOn ?? + (!selectedAgent || (selectedAgent && selectedAgent.isRagOn)) - // Check if adding these files would exceed the limit - const remainingSlots = MAX_ATTACHMENTS - selectedFiles.length - const filesToAdd = validFiles.slice(0, remainingSlots) + // Persist enableWebSearch state to localStorage when it changes + useEffect(() => { + localStorage.setItem("enableWebSearch", JSON.stringify(enableWebSearch)) + }, [enableWebSearch]) - if (filesToAdd.length < validFiles.length) { - showToast( - "Some files skipped", - `Only ${filesToAdd.length} of ${validFiles.length} files were added due to attachment limit.`, - false, - ) - } + // localStorage keys for tool selection persistence + const SELECTED_CONNECTOR_TOOLS_KEY = "selectedConnectorTools" + const SELECTED_MCP_CONNECTOR_ID_KEY = "selectedMcpConnectorId" - const newFiles: SelectedFile[] = filesToAdd.map((file) => ({ - file, - id: generateFileId(), - uploading: false, - preview: createImagePreview(file), - })) + // File upload utility functions + const showToast = createToastNotifier(toast) - setSelectedFiles((prev) => { - const existingFileNames = new Set(prev.map((f) => f.file.name)) - const filteredNewFiles = newFiles.filter( - (f) => !existingFileNames.has(f.file.name), - ) + const processFiles = useCallback( + (files: FileList | File[]) => { + // Check attachment limit + if (selectedFiles.length >= MAX_ATTACHMENTS) { + showToast( + "Attachment limit reached", + `You can only attach up to ${MAX_ATTACHMENTS} files at a time.`, + true, + ) + return + } - const filteredCount = newFiles.length - filteredNewFiles.length - if (filteredCount > 0) { + const validFiles = validateAndDeduplicateFiles(files, showToast) + if (validFiles.length === 0) return + + // Check if adding these files would exceed the limit + const remainingSlots = MAX_ATTACHMENTS - selectedFiles.length + const filesToAdd = validFiles.slice(0, remainingSlots) + + if (filesToAdd.length < validFiles.length) { showToast( - "Files already selected", - `${filteredCount} file(s) were already selected and skipped.`, + "Some files skipped", + `Only ${filesToAdd.length} of ${validFiles.length} files were added due to attachment limit.`, false, ) } - return [...prev, ...filteredNewFiles] - }) - }, - [showToast, selectedFiles.length], - ) - - const uploadFiles = useCallback( - async (files: SelectedFile[]) => { - if (files.length === 0) return [] - - setIsUploadingFiles(true) - const uploadedMetadata: AttachmentMetadata[] = [] - - // Set all files to uploading state - setSelectedFiles((prev) => - prev.map((f) => - files.some((file) => file.id === f.id) - ? { ...f, uploading: true, uploadError: undefined } - : f, - ), - ) + const newFiles: SelectedFile[] = filesToAdd.map((file) => ({ + file, + id: generateFileId(), + uploading: false, + preview: createImagePreview(file), + })) + + setSelectedFiles((prev) => { + const existingFileNames = new Set(prev.map((f) => f.file.name)) + const filteredNewFiles = newFiles.filter( + (f) => !existingFileNames.has(f.file.name), + ) - const uploadPromises = files.map(async (selectedFile) => { - try { - const formData = new FormData() - formData.append("attachment", selectedFile.file) - - // Use the new attachment upload endpoint - const response = await authFetch("/api/v1/files/upload-attachment", { - method: "POST", - body: formData, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || "Upload failed") + const filteredCount = newFiles.length - filteredNewFiles.length + if (filteredCount > 0) { + showToast( + "Files already selected", + `${filteredCount} file(s) were already selected and skipped.`, + false, + ) } - const result = await response.json() - const metadata = result.attachments?.[0] + return [...prev, ...filteredNewFiles] + }) + }, + [showToast, selectedFiles.length], + ) + + const uploadFiles = useCallback( + async (files: SelectedFile[]) => { + if (files.length === 0) return [] + + setIsUploadingFiles(true) + const uploadedMetadata: AttachmentMetadata[] = [] + + // Set all files to uploading state + setSelectedFiles((prev) => + prev.map((f) => + files.some((file) => file.id === f.id) + ? { ...f, uploading: true, uploadError: undefined } + : f, + ), + ) + + const uploadPromises = files.map(async (selectedFile) => { + try { + const formData = new FormData() + formData.append("attachment", selectedFile.file) + + // Use the new attachment upload endpoint + const response = await authFetch( + "/api/v1/files/upload-attachment", + { + method: "POST", + body: formData, + }, + ) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || "Upload failed") + } + + const result = await response.json() + const metadata = result.attachments?.[0] - if (metadata) { + if (metadata) { + setSelectedFiles((prev) => + prev.map((f) => + f.id === selectedFile.id + ? { ...f, uploading: false, metadata } + : f, + ), + ) + return metadata + } else { + throw new Error("No document ID returned from upload") + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Upload failed" setSelectedFiles((prev) => prev.map((f) => f.id === selectedFile.id - ? { ...f, uploading: false, metadata } + ? { ...f, uploading: false, uploadError: errorMessage } : f, ), ) - return metadata - } else { - throw new Error("No document ID returned from upload") + showToast( + "Upload failed", + `Failed to upload ${selectedFile.file.name}: ${errorMessage}`, + true, + ) + return null } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Upload failed" - setSelectedFiles((prev) => - prev.map((f) => - f.id === selectedFile.id - ? { ...f, uploading: false, uploadError: errorMessage } - : f, - ), - ) - showToast( - "Upload failed", - `Failed to upload ${selectedFile.file.name}: ${errorMessage}`, - true, - ) - return null - } - }) - - const results = await Promise.all(uploadPromises) - uploadedMetadata.push( - ...results.filter( - (metadata): metadata is AttachmentMetadata => metadata !== null, - ), - ) + }) - setIsUploadingFiles(false) - return uploadedMetadata - }, - [showToast], - ) + const results = await Promise.all(uploadPromises) + uploadedMetadata.push( + ...results.filter( + (metadata): metadata is AttachmentMetadata => metadata !== null, + ), + ) - const getExtension = (file: File) => { - const name = file.name - const ext = name.includes(".") ? name.split(".").pop()?.toLowerCase() : null - return ext || "file" - } + setIsUploadingFiles(false) + return uploadedMetadata + }, + [showToast], + ) - const removeFile = useCallback((id: string) => { - setSelectedFiles((prev) => { - const fileToRemove = prev.find((f) => f.id === id) - if (fileToRemove?.preview) { - URL.revokeObjectURL(fileToRemove.preview) - } - return prev.filter((f) => f.id !== id) - }) - }, []) + const getExtension = (file: File) => { + const name = file.name + const ext = name.includes(".") + ? name.split(".").pop()?.toLowerCase() + : null + return ext || "file" + } - const { handleFileSelect, handleFileChange } = createFileSelectionHandlers( - fileInputRef, - processFiles, - ) + const removeFile = useCallback((id: string) => { + setSelectedFiles((prev) => { + const fileToRemove = prev.find((f) => f.id === id) + if (fileToRemove?.preview) { + URL.revokeObjectURL(fileToRemove.preview) + } + return prev.filter((f) => f.id !== id) + }) + }, []) - const { handleDragOver, handleDragLeave } = createDragHandlers() + const { handleFileSelect, handleFileChange } = createFileSelectionHandlers( + fileInputRef, + processFiles, + ) - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - const files = Array.from(e.dataTransfer.files) - if (files.length > 0) { - // Check attachment limit before processing - if (selectedFiles.length >= MAX_ATTACHMENTS) { - showToast( - "Attachment limit reached", - `You can only attach up to ${MAX_ATTACHMENTS} files at a time.`, - true, - ) - return + const { handleDragOver, handleDragLeave } = createDragHandlers() + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) { + // Check attachment limit before processing + if (selectedFiles.length >= MAX_ATTACHMENTS) { + showToast( + "Attachment limit reached", + `You can only attach up to ${MAX_ATTACHMENTS} files at a time.`, + true, + ) + return + } + processFiles(files) } - processFiles(files) + }, + [processFiles, selectedFiles.length, showToast], + ) + + // Effect to initialize and update persistedAgentId + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search) + const agentIdFromUrl = searchParams.get("agentId") + + if (agentIdFromUrl) { + setPersistedAgentId(agentIdFromUrl) + } else if (agentIdFromChatData) { + setPersistedAgentId(agentIdFromChatData) + } else { + setPersistedAgentId(null) } - }, - [processFiles, selectedFiles.length, showToast], - ) - - // Effect to initialize and update persistedAgentId - useEffect(() => { - const searchParams = new URLSearchParams(window.location.search) - const agentIdFromUrl = searchParams.get("agentId") - - if (agentIdFromUrl) { - setPersistedAgentId(agentIdFromUrl) - } else if (agentIdFromChatData) { - setPersistedAgentId(agentIdFromChatData) - } else { - setPersistedAgentId(null) - } - // This effect should run when chatId changes (indicating a new chat context), - // when agentIdFromChatData changes (new chat data loaded), - // or when the component initially loads. - }, [chatId, agentIdFromChatData]) - - // Effect to fetch agent details for display when persistedAgentId is set - useEffect(() => { - const fetchAgentDetails = async () => { - if (persistedAgentId) { - try { - const response = await api.agents.$get() // Fetch all agents - if (response.ok) { - const allAgents = (await response.json()) as SelectPublicAgent[] - const currentAgent = allAgents.find( - (agent) => agent.externalId === persistedAgentId, - ) - if (currentAgent) { - setDisplayAgentName(currentAgent.name) - setSelectedAgent(currentAgent) - } else { - console.error( - `Agent with ID ${persistedAgentId} not found for display.`, + // This effect should run when chatId changes (indicating a new chat context), + // when agentIdFromChatData changes (new chat data loaded), + // or when the component initially loads. + }, [chatId, agentIdFromChatData]) + + // Effect to fetch agent details for display when persistedAgentId is set + useEffect(() => { + const fetchAgentDetails = async () => { + if (persistedAgentId) { + try { + const response = await api.agents.$get() // Fetch all agents + if (response.ok) { + const allAgents = (await response.json()) as SelectPublicAgent[] + const currentAgent = allAgents.find( + (agent) => agent.externalId === persistedAgentId, ) + if (currentAgent) { + setDisplayAgentName(currentAgent.name) + setSelectedAgent(currentAgent) + } else { + console.error( + `Agent with ID ${persistedAgentId} not found for display.`, + ) + setDisplayAgentName(null) + setSelectedAgent(null) + } + } else { + console.error("Failed to load agents for display.") setDisplayAgentName(null) setSelectedAgent(null) } - } else { - console.error("Failed to load agents for display.") + } catch (error) { + console.error("Error fetching agent details for display:", error) setDisplayAgentName(null) setSelectedAgent(null) } - } catch (error) { - console.error("Error fetching agent details for display:", error) - setDisplayAgentName(null) + } else { + setDisplayAgentName(null) // Clear display name if no persistedAgentId setSelectedAgent(null) } - } else { - setDisplayAgentName(null) // Clear display name if no persistedAgentId - setSelectedAgent(null) } - } - fetchAgentDetails() - }, [persistedAgentId]) // Depend on persistedAgentId - - const loadToolSelectionsFromStorage = (): Record> => { - try { - const stored = localStorage.getItem(SELECTED_CONNECTOR_TOOLS_KEY) - if (stored) { - const parsed = JSON.parse(stored) - // Convert arrays back to Sets - const result: Record> = {} - for (const [connectorId, toolNames] of Object.entries(parsed)) { - if (Array.isArray(toolNames)) { - result[connectorId] = new Set(toolNames as string[]) + fetchAgentDetails() + }, [persistedAgentId]) // Depend on persistedAgentId + + const loadToolSelectionsFromStorage = (): Record> => { + try { + const stored = localStorage.getItem(SELECTED_CONNECTOR_TOOLS_KEY) + if (stored) { + const parsed = JSON.parse(stored) + // Convert arrays back to Sets + const result: Record> = {} + for (const [connectorId, toolNames] of Object.entries(parsed)) { + if (Array.isArray(toolNames)) { + result[connectorId] = new Set(toolNames as string[]) + } } + return result } - return result + } catch (error) { + console.warn("Failed to load tool selections from localStorage:", error) } - } catch (error) { - console.warn("Failed to load tool selections from localStorage:", error) + return {} } - return {} - } - const saveToolSelectionsToStorage = ( - selections: Record>, - ) => { - try { - // Convert Sets to arrays for JSON serialization - const serializable: Record = {} - for (const [connectorId, toolNames] of Object.entries(selections)) { - serializable[connectorId] = Array.from(toolNames) + const saveToolSelectionsToStorage = ( + selections: Record>, + ) => { + try { + // Convert Sets to arrays for JSON serialization + const serializable: Record = {} + for (const [connectorId, toolNames] of Object.entries(selections)) { + serializable[connectorId] = Array.from(toolNames) + } + localStorage.setItem( + SELECTED_CONNECTOR_TOOLS_KEY, + JSON.stringify(serializable), + ) + } catch (error) { + console.warn("Failed to save tool selections to localStorage:", error) } - localStorage.setItem( - SELECTED_CONNECTOR_TOOLS_KEY, - JSON.stringify(serializable), - ) - } catch (error) { - console.warn("Failed to save tool selections to localStorage:", error) } - } - // Initialize selectedConnectorTools with data from localStorage - const [selectedConnectorTools, setSelectedConnectorTools] = useState< - Record> - >(loadToolSelectionsFromStorage) - - // Persist tool selections to localStorage whenever they change - useEffect(() => { - saveToolSelectionsToStorage(selectedConnectorTools) - }, [selectedConnectorTools]) - - // Local state for isReasoningActive and its localStorage effect are removed. Props will be used. - - useEffect(() => { - // Effect to adjust tool modal position based on its height - if ( - isToolSelectionModalOpen && - toolModalRef.current && - connectorsDropdownTriggerRef.current - ) { - const modalHeight = toolModalRef.current.offsetHeight - - const triggerRect = - connectorsDropdownTriggerRef.current.getBoundingClientRect() - const chatBoxContainer = inputRef.current?.closest( - ".relative.flex.flex-col.w-full", - ) as HTMLElement | null - const connectorDropdownWidth = 288 // Based on w-72 class (18rem * 16px/rem) - const gap = 8 // 8px gap - - let newLeftCalculation: number - let topReferenceForModalBottom: number - - if (chatBoxContainer) { - const containerRect = chatBoxContainer.getBoundingClientRect() - newLeftCalculation = - triggerRect.left - containerRect.left + connectorDropdownWidth + gap - topReferenceForModalBottom = triggerRect.top - containerRect.top - } else { - // Fallback if chatBoxContainer is not found (less likely but good for robustness) - newLeftCalculation = triggerRect.left + connectorDropdownWidth + gap - topReferenceForModalBottom = triggerRect.top - } + // Initialize selectedConnectorTools with data from localStorage + const [selectedConnectorTools, setSelectedConnectorTools] = useState< + Record> + >(loadToolSelectionsFromStorage) - const newModalTop = topReferenceForModalBottom - modalHeight + // Persist tool selections to localStorage whenever they change + useEffect(() => { + saveToolSelectionsToStorage(selectedConnectorTools) + }, [selectedConnectorTools]) - setToolModalPosition((currentPosition) => { - // Check if the position actually needs updating to prevent infinite loops - if ( - currentPosition && - currentPosition.top === newModalTop && - currentPosition.left === newLeftCalculation - ) { - return currentPosition - } - return { top: newModalTop, left: newLeftCalculation } - }) - } - }, [ - isToolSelectionModalOpen, - isLoadingTools, - connectorTools, - toolSearchTerm, - // Intentionally not including toolModalPosition here to avoid loops, - // as this effect is responsible for calculating the definitive position. - // It re-runs when factors affecting modal height change. - ]) - - useEffect(() => { - const loadInitialData = async () => { - let processedConnectors: FetchedConnector[] = [] - try { - // Role-based API routing - const isAdmin = role === UserRole.Admin || role === UserRole.SuperAdmin - - const response = isAdmin - ? await api.admin.connectors.all.$get(undefined, { - credentials: "include", - }) - : await api.connectors.all.$get(undefined, { - credentials: "include", - }) - const data = await response.json() - if (Array.isArray(data)) { - processedConnectors = data.map((conn: any) => ({ - ...conn, - displayName: conn.name || conn.config?.name || conn.app || conn.id, - })) + // Local state for isReasoningActive and its localStorage effect are removed. Props will be used. + + useEffect(() => { + // Effect to adjust tool modal position based on its height + if ( + isToolSelectionModalOpen && + toolModalRef.current && + connectorsDropdownTriggerRef.current + ) { + const modalHeight = toolModalRef.current.offsetHeight + + const triggerRect = + connectorsDropdownTriggerRef.current.getBoundingClientRect() + const chatBoxContainer = inputRef.current?.closest( + ".relative.flex.flex-col.w-full", + ) as HTMLElement | null + const connectorDropdownWidth = 288 // Based on w-72 class (18rem * 16px/rem) + const gap = 8 // 8px gap + + let newLeftCalculation: number + let topReferenceForModalBottom: number + + if (chatBoxContainer) { + const containerRect = chatBoxContainer.getBoundingClientRect() + newLeftCalculation = + triggerRect.left - containerRect.left + connectorDropdownWidth + gap + topReferenceForModalBottom = triggerRect.top - containerRect.top } else { - console.error("Fetched connectors data is not an array:", data) + // Fallback if chatBoxContainer is not found (less likely but good for robustness) + newLeftCalculation = triggerRect.left + connectorDropdownWidth + gap + topReferenceForModalBottom = triggerRect.top } - } catch (error) { - console.error("Error fetching connectors:", error) + + const newModalTop = topReferenceForModalBottom - modalHeight + + setToolModalPosition((currentPosition) => { + // Check if the position actually needs updating to prevent infinite loops + if ( + currentPosition && + currentPosition.top === newModalTop && + currentPosition.left === newLeftCalculation + ) { + return currentPosition + } + return { top: newModalTop, left: newLeftCalculation } + }) } + }, [ + isToolSelectionModalOpen, + isLoadingTools, + connectorTools, + toolSearchTerm, + // Intentionally not including toolModalPosition here to avoid loops, + // as this effect is responsible for calculating the definitive position. + // It re-runs when factors affecting modal height change. + ]) + + useEffect(() => { + const loadInitialData = async () => { + let processedConnectors: FetchedConnector[] = [] + try { + // Role-based API routing + const isAdmin = + role === UserRole.Admin || role === UserRole.SuperAdmin + + const response = isAdmin + ? await api.admin.connectors.all.$get(undefined, { + credentials: "include", + }) + : await api.connectors.all.$get(undefined, { + credentials: "include", + }) + const data = await response.json() + if (Array.isArray(data)) { + processedConnectors = data.map((conn: any) => ({ + ...conn, + displayName: + conn.name || conn.config?.name || conn.app || conn.id, + })) + } else { + console.error("Fetched connectors data is not an array:", data) + } + } catch (error) { + console.error("Error fetching connectors:", error) + } - setAllConnectors(processedConnectors) + setAllConnectors(processedConnectors) - const storedMcpId = localStorage.getItem(SELECTED_MCP_CONNECTOR_ID_KEY) - if (storedMcpId && processedConnectors.length > 0) { - const connectorExists = processedConnectors.find( - (c) => c.id === storedMcpId && c.type === ConnectorType.MCP, - ) - if (connectorExists) { - setSelectedConnectorIds(new Set([storedMcpId])) - } else { - // If stored ID is invalid (not found or not MCP), remove it. - localStorage.removeItem(SELECTED_MCP_CONNECTOR_ID_KEY) + const storedMcpId = localStorage.getItem(SELECTED_MCP_CONNECTOR_ID_KEY) + if (storedMcpId && processedConnectors.length > 0) { + const connectorExists = processedConnectors.find( + (c) => c.id === storedMcpId && c.type === ConnectorType.MCP, + ) + if (connectorExists) { + setSelectedConnectorIds(new Set([storedMcpId])) + } else { + // If stored ID is invalid (not found or not MCP), remove it. + localStorage.removeItem(SELECTED_MCP_CONNECTOR_ID_KEY) + } } + setInitialLoadComplete(true) // Mark initial load as complete } - setInitialLoadComplete(true) // Mark initial load as complete - } - loadInitialData() - }, [role]) // Added role dependency + loadInitialData() + }, [role]) // Added role dependency - // useEffect to save selected MCP connector ID - useEffect(() => { - if (!initialLoadComplete) { - // Don't run save logic during initial load phase - return - } + // useEffect to save selected MCP connector ID + useEffect(() => { + if (!initialLoadComplete) { + // Don't run save logic during initial load phase + return + } - // Find any MCP connector in the selected set - const mcpConnectorId = Array.from(selectedConnectorIds).find( - (connectorId) => { - if (allConnectors.length > 0) { - const connector = allConnectors.find((c) => c.id === connectorId) - return connector && connector.type === ConnectorType.MCP - } - return false - }, - ) + // Find any MCP connector in the selected set + const mcpConnectorId = Array.from(selectedConnectorIds).find( + (connectorId) => { + if (allConnectors.length > 0) { + const connector = allConnectors.find((c) => c.id === connectorId) + return connector && connector.type === ConnectorType.MCP + } + return false + }, + ) - if (mcpConnectorId) { - localStorage.setItem(SELECTED_MCP_CONNECTOR_ID_KEY, mcpConnectorId) - } else { - // If no MCP connector is selected - localStorage.removeItem(SELECTED_MCP_CONNECTOR_ID_KEY) - } - }, [selectedConnectorIds, allConnectors, initialLoadComplete]) - - const adjustInputHeight = useCallback(() => { - if (inputRef.current) { - inputRef.current.style.height = "auto" - const scrollHeight = inputRef.current.scrollHeight - const minHeight = 52 - const maxHeight = 320 - const newHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight)) - inputRef.current.style.height = `${newHeight}px` - } - }, []) - - const updateReferenceBoxPosition = (atIndex: number) => { - const inputElement = inputRef.current - if (!inputElement || atIndex < 0) { - const parentRect = inputElement - ?.closest(`.${CLASS_NAMES.SEARCH_CONTAINER} > .relative.flex.flex-col`) - ?.getBoundingClientRect() - const inputRect = inputElement?.getBoundingClientRect() - if (parentRect && inputRect) { - setReferenceBoxLeft(inputRect.left - parentRect.left) + if (mcpConnectorId) { + localStorage.setItem(SELECTED_MCP_CONNECTOR_ID_KEY, mcpConnectorId) } else { - setReferenceBoxLeft(0) + // If no MCP connector is selected + localStorage.removeItem(SELECTED_MCP_CONNECTOR_ID_KEY) } - return - } + }, [selectedConnectorIds, allConnectors, initialLoadComplete]) - const range = document.createRange() - let currentPos = 0 - let targetNode: Node | null = null - let targetOffsetInNode = 0 - - function findDomPosition(node: Node, charIndex: number): boolean { - if (node.nodeType === Node.TEXT_NODE) { - const textLength = node.textContent?.length || 0 - if (currentPos <= charIndex && charIndex < currentPos + textLength) { - targetNode = node - targetOffsetInNode = charIndex - currentPos - return true + const adjustInputHeight = useCallback(() => { + if (inputRef.current) { + inputRef.current.style.height = "auto" + const scrollHeight = inputRef.current.scrollHeight + const minHeight = 52 + const maxHeight = 320 + const newHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight)) + inputRef.current.style.height = `${newHeight}px` + } + }, []) + + const updateReferenceBoxPosition = (atIndex: number) => { + const inputElement = inputRef.current + if (!inputElement || atIndex < 0) { + const parentRect = inputElement + ?.closest( + `.${CLASS_NAMES.SEARCH_CONTAINER} > .relative.flex.flex-col`, + ) + ?.getBoundingClientRect() + const inputRect = inputElement?.getBoundingClientRect() + if (parentRect && inputRect) { + setReferenceBoxLeft(inputRect.left - parentRect.left) + } else { + setReferenceBoxLeft(0) } - currentPos += textLength - } else { - for (const child of node.childNodes) { - if (findDomPosition(child, charIndex)) return true + return + } + + const range = document.createRange() + let currentPos = 0 + let targetNode: Node | null = null + let targetOffsetInNode = 0 + + function findDomPosition(node: Node, charIndex: number): boolean { + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length || 0 + if (currentPos <= charIndex && charIndex < currentPos + textLength) { + targetNode = node + targetOffsetInNode = charIndex - currentPos + return true + } + currentPos += textLength + } else { + for (const child of node.childNodes) { + if (findDomPosition(child, charIndex)) return true + } } + return false } - return false - } - if (findDomPosition(inputElement, atIndex)) { - range.setStart(targetNode!, targetOffsetInNode) - range.setEnd(targetNode!, targetOffsetInNode + 1) - const rect = range.getBoundingClientRect() - const parentRect = inputElement - .closest(`.${CLASS_NAMES.SEARCH_CONTAINER} > .relative.flex.flex-col`) - ?.getBoundingClientRect() + if (findDomPosition(inputElement, atIndex)) { + range.setStart(targetNode!, targetOffsetInNode) + range.setEnd(targetNode!, targetOffsetInNode + 1) + const rect = range.getBoundingClientRect() + const parentRect = inputElement + .closest(`.${CLASS_NAMES.SEARCH_CONTAINER} > .relative.flex.flex-col`) + ?.getBoundingClientRect() - if (parentRect) { - setReferenceBoxLeft(rect.left - parentRect.left) + if (parentRect) { + setReferenceBoxLeft(rect.left - parentRect.left) + } else { + const inputRect = inputElement.getBoundingClientRect() + setReferenceBoxLeft(rect.left - inputRect.left) + } } else { const inputRect = inputElement.getBoundingClientRect() - setReferenceBoxLeft(rect.left - inputRect.left) - } - } else { - const inputRect = inputElement.getBoundingClientRect() - const parentRect = inputElement - .closest(`.${CLASS_NAMES.SEARCH_CONTAINER} > .relative.flex.flex-col`) - ?.getBoundingClientRect() - if (parentRect) { - setReferenceBoxLeft(inputRect.left - parentRect.left) - } else { - setReferenceBoxLeft(0) + const parentRect = inputElement + .closest(`.${CLASS_NAMES.SEARCH_CONTAINER} > .relative.flex.flex-col`) + ?.getBoundingClientRect() + if (parentRect) { + setReferenceBoxLeft(inputRect.left - parentRect.left) + } else { + setReferenceBoxLeft(0) + } } } - } - const derivedReferenceSearch = useMemo(() => { - if (activeAtMentionIndex === -1 || !showReferenceBox) { - return "" - } - if ( - activeAtMentionIndex >= query.length || - query[activeAtMentionIndex] !== "@" - ) { - return "" - } - return query.substring(activeAtMentionIndex + 1).trimStart() - }, [query, showReferenceBox, activeAtMentionIndex]) + const derivedReferenceSearch = useMemo(() => { + if (activeAtMentionIndex === -1 || !showReferenceBox) { + return "" + } + if ( + activeAtMentionIndex >= query.length || + query[activeAtMentionIndex] !== "@" + ) { + return "" + } + return query.substring(activeAtMentionIndex + 1).trimStart() + }, [query, showReferenceBox, activeAtMentionIndex]) - const currentSearchTerm = useMemo(() => { - if (activeAtMentionIndex === -1 && showReferenceBox) { - return referenceSearchTerm - } - return derivedReferenceSearch - }, [ - activeAtMentionIndex, - showReferenceBox, - referenceSearchTerm, - derivedReferenceSearch, - ]) - - useEffect(() => { - if (showReferenceBox && activeAtMentionIndex !== -1) { - const newMode = derivedReferenceSearch.length > 0 ? "global" : "citations" - if (newMode !== searchMode) { - setSearchMode(newMode) - setSelectedRefIndex(-1) - if (newMode === "citations") { - setGlobalResults([]) - setGlobalError(null) - setPage(1) - setTotalCount(0) + const currentSearchTerm = useMemo(() => { + if (activeAtMentionIndex === -1 && showReferenceBox) { + return referenceSearchTerm + } + return derivedReferenceSearch + }, [ + activeAtMentionIndex, + showReferenceBox, + referenceSearchTerm, + derivedReferenceSearch, + ]) + + useEffect(() => { + if (showReferenceBox && activeAtMentionIndex !== -1) { + const newMode = + derivedReferenceSearch.length > 0 ? "global" : "citations" + if (newMode !== searchMode) { + setSearchMode(newMode) + setSelectedRefIndex(-1) + if (newMode === "citations") { + setGlobalResults([]) + setGlobalError(null) + setPage(1) + setTotalCount(0) + } } + } else if (!showReferenceBox) { + setSelectedRefIndex(-1) } - } else if (!showReferenceBox) { - setSelectedRefIndex(-1) - } - }, [ - derivedReferenceSearch, - showReferenceBox, - searchMode, - activeAtMentionIndex, - ]) - - const selectedSourceItems = useMemo(() => { - return availableSources.filter((source) => selectedSources[source.id]) - }, [selectedSources]) - - const selectedSourcesCount = selectedSourceItems.length - - const formatTimestamp = (time: number | undefined) => { - if (!time) return "Unknown Date" - return new Date(time).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }) - } - - const displayedCitations = useMemo(() => { - if ( - !allCitations || - !showReferenceBox || - searchMode !== "citations" || - activeAtMentionIndex === -1 - ) { - return [] - } - const searchValue = derivedReferenceSearch.toLowerCase() - return Array.from(allCitations.values()).filter((citation) => - citation.title.toLowerCase().includes(searchValue), - ) - }, [ - allCitations, - derivedReferenceSearch, - searchMode, - showReferenceBox, - activeAtMentionIndex, - ]) - - // Create enhanced citations that include dummy "whole sheet" options for specific sheet tabs - const enhancedDisplayedCitations: (Citation & { - isWholeSheetDummy?: boolean - })[] = useMemo(() => { - return enhanceGoogleSheetsResults(displayedCitations) - }, [displayedCitations, enhanceGoogleSheetsResults]) - - const fetchResults = async ( - searchTermForFetch: string, - pageToFetch: number, - append: boolean = false, - ) => { - if (!searchTermForFetch || searchTermForFetch.length < 1) return - if ( - isGlobalLoading || - (append && globalResults.length >= totalCount && totalCount > 0) - ) - return - - setIsGlobalLoading(true) - if (!append) { - setGlobalError(null) + }, [ + derivedReferenceSearch, + showReferenceBox, + searchMode, + activeAtMentionIndex, + ]) + + const selectedSourceItems = useMemo(() => { + return availableSources.filter((source) => selectedSources[source.id]) + }, [selectedSources]) + + const selectedSourcesCount = selectedSourceItems.length + + const formatTimestamp = (time: number | undefined) => { + if (!time) return "Unknown Date" + return new Date(time).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }) } - try { - const limit = 10 - const offset = (pageToFetch - 1) * limit - const params: Record = { - query: searchTermForFetch, - limit: limit.toString(), - offset: offset.toString(), + const displayedCitations = useMemo(() => { + if ( + !allCitations || + !showReferenceBox || + searchMode !== "citations" || + activeAtMentionIndex === -1 + ) { + return [] } + const searchValue = derivedReferenceSearch.toLowerCase() + return Array.from(allCitations.values()).filter((citation) => + citation.title.toLowerCase().includes(searchValue), + ) + }, [ + allCitations, + derivedReferenceSearch, + searchMode, + showReferenceBox, + activeAtMentionIndex, + ]) + + // Create enhanced citations that include dummy "whole sheet" options for specific sheet tabs + const enhancedDisplayedCitations: (Citation & { + isWholeSheetDummy?: boolean + })[] = useMemo(() => { + return enhanceGoogleSheetsResults(displayedCitations) + }, [displayedCitations, enhanceGoogleSheetsResults]) + + const fetchResults = async ( + searchTermForFetch: string, + pageToFetch: number, + append: boolean = false, + ) => { + if (!searchTermForFetch || searchTermForFetch.length < 1) return + if ( + isGlobalLoading || + (append && globalResults.length >= totalCount && totalCount > 0) + ) + return - if (persistedAgentId) { - params.agentId = persistedAgentId + setIsGlobalLoading(true) + if (!append) { + setGlobalError(null) } - const response = await api.search.$get({ - query: params, - credentials: "include", - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error( - `API request failed with status ${response.status}: ${errorText}`, - ) - } + try { + const limit = 10 + const offset = (pageToFetch - 1) * limit + const params: Record = { + query: searchTermForFetch, + limit: limit.toString(), + offset: offset.toString(), + } - const data = await response.json() + if (persistedAgentId) { + params.agentId = persistedAgentId + } - const fetchedTotalCount = data.count || 0 - setTotalCount(fetchedTotalCount) + const response = await api.search.$get({ + query: params, + credentials: "include", + }) - const results: SearchResult[] = data.results || [] - setGlobalResults((prev) => { - if (currentSearchTerm !== searchTermForFetch) { - return append ? prev : [] + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `API request failed with status ${response.status}: ${errorText}`, + ) } - const existingIds = new Set(prev.map((r) => r.docId)) - const newResults = results.filter((r) => !existingIds.has(r.docId)) - const updatedResults = append ? [...prev, ...newResults] : newResults - if ( - !append && - updatedResults.length < 5 && - updatedResults.length < fetchedTotalCount - ) { - setTimeout(() => { - fetchResults(searchTermForFetch, pageToFetch + 1, true) - }, 0) - } + const data = await response.json() - return updatedResults - }) + const fetchedTotalCount = data.count || 0 + setTotalCount(fetchedTotalCount) - setPage(pageToFetch) - setGlobalError(null) - } catch (error) { - if (currentSearchTerm === searchTermForFetch) { - setGlobalError("Failed to fetch global results. Please try again.") - if (!append) setGlobalResults([]) - } - } finally { - if (currentSearchTerm === searchTermForFetch) { - setIsGlobalLoading(false) - } - } - } + const results: SearchResult[] = data.results || [] + setGlobalResults((prev) => { + if (currentSearchTerm !== searchTermForFetch) { + return append ? prev : [] + } + const existingIds = new Set(prev.map((r) => r.docId)) + const newResults = results.filter((r) => !existingIds.has(r.docId)) + const updatedResults = append ? [...prev, ...newResults] : newResults + + if ( + !append && + updatedResults.length < 5 && + updatedResults.length < fetchedTotalCount + ) { + setTimeout(() => { + fetchResults(searchTermForFetch, pageToFetch + 1, true) + }, 0) + } - useEffect(() => { - if ( - searchMode !== "global" || - !currentSearchTerm || - currentSearchTerm.length < 1 - ) { - if (!isGlobalLoading) { - setGlobalResults([]) + return updatedResults + }) + + setPage(pageToFetch) setGlobalError(null) - setPage(1) - setTotalCount(0) + } catch (error) { + if (currentSearchTerm === searchTermForFetch) { + setGlobalError("Failed to fetch global results. Please try again.") + if (!append) setGlobalResults([]) + } + } finally { + if (currentSearchTerm === searchTermForFetch) { + setIsGlobalLoading(false) + } } - return } - if (debounceTimeout.current) clearTimeout(debounceTimeout.current) - - const termToFetch = currentSearchTerm - debounceTimeout.current = setTimeout(() => { - setPage(1) - setGlobalResults([]) - fetchResults(termToFetch, 1, false) - }, 300) + useEffect(() => { + if ( + searchMode !== "global" || + !currentSearchTerm || + currentSearchTerm.length < 1 + ) { + if (!isGlobalLoading) { + setGlobalResults([]) + setGlobalError(null) + setPage(1) + setTotalCount(0) + } + return + } - return () => { if (debounceTimeout.current) clearTimeout(debounceTimeout.current) - } - }, [currentSearchTerm, searchMode]) - useEffect(() => { - if (scrollContainerRef.current && scrollPositionRef.current > 0) { - scrollContainerRef.current.scrollTop = scrollPositionRef.current - scrollPositionRef.current = 0 - } - }, [globalResults]) + const termToFetch = currentSearchTerm + debounceTimeout.current = setTimeout(() => { + setPage(1) + setGlobalResults([]) + fetchResults(termToFetch, 1, false) + }, 300) - useEffect(() => { - if (!showReferenceBox) { - setSelectedRefIndex(-1) - return - } + return () => { + if (debounceTimeout.current) clearTimeout(debounceTimeout.current) + } + }, [currentSearchTerm, searchMode]) - const items = - searchMode === "citations" ? enhancedDisplayedCitations : globalResults - const canLoadMore = - searchMode === "global" && - globalResults.length < totalCount && - !isGlobalLoading - if (selectedRefIndex === -1) { - setSelectedRefIndex(items.length > 0 ? 0 : -1) - } else { - const currentMaxIndex = - searchMode === "citations" - ? enhancedDisplayedCitations.length - 1 - : canLoadMore - ? globalResults.length - : globalResults.length - 1 - if (selectedRefIndex > currentMaxIndex) { - setSelectedRefIndex(currentMaxIndex) + useEffect(() => { + if (scrollContainerRef.current && scrollPositionRef.current > 0) { + scrollContainerRef.current.scrollTop = scrollPositionRef.current + scrollPositionRef.current = 0 } - } - }, [ - searchMode, - enhancedDisplayedCitations, - globalResults, - showReferenceBox, - totalCount, - isGlobalLoading, - ]) - - // Helper to find DOM node and offset from a character offset in textContent - const findBoundaryPosition = ( - root: Node, - charOffset: number, - ): { container: Node; offset: number } | null => { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) - let currentAccumulatedOffset = 0 - let node - while ((node = walker.nextNode())) { - const textNode = node as Text - const len = textNode.textContent?.length || 0 - if (currentAccumulatedOffset + len >= charOffset) { - return { - container: textNode, - offset: charOffset - currentAccumulatedOffset, + }, [globalResults]) + + useEffect(() => { + if (!showReferenceBox) { + setSelectedRefIndex(-1) + return + } + + const items = + searchMode === "citations" ? enhancedDisplayedCitations : globalResults + const canLoadMore = + searchMode === "global" && + globalResults.length < totalCount && + !isGlobalLoading + if (selectedRefIndex === -1) { + setSelectedRefIndex(items.length > 0 ? 0 : -1) + } else { + const currentMaxIndex = + searchMode === "citations" + ? enhancedDisplayedCitations.length - 1 + : canLoadMore + ? globalResults.length + : globalResults.length - 1 + if (selectedRefIndex > currentMaxIndex) { + setSelectedRefIndex(currentMaxIndex) } } - currentAccumulatedOffset += len - } - // If charOffset is at the very end of the content (after all text nodes) - if (charOffset === currentAccumulatedOffset) { - // Find the last child of the root, or root itself, to place the cursor - let containerNode: Node = root - let containerOffset = root.childNodes.length - if (root.childNodes.length > 0) { - let lastChild = root.lastChild - while ( - lastChild && - lastChild.nodeType !== Node.TEXT_NODE && - lastChild.lastChild - ) { - lastChild = lastChild.lastChild + }, [ + searchMode, + enhancedDisplayedCitations, + globalResults, + showReferenceBox, + totalCount, + isGlobalLoading, + ]) + + // Helper to find DOM node and offset from a character offset in textContent + const findBoundaryPosition = ( + root: Node, + charOffset: number, + ): { container: Node; offset: number } | null => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + let currentAccumulatedOffset = 0 + let node + while ((node = walker.nextNode())) { + const textNode = node as Text + const len = textNode.textContent?.length || 0 + if (currentAccumulatedOffset + len >= charOffset) { + return { + container: textNode, + offset: charOffset - currentAccumulatedOffset, + } } - if (lastChild && lastChild.nodeType === Node.TEXT_NODE) { - containerNode = lastChild - containerOffset = lastChild.textContent?.length || 0 - } else if (root.lastChild) { - // If last child is an element, place cursor after it in parent - containerNode = root - // Find index of lastChild + 1 for offset - containerOffset = - Array.from(root.childNodes).indexOf(root.lastChild) + 1 + currentAccumulatedOffset += len + } + // If charOffset is at the very end of the content (after all text nodes) + if (charOffset === currentAccumulatedOffset) { + // Find the last child of the root, or root itself, to place the cursor + let containerNode: Node = root + let containerOffset = root.childNodes.length + if (root.childNodes.length > 0) { + let lastChild = root.lastChild + while ( + lastChild && + lastChild.nodeType !== Node.TEXT_NODE && + lastChild.lastChild + ) { + lastChild = lastChild.lastChild + } + if (lastChild && lastChild.nodeType === Node.TEXT_NODE) { + containerNode = lastChild + containerOffset = lastChild.textContent?.length || 0 + } else if (root.lastChild) { + // If last child is an element, place cursor after it in parent + containerNode = root + // Find index of lastChild + 1 for offset + containerOffset = + Array.from(root.childNodes).indexOf(root.lastChild) + 1 + } } + return { container: containerNode, offset: containerOffset } } - return { container: containerNode, offset: containerOffset } - } - return null - } - - // Helper function to parse content and preserve existing pills as spans - THIS WILL BE REPLACED/REMOVED - // For now, keeping its signature for context, but its usage will be removed from handleAddReference/handleSelectGlobalResult - - const handleAddReference = ( - citation: Citation & { isWholeSheetDummy?: boolean }, - ) => { - // Handle DataSource navigation - if ( - citation.app === Apps.DataSource && - citation.entity === DataSourceEntity.DataSourceFile - ) { - navigate({ to: `/dataSource/${citation.docId}` }) - setShowReferenceBox(false) - return + return null } - const docId = citation.docId + // Helper function to parse content and preserve existing pills as spans - THIS WILL BE REPLACED/REMOVED + // For now, keeping its signature for context, but its usage will be removed from handleAddReference/handleSelectGlobalResult - // Check if this is a Google Sheet and determine wholeSheet property - const isGoogleSheet = - citation.app === Apps.GoogleDrive && - citation.entity === DriveEntity.Sheets - let wholeSheet: boolean | undefined = undefined - - if (isGoogleSheet) { - if (citation.isWholeSheetDummy) { - wholeSheet = true - } else if (citation.title.includes(" / ")) { - wholeSheet = false - } else { - wholeSheet = true // Default for regular sheets + const handleAddReference = ( + citation: Citation & { isWholeSheetDummy?: boolean }, + ) => { + // Handle DataSource navigation + if ( + citation.app === Apps.DataSource && + citation.entity === DataSourceEntity.DataSourceFile + ) { + navigate({ to: `/dataSource/${citation.docId}` }) + setShowReferenceBox(false) + return } - } - - const newRef: Reference = { - id: docId, - docId: docId, - title: citation.title, - url: citation.url, - app: citation.app, - entity: citation.entity, - type: "citation", - wholeSheet: wholeSheet, - threadId: (citation as any).threadId, // Add threadId if available - } - - const input = inputRef.current - if (!input || activeAtMentionIndex === -1) { - setShowReferenceBox(false) - return - } - const selection = window.getSelection() - if (!selection) { - setShowReferenceBox(false) - return - } + const docId = citation.docId - const mentionStartCharOffset = activeAtMentionIndex - // The @mention text effectively goes from activeAtMentionIndex up to the current caret position. - // When clicking a reference, getCaretCharacterOffsetWithin(input) might be unreliable if focus changes. - // Assuming the active mention always extends to the end of the current query content. - const mentionEndCharOffset = query.length + // Check if this is a Google Sheet and determine wholeSheet property + const isGoogleSheet = + citation.app === Apps.GoogleDrive && + citation.entity === DriveEntity.Sheets + let wholeSheet: boolean | undefined = undefined - const startPos = findBoundaryPosition(input, mentionStartCharOffset) - const endPos = findBoundaryPosition(input, mentionEndCharOffset) + if (isGoogleSheet) { + if (citation.isWholeSheetDummy) { + wholeSheet = true + } else if (citation.title.includes(" / ")) { + wholeSheet = false + } else { + wholeSheet = true // Default for regular sheets + } + } - if (startPos && endPos) { - const range = document.createRange() - range.setStart(startPos.container, startPos.offset) - range.setEnd(endPos.container, endPos.offset) - range.deleteContents() + const newRef: Reference = { + id: docId, + docId: docId, + title: citation.title, + url: citation.url, + app: citation.app, + entity: citation.entity, + type: "citation", + wholeSheet: wholeSheet, + threadId: (citation as any).threadId, // Add threadId if available + } - const pillHtmlString = renderToStaticMarkup() - const tempDiv = document.createElement("div") - tempDiv.innerHTML = pillHtmlString - // Find the actual tag, as renderToStaticMarkup might prepend other tags like - const pillElement = tempDiv.querySelector( - `a.${CLASS_NAMES.REFERENCE_PILL}`, - ) + const input = inputRef.current + if (!input || activeAtMentionIndex === -1) { + setShowReferenceBox(false) + return + } - if (pillElement) { - const clonedPill = pillElement.cloneNode(true) - range.insertNode(clonedPill) - const space = document.createTextNode("\u00A0") - - // Insert space after pill and set caret - range.setStartAfter(clonedPill) - range.insertNode(space) - range.setStart(space, space.length) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) + const selection = window.getSelection() + if (!selection) { + setShowReferenceBox(false) + return } - setQuery(input.textContent || "") - } else { - console.error( - "Could not determine range for @mention replacement in handleAddReference.", - ) - // Fallback or error handling if positions can't be found - } - setShowReferenceBox(false) - setActiveAtMentionIndex(-1) - setReferenceSearchTerm("") - setGlobalResults([]) - setGlobalError(null) - setPage(1) - setTotalCount(0) - setSelectedRefIndex(-1) - } + const mentionStartCharOffset = activeAtMentionIndex + // The @mention text effectively goes from activeAtMentionIndex up to the current caret position. + // When clicking a reference, getCaretCharacterOffsetWithin(input) might be unreliable if focus changes. + // Assuming the active mention always extends to the end of the current query content. + const mentionEndCharOffset = query.length + + const startPos = findBoundaryPosition(input, mentionStartCharOffset) + const endPos = findBoundaryPosition(input, mentionEndCharOffset) + + if (startPos && endPos) { + const range = document.createRange() + range.setStart(startPos.container, startPos.offset) + range.setEnd(endPos.container, endPos.offset) + range.deleteContents() + + const pillHtmlString = renderToStaticMarkup() + const tempDiv = document.createElement("div") + tempDiv.innerHTML = pillHtmlString + // Find the actual tag, as renderToStaticMarkup might prepend other tags like + const pillElement = tempDiv.querySelector( + `a.${CLASS_NAMES.REFERENCE_PILL}`, + ) - const handleSelectGlobalResult = ( - result: SearchResult & { isWholeSheetDummy?: boolean }, - ) => { - let resultUrl = result.url - if (!resultUrl && result.app === Apps.Gmail) { - const identifier = result.threadId || result.docId - if (identifier) { - resultUrl = `https://mail.google.com/mail/u/0/#inbox/${identifier}` - } - } - if (result.app === Apps.Slack) { - if (result.threadId) { - // Thread message format - resultUrl = `https://${result.domain}.slack.com/archives/${result.channelId}/p${slackTs(result.createdAt)}?thread_ts=${result.threadId}&cid=${result.channelId}` + if (pillElement) { + const clonedPill = pillElement.cloneNode(true) + range.insertNode(clonedPill) + const space = document.createTextNode("\u00A0") + + // Insert space after pill and set caret + range.setStartAfter(clonedPill) + range.insertNode(space) + range.setStart(space, space.length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + setQuery(input.textContent || "") } else { - // Normal message format - resultUrl = `https://${result.domain}.slack.com/archives/${result.channelId}/p${slackTs(result.createdAt)}` + console.error( + "Could not determine range for @mention replacement in handleAddReference.", + ) + // Fallback or error handling if positions can't be found } - } - const displayTitle = - result.text || - result.name || - result.subject || - result.title || - result.filename || - (result.type === "user" && result.email) || - "Untitled" - const refId = result.docId || (result.type === "user" && result.email) || "" - - if (!refId) { - console.error("Cannot add reference without a valid ID.", result) setShowReferenceBox(false) setActiveAtMentionIndex(-1) setReferenceSearchTerm("") - return + setGlobalResults([]) + setGlobalError(null) + setPage(1) + setTotalCount(0) + setSelectedRefIndex(-1) } - // Check if this is a Google Sheet with a specific tab (contains " / " in title) - const isGoogleSheet = - result.app === Apps.GoogleDrive && result.entity === DriveEntity.Sheets - const isSpecificSheetTab = - isGoogleSheet && displayTitle.includes(" / ") && !result.isWholeSheetDummy - const isWholeSheetDummy = result.isWholeSheetDummy || false - - let newRef: Reference - - if (isSpecificSheetTab) { - // For specific sheet tabs, create the reference with wholeSheet: false - newRef = { - id: refId, - title: displayTitle, - url: resultUrl, - docId: result.docId, - mailId: result.mailId, - app: result.app, - entity: result.entity, - type: "global", - photoLink: result.photoLink, - userMap: result.userMap, - wholeSheet: false, - } - } else if (isWholeSheetDummy) { - // For whole sheet dummy results, create reference with wholeSheet: true - newRef = { - id: refId, - title: displayTitle, - url: resultUrl, - docId: result.docId, - mailId: result.mailId, - app: result.app, - entity: result.entity, - type: "global", - photoLink: result.photoLink, - userMap: result.userMap, - wholeSheet: true, + const handleSelectGlobalResult = ( + result: SearchResult & { isWholeSheetDummy?: boolean }, + ) => { + let resultUrl = result.url + if (!resultUrl && result.app === Apps.Gmail) { + const identifier = result.threadId || result.docId + if (identifier) { + resultUrl = `https://mail.google.com/mail/u/0/#inbox/${identifier}` + } } - } else { - // For all other types, create normal reference - newRef = { - id: refId, - title: displayTitle, - url: resultUrl, - docId: result.docId, - mailId: result.mailId, - threadId: result.threadId, // Add threadId from result - app: result.app, - entity: result.entity, - type: "global", - photoLink: result.photoLink, - userMap: result.userMap, - wholeSheet: isGoogleSheet ? true : undefined, + if (result.app === Apps.Slack) { + if (result.threadId) { + // Thread message format + resultUrl = `https://${result.domain}.slack.com/archives/${result.channelId}/p${slackTs(result.createdAt)}?thread_ts=${result.threadId}&cid=${result.channelId}` + } else { + // Normal message format + resultUrl = `https://${result.domain}.slack.com/archives/${result.channelId}/p${slackTs(result.createdAt)}` + } } - } - - const input = inputRef.current - if (!input || activeAtMentionIndex === -1) { - setShowReferenceBox(false) - return - } - - const selection = window.getSelection() - if (!selection) { - setShowReferenceBox(false) - return - } - const mentionStartCharOffset = activeAtMentionIndex - // When clicking a reference, getCaretCharacterOffsetWithin(input) might be unreliable if focus changes. - // Assuming the active mention always extends to the end of the current query content. - const mentionEndCharOffset = query.length - - const startPos = findBoundaryPosition(input, mentionStartCharOffset) - const endPos = findBoundaryPosition(input, mentionEndCharOffset) + const displayTitle = + result.text || + result.name || + result.subject || + result.title || + result.filename || + (result.type === "user" && result.email) || + "Untitled" + const refId = + result.docId || (result.type === "user" && result.email) || "" + + if (!refId) { + console.error("Cannot add reference without a valid ID.", result) + setShowReferenceBox(false) + setActiveAtMentionIndex(-1) + setReferenceSearchTerm("") + return + } - if (startPos && endPos) { - const range = document.createRange() - range.setStart(startPos.container, startPos.offset) - range.setEnd(endPos.container, endPos.offset) - range.deleteContents() + // Check if this is a Google Sheet with a specific tab (contains " / " in title) + const isGoogleSheet = + result.app === Apps.GoogleDrive && result.entity === DriveEntity.Sheets + const isSpecificSheetTab = + isGoogleSheet && + displayTitle.includes(" / ") && + !result.isWholeSheetDummy + const isWholeSheetDummy = result.isWholeSheetDummy || false + + let newRef: Reference + + if (isSpecificSheetTab) { + // For specific sheet tabs, create the reference with wholeSheet: false + newRef = { + id: refId, + title: displayTitle, + url: resultUrl, + docId: result.docId, + mailId: result.mailId, + app: result.app, + entity: result.entity, + type: "global", + photoLink: result.photoLink, + userMap: result.userMap, + wholeSheet: false, + } + } else if (isWholeSheetDummy) { + // For whole sheet dummy results, create reference with wholeSheet: true + newRef = { + id: refId, + title: displayTitle, + url: resultUrl, + docId: result.docId, + mailId: result.mailId, + app: result.app, + entity: result.entity, + type: "global", + photoLink: result.photoLink, + userMap: result.userMap, + wholeSheet: true, + } + } else { + // For all other types, create normal reference + newRef = { + id: refId, + title: displayTitle, + url: resultUrl, + docId: result.docId, + mailId: result.mailId, + threadId: result.threadId, // Add threadId from result + app: result.app, + entity: result.entity, + type: "global", + photoLink: result.photoLink, + userMap: result.userMap, + wholeSheet: isGoogleSheet ? true : undefined, + } + } - const pillHtmlString = renderToStaticMarkup() - const tempDiv = document.createElement("div") - tempDiv.innerHTML = pillHtmlString - // Find the actual tag, as renderToStaticMarkup might prepend other tags like - const pillElement = tempDiv.querySelector("a.reference-pill") - - if (pillElement) { - const clonedPill = pillElement.cloneNode(true) - range.insertNode(clonedPill) - const space = document.createTextNode("\u00A0") - - range.setStartAfter(clonedPill) - range.insertNode(space) - range.setStart(space, space.length) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) + const input = inputRef.current + if (!input || activeAtMentionIndex === -1) { + setShowReferenceBox(false) + return } - setQuery(input.textContent || "") - } else { - console.error( - "Could not determine range for @mention replacement in handleSelectGlobalResult.", - ) - } - setShowReferenceBox(false) - setActiveAtMentionIndex(-1) - setReferenceSearchTerm("") - setGlobalResults([]) - setGlobalError(null) - setPage(1) - setTotalCount(0) - setSelectedRefIndex(-1) - } + const selection = window.getSelection() + if (!selection) { + setShowReferenceBox(false) + return + } - const handleReferenceKeyDown = ( - e: React.KeyboardEvent, - ) => { - if (!showReferenceBox) return - - const items = - searchMode === "citations" - ? enhancedDisplayedCitations - : enhancedGlobalResults - const totalItemsCount = items.length - const canLoadMore = - searchMode === "global" && - globalResults.length < totalCount && - !isGlobalLoading - const loadMoreIndex = enhancedGlobalResults.length - - if (e.key === "ArrowDown") { - e.preventDefault() - const maxIndex = canLoadMore ? loadMoreIndex : totalItemsCount - 1 - setSelectedRefIndex((prev) => { - const nextIndex = Math.min(prev + 1, maxIndex) - if (prev === -1 && items.length > 0) return 0 - return nextIndex - }) - } else if (e.key === "ArrowUp") { - e.preventDefault() - setSelectedRefIndex((prev) => { - const nextIndex = Math.max(prev - 1, 0) - return nextIndex - }) - } else if (e.key === "Enter") { - e.preventDefault() - if (selectedRefIndex >= 0 && selectedRefIndex < totalItemsCount) { - if (searchMode === "citations") { - if (enhancedDisplayedCitations[selectedRefIndex]) { - handleAddReference(enhancedDisplayedCitations[selectedRefIndex]) - } - } else { - if (enhancedGlobalResults[selectedRefIndex]) { - handleSelectGlobalResult(enhancedGlobalResults[selectedRefIndex]) - } + const mentionStartCharOffset = activeAtMentionIndex + // When clicking a reference, getCaretCharacterOffsetWithin(input) might be unreliable if focus changes. + // Assuming the active mention always extends to the end of the current query content. + const mentionEndCharOffset = query.length + + const startPos = findBoundaryPosition(input, mentionStartCharOffset) + const endPos = findBoundaryPosition(input, mentionEndCharOffset) + + if (startPos && endPos) { + const range = document.createRange() + range.setStart(startPos.container, startPos.offset) + range.setEnd(endPos.container, endPos.offset) + range.deleteContents() + + const pillHtmlString = renderToStaticMarkup() + const tempDiv = document.createElement("div") + tempDiv.innerHTML = pillHtmlString + // Find the actual tag, as renderToStaticMarkup might prepend other tags like + const pillElement = tempDiv.querySelector("a.reference-pill") + + if (pillElement) { + const clonedPill = pillElement.cloneNode(true) + range.insertNode(clonedPill) + const space = document.createTextNode("\u00A0") + + range.setStartAfter(clonedPill) + range.insertNode(space) + range.setStart(space, space.length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) } - } else if ( - searchMode === "global" && - selectedRefIndex === loadMoreIndex && - canLoadMore - ) { - handleLoadMore() + setQuery(input.textContent || "") + } else { + console.error( + "Could not determine range for @mention replacement in handleSelectGlobalResult.", + ) } - } else if (e.key === "Escape") { - e.preventDefault() + setShowReferenceBox(false) setActiveAtMentionIndex(-1) setReferenceSearchTerm("") + setGlobalResults([]) + setGlobalError(null) + setPage(1) + setTotalCount(0) setSelectedRefIndex(-1) } - } - useEffect(() => { - if (selectedRefIndex >= 0 && referenceItemsRef.current[selectedRefIndex]) { - referenceItemsRef.current[selectedRefIndex]?.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }) - } - }, [selectedRefIndex]) + const handleReferenceKeyDown = ( + e: React.KeyboardEvent, + ) => { + if (!showReferenceBox) return - useEffect(() => { - const handleOutsideClick = (event: MouseEvent) => { - const target = event.target as Node - if ( - showReferenceBox && - referenceBoxRef.current && - !referenceBoxRef.current.contains(target) && - inputRef.current && - !inputRef.current.contains(target) && - !(event.target as HTMLElement).closest( - `.${CLASS_NAMES.REFERENCE_TRIGGER}`, - ) - ) { + const items = + searchMode === "citations" + ? enhancedDisplayedCitations + : enhancedGlobalResults + const totalItemsCount = items.length + const canLoadMore = + searchMode === "global" && + globalResults.length < totalCount && + !isGlobalLoading + const loadMoreIndex = enhancedGlobalResults.length + + if (e.key === "ArrowDown") { + e.preventDefault() + const maxIndex = canLoadMore ? loadMoreIndex : totalItemsCount - 1 + setSelectedRefIndex((prev) => { + const nextIndex = Math.min(prev + 1, maxIndex) + if (prev === -1 && items.length > 0) return 0 + return nextIndex + }) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedRefIndex((prev) => { + const nextIndex = Math.max(prev - 1, 0) + return nextIndex + }) + } else if (e.key === "Enter") { + e.preventDefault() + if (selectedRefIndex >= 0 && selectedRefIndex < totalItemsCount) { + if (searchMode === "citations") { + if (enhancedDisplayedCitations[selectedRefIndex]) { + handleAddReference(enhancedDisplayedCitations[selectedRefIndex]) + } + } else { + if (enhancedGlobalResults[selectedRefIndex]) { + handleSelectGlobalResult(enhancedGlobalResults[selectedRefIndex]) + } + } + } else if ( + searchMode === "global" && + selectedRefIndex === loadMoreIndex && + canLoadMore + ) { + handleLoadMore() + } + } else if (e.key === "Escape") { + e.preventDefault() setShowReferenceBox(false) setActiveAtMentionIndex(-1) setReferenceSearchTerm("") + setSelectedRefIndex(-1) } } - document.addEventListener("mousedown", handleOutsideClick) - return () => document.removeEventListener("mousedown", handleOutsideClick) - }, [showReferenceBox]) - - const handleSendMessage = useCallback(async () => { - const activeSourceIds = Object.entries(selectedSources) - .filter(([, isSelected]) => isSelected) - .map(([id]) => id) - - let htmlMessage = inputRef.current?.innerHTML || "" - htmlMessage = htmlMessage.replace(/( |\s)+$/g, "") - htmlMessage = htmlMessage.replace(/(\s*)+$/gi, "") - htmlMessage = htmlMessage.replace(/( |\s)+$/g, "") - - let toolsListToSend: ToolsListItem[] | undefined = undefined - - // Build toolsList from all selected connectors - if (selectedConnectorIds.size > 0) { - const toolsListArray: ToolsListItem[] = [] - - // Include tools from all selected connectors - selectedConnectorIds.forEach((connectorId) => { - const toolsSet = selectedConnectorTools[connectorId] - - if (toolsSet && toolsSet.size > 0) { - // Find the connector to get its internal connectorId - const connector = allConnectors.find((c) => c.id === connectorId) - if (connector) { - const toolsArray = Array.from(toolsSet) - toolsListArray.push({ - connectorId: connector.connectorId.toString(), // Use internal DB id - tools: toolsArray, - }) - } - } - }) - - // Only send toolsList if we actually have tools selected + useEffect(() => { if ( - toolsListArray.length > 0 && - toolsListArray.some((item) => item.tools.length > 0) + selectedRefIndex >= 0 && + referenceItemsRef.current[selectedRefIndex] ) { - toolsListToSend = toolsListArray + referenceItemsRef.current[selectedRefIndex]?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }) } - } + }, [selectedRefIndex]) - // Handle Attachments Metadata - let attachmentsMetadata: AttachmentMetadata[] = [] - if (selectedFiles.length > 0) { - const filesToUpload = selectedFiles.filter( - (f) => !f.metadata && !f.uploading, - ) - const alreadyUploadedMetadata = selectedFiles - .map((f) => f.metadata) - .filter((m): m is AttachmentMetadata => !!m) - - if (filesToUpload.length > 0) { - const newUploadedMetadata = await uploadFiles(filesToUpload) - attachmentsMetadata = [ - ...alreadyUploadedMetadata, - ...newUploadedMetadata, - ] - } else { - attachmentsMetadata = alreadyUploadedMetadata + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + const target = event.target as Node + if ( + showReferenceBox && + referenceBoxRef.current && + !referenceBoxRef.current.contains(target) && + inputRef.current && + !inputRef.current.contains(target) && + !(event.target as HTMLElement).closest( + `.${CLASS_NAMES.REFERENCE_TRIGGER}`, + ) + ) { + setShowReferenceBox(false) + setActiveAtMentionIndex(-1) + setReferenceSearchTerm("") + } } - } - // Replace data-doc-id and data-reference-id with mailId - const tempDiv = document.createElement("div") - tempDiv.innerHTML = htmlMessage - const pills = tempDiv.querySelectorAll("a.reference-pill") - - pills.forEach((pill) => { - const mailId = pill.getAttribute("data-mail-id") - const userMap = pill.getAttribute("user-map") - const threadId = pill.getAttribute("data-thread-id") - const docId = - pill.getAttribute("data-doc-id") || - pill.getAttribute("data-reference-id") - if (userMap) { - try { - const parsedUserMap = JSON.parse(userMap) - if (user?.email && parsedUserMap[user.email]) { - pill.setAttribute( - "href", - `https://mail.google.com/mail/u/0/#inbox/${parsedUserMap[user.email]}`, - ) - } else { - console.warn( - `No mapping found for user email: ${user?.email} in userMap.`, - ) + document.addEventListener("mousedown", handleOutsideClick) + return () => document.removeEventListener("mousedown", handleOutsideClick) + }, [showReferenceBox]) + + const handleSendMessage = useCallback(async () => { + const activeSourceIds = Object.entries(selectedSources) + .filter(([, isSelected]) => isSelected) + .map(([id]) => id) + + let htmlMessage = inputRef.current?.innerHTML || "" + htmlMessage = htmlMessage.replace(/( |\s)+$/g, "") + htmlMessage = htmlMessage.replace(/(\s*)+$/gi, "") + htmlMessage = htmlMessage.replace(/( |\s)+$/g, "") + + let toolsListToSend: ToolsListItem[] | undefined = undefined + + // Build toolsList from all selected connectors + if (selectedConnectorIds.size > 0) { + const toolsListArray: ToolsListItem[] = [] + + // Include tools from all selected connectors + selectedConnectorIds.forEach((connectorId) => { + const toolsSet = selectedConnectorTools[connectorId] + + if (toolsSet && toolsSet.size > 0) { + // Find the connector to get its internal connectorId + const connector = allConnectors.find((c) => c.id === connectorId) + if (connector) { + const toolsArray = Array.from(toolsSet) + toolsListArray.push({ + connectorId: connector.connectorId.toString(), // Use internal DB id + tools: toolsArray, + }) + } } - } catch (error) { - console.error("Failed to parse userMap:", error) + }) + + // Only send toolsList if we actually have tools selected + if ( + toolsListArray.length > 0 && + toolsListArray.some((item) => item.tools.length > 0) + ) { + toolsListToSend = toolsListArray } } - if (mailId) { - pill.setAttribute("data-doc-id", mailId) - pill.setAttribute("data-reference-id", mailId) - pill.setAttribute("data-thread-id", threadId || "") - } else { - console.warn( - `No mailId found for pill with docId: ${docId}. Skipping replacement.`, + // Handle Attachments Metadata + let attachmentsMetadata: AttachmentMetadata[] = [] + if (selectedFiles.length > 0) { + const filesToUpload = selectedFiles.filter( + (f) => !f.metadata && !f.uploading, ) + const alreadyUploadedMetadata = selectedFiles + .map((f) => f.metadata) + .filter((m): m is AttachmentMetadata => !!m) + + if (filesToUpload.length > 0) { + const newUploadedMetadata = await uploadFiles(filesToUpload) + attachmentsMetadata = [ + ...alreadyUploadedMetadata, + ...newUploadedMetadata, + ] + } else { + attachmentsMetadata = alreadyUploadedMetadata + } } - }) - - htmlMessage = tempDiv.innerHTML - handleSend( - htmlMessage, - attachmentsMetadata, - activeSourceIds.length > 0 ? activeSourceIds : undefined, - persistedAgentId, - toolsListToSend, - ) - - // Clear the input and attached files after sending - if (inputRef.current) { - inputRef.current.innerHTML = "" - } - setQuery("") - - // Cleanup preview URLs before clearing files - const previewUrls = selectedFiles - .map((f) => f.preview) - .filter(Boolean) as string[] - cleanupPreviewUrls(previewUrls) - setSelectedFiles([]) - }, [ - selectedSources, - selectedConnectorIds, - selectedConnectorTools, - allConnectors, - selectedFiles, - persistedAgentId, - handleSend, - uploadFiles, - user, - setQuery, - setSelectedFiles, - cleanupPreviewUrls, - ]) - - const handleSourceSelectionChange = (sourceId: string, checked: boolean) => { - setSelectedSources((prev) => ({ - ...prev, - [sourceId]: checked, - })) - setPage(1) - setGlobalResults([]) - } - const handleClearAllSources = () => { - const clearedSources: Record = {} - availableSources.forEach((source) => { - clearedSources[source.id] = false - }) - setSelectedSources(clearedSources) - setPage(1) - setGlobalResults([]) - } + // Replace data-doc-id and data-reference-id with mailId + const tempDiv = document.createElement("div") + tempDiv.innerHTML = htmlMessage + const pills = tempDiv.querySelectorAll("a.reference-pill") + + pills.forEach((pill) => { + const mailId = pill.getAttribute("data-mail-id") + const userMap = pill.getAttribute("user-map") + const threadId = pill.getAttribute("data-thread-id") + const docId = + pill.getAttribute("data-doc-id") || + pill.getAttribute("data-reference-id") + if (userMap) { + try { + const parsedUserMap = JSON.parse(userMap) + if (user?.email && parsedUserMap[user.email]) { + pill.setAttribute( + "href", + `https://mail.google.com/mail/u/0/#inbox/${parsedUserMap[user.email]}`, + ) + } else { + console.warn( + `No mapping found for user email: ${user?.email} in userMap.`, + ) + } + } catch (error) { + console.error("Failed to parse userMap:", error) + } + } - const handleLoadMore = () => { - if (scrollContainerRef.current) { - scrollPositionRef.current = scrollContainerRef.current.scrollTop - } - const nextPage = page + 1 - fetchResults(currentSearchTerm, nextPage, true) - } + if (mailId) { + pill.setAttribute("data-doc-id", mailId) + pill.setAttribute("data-reference-id", mailId) + pill.setAttribute("data-thread-id", threadId || "") + } else { + console.warn( + `No mailId found for pill with docId: ${docId}. Skipping replacement.`, + ) + } + }) - useEffect(() => { - if ( - showReferenceBox && - activeAtMentionIndex === -1 && - referenceSearchInputRef.current - ) { - referenceSearchInputRef.current.focus() - } - }, [showReferenceBox, activeAtMentionIndex]) + htmlMessage = tempDiv.innerHTML - useEffect(() => { - adjustInputHeight() - }, [query, adjustInputHeight]) + handleSend( + htmlMessage, + attachmentsMetadata, + activeSourceIds.length > 0 ? activeSourceIds : undefined, + persistedAgentId, + toolsListToSend, + enableWebSearch, + ) - // Cleanup preview URLs when component unmounts - const selectedFilesRef = useRef(selectedFiles) - selectedFilesRef.current = selectedFiles + // Clear the input and attached files after sending + if (inputRef.current) { + inputRef.current.innerHTML = "" + } + setQuery("") - useEffect(() => { - return () => { - const previewUrls = selectedFilesRef.current + // Cleanup preview URLs before clearing files + const previewUrls = selectedFiles .map((f) => f.preview) .filter(Boolean) as string[] cleanupPreviewUrls(previewUrls) + setSelectedFiles([]) + }, [ + selectedSources, + selectedConnectorIds, + selectedConnectorTools, + allConnectors, + selectedFiles, + persistedAgentId, + handleSend, + uploadFiles, + user, + setQuery, + setSelectedFiles, + cleanupPreviewUrls, + enableWebSearch, + ]) + + const handleSourceSelectionChange = ( + sourceId: string, + checked: boolean, + ) => { + setSelectedSources((prev) => ({ + ...prev, + [sourceId]: checked, + })) + setPage(1) + setGlobalResults([]) } - }, []) - - // Add imperative handle to expose sendMessage method - React.useImperativeHandle(ref, () => ({ - sendMessage: (message: string) => { - // Set the query first - setQuery(message) - // Update the input content - if (inputRef.current) { - inputRef.current.textContent = message - setIsPlaceholderVisible(false) + + const handleClearAllSources = () => { + const clearedSources: Record = {} + availableSources.forEach((source) => { + clearedSources[source.id] = false + }) + setSelectedSources(clearedSources) + setPage(1) + setGlobalResults([]) + } + + const handleLoadMore = () => { + if (scrollContainerRef.current) { + scrollPositionRef.current = scrollContainerRef.current.scrollTop } - // Then trigger the send message with all the internal state - // Use setTimeout to ensure state updates are applied - setTimeout(() => { - // Call handleSendMessage which will use the current state values - // for agents, tools, connectors, etc. - handleSendMessage() - }, 0) + const nextPage = page + 1 + fetchResults(currentSearchTerm, nextPage, true) } - }), [ - // Include dependencies that affect what gets sent - selectedConnectorIds, - selectedConnectorTools, - persistedAgentId, - selectedSources, - selectedFiles, - handleSendMessage - ]) - - return ( -
- {persistedAgentId && displayAgentName && ( -
-
- + + useEffect(() => { + if ( + showReferenceBox && + activeAtMentionIndex === -1 && + referenceSearchInputRef.current + ) { + referenceSearchInputRef.current.focus() + } + }, [showReferenceBox, activeAtMentionIndex]) + + useEffect(() => { + adjustInputHeight() + }, [query, adjustInputHeight]) + + // Cleanup preview URLs when component unmounts + const selectedFilesRef = useRef(selectedFiles) + selectedFilesRef.current = selectedFiles + + useEffect(() => { + return () => { + const previewUrls = selectedFilesRef.current + .map((f) => f.preview) + .filter(Boolean) as string[] + cleanupPreviewUrls(previewUrls) + } + }, []) + + // Add imperative handle to expose sendMessage method + React.useImperativeHandle( + ref, + () => ({ + sendMessage: (message: string) => { + // Set the query first + setQuery(message) + // Update the input content + if (inputRef.current) { + inputRef.current.textContent = message + setIsPlaceholderVisible(false) + } + // Then trigger the send message with all the internal state + // Use setTimeout to ensure state updates are applied + setTimeout(() => { + // Call handleSendMessage which will use the current state values + // for agents, tools, connectors, etc. + handleSendMessage() + }, 0) + }, + }), + [ + // Include dependencies that affect what gets sent + selectedConnectorIds, + selectedConnectorTools, + persistedAgentId, + selectedSources, + selectedFiles, + handleSendMessage, + ], + ) + + return ( +
+ {persistedAgentId && displayAgentName && ( +
+
+ + + {displayAgentName} + +
- {displayAgentName} + ASK AGENT
- - ASK AGENT - -
- )} - {showReferenceBox && ( -
- {activeAtMentionIndex === -1 && ( -
- - setReferenceSearchTerm(e.target.value)} - onKeyDown={handleReferenceKeyDown} - className="w-full pl-8 pr-2 py-1.5 text-sm border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" - /> -
- )} + )} + {showReferenceBox && (
- {searchMode === "citations" && activeAtMentionIndex !== -1 && ( - <> - {enhancedDisplayedCitations.length > 0 ? ( - <> - {enhancedDisplayedCitations.map( - ( - citation: Citation & { isWholeSheetDummy?: boolean }, - index, - ) => { - const citationApp = (citation as any).app - const citationEntity = (citation as any).entity - return ( -
- (referenceItemsRef.current[index] = el) - } - className={`p-2 cursor-pointer hover:bg-[#EDF2F7] dark:hover:bg-slate-700 rounded-md ${ - index === selectedRefIndex - ? "bg-[#EDF2F7] dark:bg-slate-700" - : "" - }`} - onClick={() => handleAddReference(citation)} - onMouseEnter={() => setSelectedRefIndex(index)} - > -
- {citationApp && citationEntity ? ( - getIcon(citationApp, citationEntity, { - w: 16, - h: 16, - mr: 0, - }) - ) : ( - - )} -

- {citation.title || citation.name} + {activeAtMentionIndex === -1 && ( +

+ + setReferenceSearchTerm(e.target.value)} + onKeyDown={handleReferenceKeyDown} + className="w-full pl-8 pr-2 py-1.5 text-sm border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" + /> +
+ )} +
+ {searchMode === "citations" && activeAtMentionIndex !== -1 && ( + <> + {enhancedDisplayedCitations.length > 0 ? ( + <> + {enhancedDisplayedCitations.map( + ( + citation: Citation & { isWholeSheetDummy?: boolean }, + index, + ) => { + const citationApp = (citation as any).app + const citationEntity = (citation as any).entity + return ( +
+ (referenceItemsRef.current[index] = el) + } + className={`p-2 cursor-pointer hover:bg-[#EDF2F7] dark:hover:bg-slate-700 rounded-md ${ + index === selectedRefIndex + ? "bg-[#EDF2F7] dark:bg-slate-700" + : "" + }`} + onClick={() => handleAddReference(citation)} + onMouseEnter={() => setSelectedRefIndex(index)} + > +
+ {citationApp && citationEntity ? ( + getIcon(citationApp, citationEntity, { + w: 16, + h: 16, + mr: 0, + }) + ) : ( + + )} +

+ {citation.title || citation.name} +

+
+

+ {citation.url}

-

- {citation.url} -

-
- ) - }, - )} - - ) : derivedReferenceSearch.length > 0 ? ( -

- No citations found for "{derivedReferenceSearch}". -

- ) : ( -

- Start typing to search citations from this chat. -

- )} - - )} - {searchMode === "global" && ( - <> - {isGlobalLoading && - globalResults.length === 0 && - !globalError && ( + ) + }, + )} + + ) : derivedReferenceSearch.length > 0 ? (

- {currentSearchTerm - ? `Searching for "${currentSearchTerm}"...` - : "Searching..."} + No citations found for "{derivedReferenceSearch}".

- )} - {globalError && ( -

- {globalError} -

- )} - {!isGlobalLoading && - !globalError && - globalResults.length === 0 && - currentSearchTerm && - currentSearchTerm.length > 0 && ( + ) : (

- No results found for "{currentSearchTerm}". + Start typing to search citations from this chat.

)} - {!isGlobalLoading && - !globalError && - globalResults.length === 0 && - (!currentSearchTerm || currentSearchTerm.length === 0) && ( -

- Type to search for documents, messages, and more. + + )} + {searchMode === "global" && ( + <> + {isGlobalLoading && + globalResults.length === 0 && + !globalError && ( +

+ {currentSearchTerm + ? `Searching for "${currentSearchTerm}"...` + : "Searching..."} +

+ )} + {globalError && ( +

+ {globalError}

)} - {globalResults.length > 0 && - enhancedGlobalResults.map((result, index) => { - const displayTitle = - result.text || - result.name || - result.subject || - result.title || - result.filename || - (result.type === "user" && result.email) || - "Untitled" - return ( -
(referenceItemsRef.current[index] = el)} - className={`p-2 cursor-pointer hover:bg-[#EDF2F7] dark:hover:bg-slate-700 rounded-md ${ - index === selectedRefIndex - ? "bg-[#EDF2F7] dark:bg-slate-700" - : "" - }`} - onClick={() => handleSelectGlobalResult(result)} - onMouseEnter={() => setSelectedRefIndex(index)} - > -
- {result.type === "user" && result.photoLink ? ( - {displayTitle} - ) : ( - getIcon(result.app, result.entity, { - w: 16, - h: 16, - mr: 0, - }) - )} -

- -

-
- {result.type !== "user" && ( -

- {result.from - ? `From: ${result.from} | ` - : result.name - ? `From: ${result.name} |` - : ""} - {formatTimestamp( - result.timestamp || result.updatedAt, + {!isGlobalLoading && + !globalError && + globalResults.length === 0 && + currentSearchTerm && + currentSearchTerm.length > 0 && ( +

+ No results found for "{currentSearchTerm}". +

+ )} + {!isGlobalLoading && + !globalError && + globalResults.length === 0 && + (!currentSearchTerm || currentSearchTerm.length === 0) && ( +

+ Type to search for documents, messages, and more. +

+ )} + {globalResults.length > 0 && + enhancedGlobalResults.map((result, index) => { + const displayTitle = + result.text || + result.name || + result.subject || + result.title || + result.filename || + (result.type === "user" && result.email) || + "Untitled" + return ( +
(referenceItemsRef.current[index] = el)} + className={`p-2 cursor-pointer hover:bg-[#EDF2F7] dark:hover:bg-slate-700 rounded-md ${ + index === selectedRefIndex + ? "bg-[#EDF2F7] dark:bg-slate-700" + : "" + }`} + onClick={() => handleSelectGlobalResult(result)} + onMouseEnter={() => setSelectedRefIndex(index)} + > +
+ {result.type === "user" && result.photoLink ? ( + {displayTitle} + ) : ( + getIcon(result.app, result.entity, { + w: 16, + h: 16, + mr: 0, + }) )} -

- )} -
- ) - })} - {!globalError && - globalResults.length > 0 && - globalResults.length < totalCount && ( - - )} - - )} -
-
- )} -
= MAX_ATTACHMENTS - ? "border-amber-300 dark:border-amber-600" - : "" - } ${CLASS_NAMES.SEARCH_CONTAINER}`} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > -
- {isPlaceholderVisible && ( -
- Ask anything across apps... -
- )} -
) => { - e.preventDefault() - - const items = e.clipboardData?.items - if (items) { - // Handle image paste - for (let i = 0; i < items.length; i++) { - const item = items[i] - if (item.type.indexOf("image") !== -1) { - // Check attachment limit before processing - if (selectedFiles.length >= MAX_ATTACHMENTS) { - showToast( - "Attachment limit reached", - `You can only attach up to ${MAX_ATTACHMENTS} files at a time.`, - true, +

+ +

+
+ {result.type !== "user" && ( +

+ {result.from + ? `From: ${result.from} | ` + : result.name + ? `From: ${result.name} |` + : ""} + {formatTimestamp( + result.timestamp || result.updatedAt, + )} +

+ )} +
) - return - } + })} + {!globalError && + globalResults.length > 0 && + globalResults.length < totalCount && ( + + )} + + )} +
+
+ )} +
= MAX_ATTACHMENTS + ? "border-amber-300 dark:border-amber-600" + : "" + } ${CLASS_NAMES.SEARCH_CONTAINER}`} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > +
+ {isPlaceholderVisible && ( +
+ Ask anything across apps... +
+ )} +
) => { + e.preventDefault() - const file = item.getAsFile() - if (file) { - // Process the pasted image file directly - // The file will be processed with a default name by the processFiles function - processFiles([file]) - showToast( - "Image pasted", - "Image has been added to your message.", - false, - ) - return // Exit early since we handled the image + const items = e.clipboardData?.items + if (items) { + // Handle image paste + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.type.indexOf("image") !== -1) { + // Check attachment limit before processing + if (selectedFiles.length >= MAX_ATTACHMENTS) { + showToast( + "Attachment limit reached", + `You can only attach up to ${MAX_ATTACHMENTS} files at a time.`, + true, + ) + return + } + + const file = item.getAsFile() + if (file) { + // Process the pasted image file directly + // The file will be processed with a default name by the processFiles function + processFiles([file]) + showToast( + "Image pasted", + "Image has been added to your message.", + false, + ) + return // Exit early since we handled the image + } } } } - } - // Handle text paste (existing logic) - const pastedText = e.clipboardData?.getData("text/plain") - const currentInput = inputRef.current + // Handle text paste (existing logic) + const pastedText = e.clipboardData?.getData("text/plain") + const currentInput = inputRef.current - if (pastedText && currentInput) { - const selection = window.getSelection() - if (!selection || !selection.rangeCount) return + if (pastedText && currentInput) { + const selection = window.getSelection() + if (!selection || !selection.rangeCount) return - const range = selection.getRangeAt(0) - range.deleteContents() // Clear existing selection or cursor position + const range = selection.getRangeAt(0) + range.deleteContents() // Clear existing selection or cursor position - const segments = pastedText.split(/(\s+)/) - let lastNode: Node | null = null + const segments = pastedText.split(/(\s+)/) + let lastNode: Node | null = null - segments.forEach((segment) => { - if (segment.length === 0) return + segments.forEach((segment) => { + if (segment.length === 0) return - let nodeToInsert: Node - let isLinkNode = false + let nodeToInsert: Node + let isLinkNode = false - if (segment.match(/^\s+$/)) { - // If the segment is just whitespace - nodeToInsert = document.createTextNode(segment) - } else { - // Logic for non-whitespace segments - let isPotentiallyLinkCandidate = false - let urlToParseAttempt = segment - - if (segment.startsWith("www.")) { - urlToParseAttempt = "http://" + segment - isPotentiallyLinkCandidate = true - } else if ( - segment.startsWith("http://") || - segment.startsWith("https://") - ) { - isPotentiallyLinkCandidate = true - } + if (segment.match(/^\s+$/)) { + // If the segment is just whitespace + nodeToInsert = document.createTextNode(segment) + } else { + // Logic for non-whitespace segments + let isPotentiallyLinkCandidate = false + let urlToParseAttempt = segment + + if (segment.startsWith("www.")) { + urlToParseAttempt = "http://" + segment + isPotentiallyLinkCandidate = true + } else if ( + segment.startsWith("http://") || + segment.startsWith("https://") + ) { + isPotentiallyLinkCandidate = true + } - if (isPotentiallyLinkCandidate) { - try { - const url = new URL(urlToParseAttempt) - // Ensure it's an http or https link. - if ( - url.protocol === "http:" || - url.protocol === "https:" - ) { - const anchor = document.createElement("a") - anchor.href = url.href // Use the (potentially modified) href - anchor.textContent = segment // Display the original segment - anchor.target = "_blank" - anchor.rel = "noopener noreferrer" - anchor.className = - "text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300 cursor-pointer" - nodeToInsert = anchor - isLinkNode = true - } else { - // Parsed by new URL(), but not http/https. Treat as text. + if (isPotentiallyLinkCandidate) { + try { + const url = new URL(urlToParseAttempt) + // Ensure it's an http or https link. + if ( + url.protocol === "http:" || + url.protocol === "https:" + ) { + const anchor = document.createElement("a") + anchor.href = url.href // Use the (potentially modified) href + anchor.textContent = segment // Display the original segment + anchor.target = "_blank" + anchor.rel = "noopener noreferrer" + anchor.className = + "text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300 cursor-pointer" + nodeToInsert = anchor + isLinkNode = true + } else { + // Parsed by new URL(), but not http/https. Treat as text. + nodeToInsert = document.createTextNode(segment) + } + } catch (_) { + // Failed to parse with new URL(). Treat as text. nodeToInsert = document.createTextNode(segment) } - } catch (_) { - // Failed to parse with new URL(). Treat as text. + } else { + // Not considered a potential link candidate. Treat as text. nodeToInsert = document.createTextNode(segment) } - } else { - // Not considered a potential link candidate. Treat as text. - nodeToInsert = document.createTextNode(segment) } - } - range.insertNode(nodeToInsert) - lastNode = nodeToInsert + range.insertNode(nodeToInsert) + lastNode = nodeToInsert - if (isLinkNode) { - // If a link was just inserted, add a space after it - const spaceNode = document.createTextNode("\u00A0") - range.setStartAfter(nodeToInsert) - range.insertNode(spaceNode) - lastNode = spaceNode - } + if (isLinkNode) { + // If a link was just inserted, add a space after it + const spaceNode = document.createTextNode("\u00A0") + range.setStartAfter(nodeToInsert) + range.insertNode(spaceNode) + lastNode = spaceNode + } - // Always move the range to be after the last inserted node (content or space) + // Always move the range to be after the last inserted node (content or space) + if (lastNode) { + // Ensure lastNode is not null + range.setStartAfter(lastNode) + range.collapse(true) + } + }) + + // Ensure the cursor is at the very end of all pasted content. if (lastNode) { - // Ensure lastNode is not null range.setStartAfter(lastNode) range.collapse(true) } - }) - - // Ensure the cursor is at the very end of all pasted content. - if (lastNode) { - range.setStartAfter(lastNode) - range.collapse(true) - } - selection.removeAllRanges() - selection.addRange(range) + selection.removeAllRanges() + selection.addRange(range) - // Dispatch an 'input' event to trigger the onInput handler - currentInput.dispatchEvent( - new Event("input", { bubbles: true, cancelable: true }), - ) - } - }} - onInput={(e) => { - const currentInput = inputRef.current - if (!currentInput) return + // Dispatch an 'input' event to trigger the onInput handler + currentInput.dispatchEvent( + new Event("input", { bubbles: true, cancelable: true }), + ) + } + }} + onInput={(e) => { + const currentInput = inputRef.current + if (!currentInput) return - const newValue = currentInput.textContent || "" - setQuery(newValue) - setIsPlaceholderVisible(newValue.length === 0) + const newValue = currentInput.textContent || "" + setQuery(newValue) + setIsPlaceholderVisible(newValue.length === 0) - // The 'references' state and its update logic have been removed. - // Pill management is now primarily through direct DOM interaction - // and parsing the innerHTML when sending the message. + // The 'references' state and its update logic have been removed. + // Pill management is now primarily through direct DOM interaction + // and parsing the innerHTML when sending the message. - const cursorPosition = getCaretCharacterOffsetWithin( - currentInput as Node, - ) + const cursorPosition = getCaretCharacterOffsetWithin( + currentInput as Node, + ) - let shouldTriggerBox = false - let newActiveMentionIndex = -1 - - // Check if the character right before the cursor is an '@' and if it's validly placed - const atCharIndex = cursorPosition - 1 - if (atCharIndex >= 0 && newValue[atCharIndex] === "@") { - const isFirstCharacter = atCharIndex === 0 - const isPrecededBySpace = - atCharIndex > 0 && - (newValue[atCharIndex - 1] === " " || - newValue[atCharIndex - 1] === "\u00A0") - if (isFirstCharacter || isPrecededBySpace) { - shouldTriggerBox = true - newActiveMentionIndex = atCharIndex + let shouldTriggerBox = false + let newActiveMentionIndex = -1 + + // Check if the character right before the cursor is an '@' and if it's validly placed + const atCharIndex = cursorPosition - 1 + if (atCharIndex >= 0 && newValue[atCharIndex] === "@") { + const isFirstCharacter = atCharIndex === 0 + const isPrecededBySpace = + atCharIndex > 0 && + (newValue[atCharIndex - 1] === " " || + newValue[atCharIndex - 1] === "\u00A0") + if (isFirstCharacter || isPrecededBySpace) { + shouldTriggerBox = true + newActiveMentionIndex = atCharIndex + } } - } - if (shouldTriggerBox) { - // A validly placed '@' is at the cursor. Open or keep the box open for this '@'. - if ( - activeAtMentionIndex !== newActiveMentionIndex || - !showReferenceBox - ) { - // It's a new trigger point or the box was closed. Activate for this '@'. - setActiveAtMentionIndex(newActiveMentionIndex) - setShowReferenceBox(true) - updateReferenceBoxPosition(newActiveMentionIndex) - setReferenceSearchTerm("") // Clear search for new mention context - setGlobalResults([]) - setGlobalError(null) - setPage(1) - setTotalCount(0) - setSelectedRefIndex(-1) - setSearchMode("citations") // Default to citations - } - // If activeAtMentionIndex === newActiveMentionIndex and showReferenceBox is true, - // the box is already open for this exact '@'. derivedReferenceSearch will handle query updates. - } else { - // No valid '@' trigger at the current cursor position. - // If a reference box was open, determine if it should be closed. - if (showReferenceBox && activeAtMentionIndex !== -1) { - // Check if the previously active mention (at activeAtMentionIndex) is still valid - // and if the cursor is still actively engaged with it (i.e., after it). - const charAtOldActiveMention = newValue[activeAtMentionIndex] - const oldActiveMentionStillIsAt = - charAtOldActiveMention === "@" - const oldActiveMentionIsFirst = activeAtMentionIndex === 0 - const oldActiveMentionPrecededBySpace = - activeAtMentionIndex > 0 && - (newValue[activeAtMentionIndex - 1] === " " || - newValue[activeAtMentionIndex - 1] === "\u00A0") - const oldActiveMentionStillValidlyPlaced = - oldActiveMentionIsFirst || oldActiveMentionPrecededBySpace - - // Close the box if: - // 1. Cursor has moved to or before the previously active '@'. - // 2. The character at the old activeAtMentionIndex is no longer an '@'. - // 3. The placement of the old active '@' is no longer valid (e.g., preceding space removed). + if (shouldTriggerBox) { + // A validly placed '@' is at the cursor. Open or keep the box open for this '@'. if ( - cursorPosition <= activeAtMentionIndex || - !oldActiveMentionStillIsAt || - !oldActiveMentionStillValidlyPlaced + activeAtMentionIndex !== newActiveMentionIndex || + !showReferenceBox ) { - setShowReferenceBox(false) - setActiveAtMentionIndex(-1) - setReferenceSearchTerm("") // Clear search term when box closes + // It's a new trigger point or the box was closed. Activate for this '@'. + setActiveAtMentionIndex(newActiveMentionIndex) + setShowReferenceBox(true) + updateReferenceBoxPosition(newActiveMentionIndex) + setReferenceSearchTerm("") // Clear search for new mention context + setGlobalResults([]) + setGlobalError(null) + setPage(1) + setTotalCount(0) + setSelectedRefIndex(-1) + setSearchMode("citations") // Default to citations + } + // If activeAtMentionIndex === newActiveMentionIndex and showReferenceBox is true, + // the box is already open for this exact '@'. derivedReferenceSearch will handle query updates. + } else { + // No valid '@' trigger at the current cursor position. + // If a reference box was open, determine if it should be closed. + if (showReferenceBox && activeAtMentionIndex !== -1) { + // Check if the previously active mention (at activeAtMentionIndex) is still valid + // and if the cursor is still actively engaged with it (i.e., after it). + const charAtOldActiveMention = + newValue[activeAtMentionIndex] + const oldActiveMentionStillIsAt = + charAtOldActiveMention === "@" + const oldActiveMentionIsFirst = activeAtMentionIndex === 0 + const oldActiveMentionPrecededBySpace = + activeAtMentionIndex > 0 && + (newValue[activeAtMentionIndex - 1] === " " || + newValue[activeAtMentionIndex - 1] === "\u00A0") + const oldActiveMentionStillValidlyPlaced = + oldActiveMentionIsFirst || oldActiveMentionPrecededBySpace + + // Close the box if: + // 1. Cursor has moved to or before the previously active '@'. + // 2. The character at the old activeAtMentionIndex is no longer an '@'. + // 3. The placement of the old active '@' is no longer valid (e.g., preceding space removed). + if ( + cursorPosition <= activeAtMentionIndex || + !oldActiveMentionStillIsAt || + !oldActiveMentionStillValidlyPlaced + ) { + setShowReferenceBox(false) + setActiveAtMentionIndex(-1) + setReferenceSearchTerm("") // Clear search term when box closes + } + // Otherwise, the box remains open (e.g., user is typing after a valid '@'). } - // Otherwise, the box remains open (e.g., user is typing after a valid '@'). } - } - adjustInputHeight() - }} - onKeyDown={(e) => { - if (showReferenceBox) { - handleReferenceKeyDown( - e as React.KeyboardEvent< - HTMLTextAreaElement | HTMLInputElement - >, - ) - if (e.defaultPrevented) return - } + adjustInputHeight() + }} + onKeyDown={(e) => { + if (showReferenceBox) { + handleReferenceKeyDown( + e as React.KeyboardEvent< + HTMLTextAreaElement | HTMLInputElement + >, + ) + if (e.defaultPrevented) return + } - if (e.key === "Enter" && !e.shiftKey && query.trim().length > 0) { - e.preventDefault() - handleSendMessage() - } - }} - style={{ - minHeight: "52px", - maxHeight: "320px", - }} - onFocus={(e) => { - const target = e.target - setTimeout(() => { - if (document.activeElement === target) { - const len = target.textContent?.length || 0 - setCaretPosition(inputRef.current as Node, len) + if ( + e.key === "Enter" && + !e.shiftKey && + query.trim().length > 0 + ) { + e.preventDefault() + handleSendMessage() } - }, 0) - }} - onClick={(e) => { - const target = e.target as HTMLElement - const anchor = target.closest("a") - - if ( - anchor && - anchor.href && - anchor.closest(SELECTORS.CHAT_INPUT) === inputRef.current - ) { - // If it's an anchor with an href *inside our contentEditable div* - e.preventDefault() // Prevent default contentEditable behavior first - - // Check if the clicked anchor is an "OtherContacts" pill - if (anchor.dataset.entity === "OtherContacts") { - // For "OtherContacts" pills, do nothing further (link should not open) + }} + style={{ + minHeight: "52px", + maxHeight: "320px", + }} + onFocus={(e) => { + const target = e.target + setTimeout(() => { + if (document.activeElement === target) { + const len = target.textContent?.length || 0 + setCaretPosition(inputRef.current as Node, len) + } + }, 0) + }} + onClick={(e) => { + const target = e.target as HTMLElement + const anchor = target.closest("a") + + if ( + anchor && + anchor.href && + anchor.closest(SELECTORS.CHAT_INPUT) === inputRef.current + ) { + // If it's an anchor with an href *inside our contentEditable div* + e.preventDefault() // Prevent default contentEditable behavior first + + // Check if the clicked anchor is an "OtherContacts" pill + if (anchor.dataset.entity === "OtherContacts") { + // For "OtherContacts" pills, do nothing further (link should not open) + return + } + + // For other pills or regular links, open the link in a new tab + window.open(anchor.href, "_blank", "noopener,noreferrer") + // Stop further processing to avoid @mention box logic if a link was clicked return } - // For other pills or regular links, open the link in a new tab - window.open(anchor.href, "_blank", "noopener,noreferrer") - // Stop further processing to avoid @mention box logic if a link was clicked - return - } - - // Original onClick logic for @mention box (if no link was clicked and handled) - // This part was the first onClick handler's body - const cursorPosition = getCaretCharacterOffsetWithin( - inputRef.current as Node, - ) - if ( - showReferenceBox && - activeAtMentionIndex !== -1 && - cursorPosition <= activeAtMentionIndex - ) { - setShowReferenceBox(false) - setActiveAtMentionIndex(-1) - setReferenceSearchTerm("") - } - }} - /> -
+ // Original onClick logic for @mention box (if no link was clicked and handled) + // This part was the first onClick handler's body + const cursorPosition = getCaretCharacterOffsetWithin( + inputRef.current as Node, + ) + if ( + showReferenceBox && + activeAtMentionIndex !== -1 && + cursorPosition <= activeAtMentionIndex + ) { + setShowReferenceBox(false) + setActiveAtMentionIndex(-1) + setReferenceSearchTerm("") + } + }} + /> +
- {/* File Attachments Preview */} - {selectedFiles.length > 0 && ( -
-
- - Attachments ({selectedFiles.length}/{MAX_ATTACHMENTS}) - -
-
- {selectedFiles.map((selectedFile) => ( -
- {selectedFile.preview ? ( -
-
- {selectedFile.uploading && ( -
- -
- )} - {selectedFile.uploadError && ( -
- -
- )} - {selectedFile.metadata?.fileId && ( -
- -
- )} -
- -
- - {selectedFile.file.name.length > 12 - ? `${selectedFile.file.name.substring(0, 9)}...` - : selectedFile.file.name} - -
-
- ) : ( -
- -
- - {selectedFile.file.name} - - - {getExtension(selectedFile.file)} - -
-
- {selectedFile.uploading && ( - - )} - {selectedFile.uploadError && ( - - ⚠️ - - )} - {selectedFile.metadata?.fileId && ( - - )} +
+ {selectedFile.uploading && ( +
+ +
+ )} + {selectedFile.uploadError && ( +
+ +
+ )} + {selectedFile.metadata?.fileId && ( +
+ +
+ )} +
+
+ + {selectedFile.file.name.length > 12 + ? `${selectedFile.file.name.substring(0, 9)}...` + : selectedFile.file.name} + +
-
- )} + ) : ( +
+ +
+ + {selectedFile.file.name} + + + {getExtension(selectedFile.file)} + +
+
+ {selectedFile.uploading && ( + + )} + {selectedFile.uploadError && ( + + ⚠️ + + )} + {selectedFile.metadata?.fileId && ( + + )} + +
+
+ )} +
+ ))} +
+ {isUploadingFiles && ( +
+ + Uploading files... +
+ )} + {selectedFiles.length >= MAX_ATTACHMENTS && ( +
+ ⚠️ + Maximum attachments reached ({MAX_ATTACHMENTS})
- ))} + )}
- {isUploadingFiles && ( -
- - Uploading files... -
- )} - {selectedFiles.length >= MAX_ATTACHMENTS && ( -
- ⚠️ - Maximum attachments reached ({MAX_ATTACHMENTS}) -
- )} -
- )} - -
- = MAX_ATTACHMENTS - ? "text-gray-300 dark:text-gray-600 cursor-not-allowed" - : "text-[#464D53] dark:text-gray-400 cursor-pointer hover:text-[#2563eb] dark:hover:text-blue-400" - } transition-colors`} - onClick={ - selectedFiles.length >= MAX_ATTACHMENTS - ? undefined - : handleFileSelect - } - title={ - selectedFiles.length >= MAX_ATTACHMENTS - ? `Maximum ${MAX_ATTACHMENTS} attachments allowed` - : "Attach files" - } - /> - {showAdvancedOptions && ( - <> - - { - const input = inputRef.current - if (!input) return - - const textContentBeforeAt = input.textContent || "" - - const textToAppend = - textContentBeforeAt.length === 0 || - textContentBeforeAt.endsWith(" ") || - textContentBeforeAt.endsWith("\n") || - textContentBeforeAt.endsWith("\u00A0") - ? "@" - : " @" - - const atTextNode = document.createTextNode(textToAppend) - - input.appendChild(atTextNode) - - const newTextContent = input.textContent || "" - setQuery(newTextContent) - setIsPlaceholderVisible(newTextContent.length === 0) - - const newAtSymbolIndex = - textContentBeforeAt.length + (textToAppend === " @" ? 1 : 0) - setCaretPosition(input, newTextContent.length) - - setActiveAtMentionIndex(newAtSymbolIndex) - setReferenceSearchTerm("") - setShowReferenceBox(true) - updateReferenceBoxPosition(newAtSymbolIndex) - setSearchMode("citations") - setGlobalResults([]) - setGlobalError(null) - setPage(1) - setTotalCount(0) - setSelectedRefIndex(-1) - - input.focus() - }} - /> - )} - {/* Dropdown for All Connectors */} - {showAdvancedOptions && - (role === UserRole.SuperAdmin || role === UserRole.Admin) && ( - { - if (isAgenticMode) { - setIsConnectorsMenuOpen(open) - } - }} - > - - - - - - Select a Connector - - - {allConnectors.length > 0 ? ( - allConnectors - .filter((c) => c.type === ConnectorType.MCP) - .map((connector) => { - const isMCP = connector.type === ConnectorType.MCP - - return ( - e.preventDefault()} // Prevent closing on item click if it has a sub-menu - className="p-0" // Remove padding for full-width item - > -
{ - const isCurrentlySelected = - selectedConnectorIds.has(connector.id) - - if (isCurrentlySelected) { - // If clicking the already selected connector, deselect it - setSelectedConnectorIds((prev) => { - const newSet = new Set(prev) - newSet.delete(connector.id) - return newSet - }) - setConnectorTools([]) // Clear any tools - // Close the tool selection modal if it's open for this connector - if ( - isToolSelectionModalOpen && - activeToolConnectorId === connector.id - ) { - setIsToolSelectionModalOpen(false) - setActiveToolConnectorId(null) - } - // Keep the main dropdown open or close it based on desired UX for deselection. - // For now, let's assume it stays open. - } else { - // Clicking a new connector to add it to selection - setSelectedConnectorIds((prev) => { - const newSet = new Set(prev) - newSet.add(connector.id) - return newSet - }) - if (!isMCP) { - // For non-MCP connectors, preserve existing selections or initialize empty - setConnectorTools([]) - // Don't override existing selections, they should be preserved from localStorage - if (!selectedConnectorTools[connector.id]) { - setSelectedConnectorTools((prev) => ({ - ...prev, - [connector.id]: new Set(), - })) + )} + + + + + + Select a Connector + + + {allConnectors.length > 0 ? ( + allConnectors + .filter((c) => c.type === ConnectorType.MCP) + .map((connector) => { + const isMCP = connector.type === ConnectorType.MCP + + return ( + e.preventDefault()} // Prevent closing on item click if it has a sub-menu + className="p-0" // Remove padding for full-width item + > +
{ + const isCurrentlySelected = + selectedConnectorIds.has(connector.id) + + if (isCurrentlySelected) { + // If clicking the already selected connector, deselect it + setSelectedConnectorIds((prev) => { + const newSet = new Set(prev) + newSet.delete(connector.id) + return newSet + }) + setConnectorTools([]) // Clear any tools + // Close the tool selection modal if it's open for this connector + if ( + isToolSelectionModalOpen && + activeToolConnectorId === connector.id + ) { + setIsToolSelectionModalOpen(false) + setActiveToolConnectorId(null) } - // Don't close dropdown for multiple selections + // Keep the main dropdown open or close it based on desired UX for deselection. + // For now, let's assume it stays open. } else { - // For MCP connectors, clear tools from any previously selected MCP. - setConnectorTools([]) - // Tool fetching for this MCP connector is handled by PlusCircle click. - // Dropdown stays open. - } - } - }} - > -
- {getIcon(connector.app, connector.type, { - w: 14, - h: 14, - mr: 0, - })} - - {connector.displayName} - -
- - {/* Icons container - aligned to the right */} -
- {selectedConnectorIds.has(connector.id) && ( - - )} - {isMCP && ( - { - e.stopPropagation() // IMPORTANT: Prevent main item click handler - - // Ensure this connector is marked as active if not already + // Clicking a new connector to add it to selection + setSelectedConnectorIds((prev) => { + const newSet = new Set(prev) + newSet.add(connector.id) + return newSet + }) + if (!isMCP) { + // For non-MCP connectors, preserve existing selections or initialize empty + setConnectorTools([]) + // Don't override existing selections, they should be preserved from localStorage if ( - !selectedConnectorIds.has(connector.id) - ) { - setSelectedConnectorIds((prev) => { - const newSet = new Set(prev) - newSet.add(connector.id) - return newSet - }) - setConnectorTools([]) // Clear tools if switching to this MCP - } else if ( - !connectorTools.length && - selectedConnectorIds.has(connector.id) + !selectedConnectorTools[connector.id] ) { - // If it's already selected but tools aren't loaded (e.g. re-opening modal) - // proceed to load them. + setSelectedConnectorTools((prev) => ({ + ...prev, + [connector.id]: new Set(), + })) } - - // Set this connector as the active one for tool selection - setActiveToolConnectorId(connector.id) - - if ( - connectorsDropdownTriggerRef.current - ) { - const rect = - connectorsDropdownTriggerRef.current.getBoundingClientRect() - const chatBoxContainer = - inputRef.current?.closest( - ".relative.flex.flex-col.w-full", - ) as HTMLElement | null - const connectorDropdownWidth = 288 // w-72 - const gap = 8 - - let preliminaryLeftCalc: number - let preliminaryTopReference: number - - if (chatBoxContainer) { - const containerRect = - chatBoxContainer.getBoundingClientRect() - preliminaryLeftCalc = - rect.left - - containerRect.left + - connectorDropdownWidth + - gap - preliminaryTopReference = - rect.top - containerRect.top - } else { - preliminaryLeftCalc = - rect.left + - connectorDropdownWidth + - gap - preliminaryTopReference = rect.top + // Don't close dropdown for multiple selections + } else { + // For MCP connectors, clear tools from any previously selected MCP. + setConnectorTools([]) + // Tool fetching for this MCP connector is handled by PlusCircle click. + // Dropdown stays open. + } + } + }} + > +
+ {getIcon(connector.app, connector.type, { + w: 14, + h: 14, + mr: 0, + })} + + {connector.displayName} + +
+ + {/* Icons container - aligned to the right */} +
+ {selectedConnectorIds.has(connector.id) && ( + + )} + {isMCP && ( + { + e.stopPropagation() // IMPORTANT: Prevent main item click handler + + // Ensure this connector is marked as active if not already + if ( + !selectedConnectorIds.has( + connector.id, + ) + ) { + setSelectedConnectorIds((prev) => { + const newSet = new Set(prev) + newSet.add(connector.id) + return newSet + }) + setConnectorTools([]) // Clear tools if switching to this MCP + } else if ( + !connectorTools.length && + selectedConnectorIds.has(connector.id) + ) { + // If it's already selected but tools aren't loaded (e.g. re-opening modal) + // proceed to load them. } - setToolModalPosition({ - top: preliminaryTopReference, - left: preliminaryLeftCalc, - }) - } - setIsLoadingTools(true) - setToolSearchTerm("") - try { - const response: Response = - await api.admin.connector[ - connector.id - ].tools.$get(undefined, { - credentials: "include", + // Set this connector as the active one for tool selection + setActiveToolConnectorId(connector.id) + + if ( + connectorsDropdownTriggerRef.current + ) { + const rect = + connectorsDropdownTriggerRef.current.getBoundingClientRect() + const chatBoxContainer = + inputRef.current?.closest( + ".relative.flex.flex-col.w-full", + ) as HTMLElement | null + const connectorDropdownWidth = 288 // w-72 + const gap = 8 + + let preliminaryLeftCalc: number + let preliminaryTopReference: number + + if (chatBoxContainer) { + const containerRect = + chatBoxContainer.getBoundingClientRect() + preliminaryLeftCalc = + rect.left - + containerRect.left + + connectorDropdownWidth + + gap + preliminaryTopReference = + rect.top - containerRect.top + } else { + preliminaryLeftCalc = + rect.left + + connectorDropdownWidth + + gap + preliminaryTopReference = rect.top + } + setToolModalPosition({ + top: preliminaryTopReference, + left: preliminaryLeftCalc, }) - const toolsData: FetchedTool[] | any = - await response.json() - if (Array.isArray(toolsData)) { - const enabledTools = toolsData.filter( - (tool) => tool.enabled, - ) - setConnectorTools(enabledTools) - - // Check if we have existing selections from localStorage for this connector - const existingSelections = - selectedConnectorTools[connector.id] - - if ( - existingSelections && - existingSelections.size > 0 - ) { - // Use existing selections from localStorage, but only for enabled tools - const enabledToolExternalIds = - new Set( - enabledTools.map( - (t) => t.externalId, - ), + } + + setIsLoadingTools(true) + setToolSearchTerm("") + try { + const response: Response = + await api.admin.connector[ + connector.id + ].tools.$get(undefined, { + credentials: "include", + }) + const toolsData: FetchedTool[] | any = + await response.json() + if (Array.isArray(toolsData)) { + const enabledTools = + toolsData.filter( + (tool) => tool.enabled, ) - const validSelections = new Set( - Array.from( - existingSelections, - ).filter((toolExternalId) => - enabledToolExternalIds.has( - toolExternalId, + setConnectorTools(enabledTools) + + // Check if we have existing selections from localStorage for this connector + const existingSelections = + selectedConnectorTools[ + connector.id + ] + + if ( + existingSelections && + existingSelections.size > 0 + ) { + // Use existing selections from localStorage, but only for enabled tools + const enabledToolExternalIds = + new Set( + enabledTools.map( + (t) => t.externalId, + ), + ) + const validSelections = new Set( + Array.from( + existingSelections, + ).filter((toolExternalId) => + enabledToolExternalIds.has( + toolExternalId, + ), ), - ), - ) + ) - setSelectedConnectorTools( - (prev) => ({ - ...prev, - [connector.id]: validSelections, - }), - ) - } else { - // No existing selections, default to all enabled tools being selected - const initiallySelectedEnabledTools = - new Set( - enabledTools.map( - (t) => t.externalId, - ), + setSelectedConnectorTools( + (prev) => ({ + ...prev, + [connector.id]: + validSelections, + }), + ) + } else { + // No existing selections, default to all enabled tools being selected + const initiallySelectedEnabledTools = + new Set( + enabledTools.map( + (t) => t.externalId, + ), + ) + setSelectedConnectorTools( + (prev) => ({ + ...prev, + [connector.id]: + initiallySelectedEnabledTools, + }), ) + } + } else { + setConnectorTools([]) + // Ensure no selections if tools aren't loaded correctly or if data is not an array setSelectedConnectorTools( (prev) => ({ ...prev, - [connector.id]: - initiallySelectedEnabledTools, + [connector.id]: new Set(), }), ) } - } else { + } catch (error) { + console.error( + `Error fetching tools for ${connector.id}:`, + error, + ) setConnectorTools([]) - // Ensure no selections if tools aren't loaded correctly or if data is not an array + // Clear selections for this connector on error setSelectedConnectorTools((prev) => ({ ...prev, [connector.id]: new Set(), })) + } finally { + setIsLoadingTools(false) + setIsToolSelectionModalOpen(true) + // Main dropdown (isConnectorsMenuOpen) should remain open } - } catch (error) { - console.error( - `Error fetching tools for ${connector.id}:`, - error, - ) - setConnectorTools([]) - // Clear selections for this connector on error - setSelectedConnectorTools((prev) => ({ - ...prev, - [connector.id]: new Set(), - })) - } finally { - setIsLoadingTools(false) - setIsToolSelectionModalOpen(true) - // Main dropdown (isConnectorsMenuOpen) should remain open - } - }} - /> - )} + }} + /> + )} +
-
-
- ) - }) - ) : ( - - No connectors available - - )} -
- - )} + + ) + }) + ) : ( + + No connectors available + + )} + + + )} - {/* Tool Selection Modal / Popover */} - {showAdvancedOptions && - isToolSelectionModalOpen && - activeToolConnectorId && - toolModalPosition && - allConnectors.find((c) => c.id === activeToolConnectorId)?.type === - ConnectorType.MCP && ( -
e.stopPropagation()} // Prevent clicks inside from closing it if it's part of a larger clickable area - > -
-
-

- Tools for{" "} - { - allConnectors.find( - (c) => c.id === activeToolConnectorId, - )?.displayName - } -

- -
- setToolSearchTerm(e.target.value)} - className="mb-2 text-sm" - /> - {isLoadingTools ? ( -

- Loading tools... -

- ) : ( -
- {" "} - {/* Explicit max-height for scrolling */} - {connectorTools.filter((tool) => - tool.toolName - .toLowerCase() - .includes(toolSearchTerm.toLowerCase()), - ).length > 0 ? ( - connectorTools - .filter((tool) => - tool.toolName - .toLowerCase() - .includes(toolSearchTerm.toLowerCase()), - ) - .map((tool) => ( -
{ - setSelectedConnectorTools((prev) => { - const newSelected = new Set( - prev[activeToolConnectorId!] || [], - ) - if (newSelected.has(tool.externalId)) { - newSelected.delete(tool.externalId) - } else { - newSelected.add(tool.externalId) - } - return { - ...prev, - [activeToolConnectorId!]: newSelected, - } - }) - }} - > - + + + +
+ setToolSearchTerm(e.target.value)} + className="mb-2 text-sm" + /> + {isLoadingTools ? ( +

+ Loading tools... +

+ ) : ( +
+ {" "} + {/* Explicit max-height for scrolling */} + {connectorTools.filter((tool) => + tool.toolName + .toLowerCase() + .includes(toolSearchTerm.toLowerCase()), + ).length > 0 ? ( + connectorTools + .filter((tool) => + tool.toolName + .toLowerCase() + .includes(toolSearchTerm.toLowerCase()), + ) + .map((tool) => ( +
{ + setSelectedConnectorTools((prev) => { + const newSelected = new Set( + prev[activeToolConnectorId!] || [], + ) + if (newSelected.has(tool.externalId)) { + newSelected.delete(tool.externalId) + } else { + newSelected.add(tool.externalId) + } + return { + ...prev, + [activeToolConnectorId!]: newSelected, + } + }) + }} > - {tool.toolName} - -
- {( - selectedConnectorTools[ - activeToolConnectorId! - ] || new Set() - ).has(tool.externalId) && ( - - )} + + {tool.toolName} + +
+ {( + selectedConnectorTools[ + activeToolConnectorId! + ] || new Set() + ).has(tool.externalId) && ( + + )} +
-
- )) - ) : ( -

- No tools found - {toolSearchTerm ? ` for "${toolSearchTerm}"` : ""}. -

- )} -
- )} - {/* "Done" button can be removed if selection is immediate, or kept for explicit confirmation */} - {/*
+ )} + {/* "Done" button can be removed if selection is immediate, or kept for explicit confirmation */} + {/* */} +
-
- )} + )} - {showSourcesButton && ( // Added this condition because currently it's backend is not ready therefore we are not showing it - - - - - -
- - Filter Sources - - {selectedSourcesCount > 0 ? ( - - - - + + +
+ + Filter Sources + + {selectedSourcesCount > 0 ? ( + + + + + + - - - - -

Clear all

-
-
-
- ) : ( - - )} -
- - {availableSources.map((source) => { - const isChecked = selectedSources[source.id] || false - return ( - - handleSourceSelectionChange(source.id, !isChecked) - } - onSelect={(e) => e.preventDefault()} - className="relative flex items-center pl-2 pr-2 gap-2 cursor-pointer" - > -
- {getIcon(source.app, source.entity, { - w: 16, - h: 16, - mr: 0, - })} - {source.name} -
-
Clear all

+ + + + ) : ( +
-
- ) - })} -
- - )} - {/* Closing tag for the conditional render */} -
- + )} +
+ + {availableSources.map((source) => { + const isChecked = selectedSources[source.id] || false + return ( + + handleSourceSelectionChange(source.id, !isChecked) + } + onSelect={(e) => e.preventDefault()} + className="relative flex items-center pl-2 pr-2 gap-2 cursor-pointer" + > +
+ {getIcon(source.app, source.entity, { + w: 16, + h: 16, + mr: 0, + })} + {source.name} +
+
+ {isChecked && ( + + )} +
+
+ ) + })} + + + )} + {/* Closing tag for the conditional render */} +
+ -
- {showAdvancedOptions && ( - - )} - {(isStreaming || retryIsStreaming) && chatId ? ( - - ) : ( - - )} + + Reasoning + + +
+ {showAdvancedOptions && ( + + )} + {(isStreaming || retryIsStreaming) && chatId ? ( + + ) : ( + + )} +
-
- {/* Hidden File Input */} - -
- ) -}) + {/* Hidden File Input */} + +
+ ) + }, +) diff --git a/frontend/src/components/GroupFilter.tsx b/frontend/src/components/GroupFilter.tsx index fd1b6eee2..71be16b20 100644 --- a/frontend/src/components/GroupFilter.tsx +++ b/frontend/src/components/GroupFilter.tsx @@ -8,6 +8,7 @@ import { isMailAttachment, SlackEntity, SystemEntity, + WebSearchEntity, } from "shared/types" import { Filter, Groups } from "@/types" import { getIcon } from "@/lib/common" @@ -112,6 +113,8 @@ export const getName = (app: Apps, entity: Entity): string => { return "Data-Source" } else if (app === Apps.KnowledgeBase && entity === SystemEntity.SystemInfo) { return "Knowledge-Base" + } else if (app === Apps.WebSearch && entity === WebSearchEntity.WebSearch) { + return "Web Search" } else { throw new Error(`Invalid app ${app} and entity ${entity}`) } diff --git a/frontend/src/hooks/useChatStream.ts b/frontend/src/hooks/useChatStream.ts index 890e3f131..3365f8b39 100644 --- a/frontend/src/hooks/useChatStream.ts +++ b/frontend/src/hooks/useChatStream.ts @@ -171,15 +171,14 @@ const notifySubscribers = (streamId: string) => { } } - // Helper function to append reasoning data to stream state const appendReasoningData = (streamState: StreamState, data: string) => { try { const stepData = JSON.parse(data) - + // If this is a valid reasoning step, add it as a new line if (stepData.step || stepData.text) { - streamState.thinking += data + '\n' + streamState.thinking += data + "\n" } else { // Fallback to simple text accumulation streamState.thinking += data @@ -221,7 +220,6 @@ export async function createAuthEventSource(url: string): Promise { make() }) - } // Start a new stream or continue existing one @@ -237,6 +235,7 @@ export const startStream = async ( agentIdFromChatParams?: string | null, toolsList?: ToolsListItem[], metadata?: AttachmentMetadata[], + enableWebSearch: boolean = false, ): Promise => { if (!messageToSend) return @@ -281,6 +280,8 @@ export const startStream = async ( url.searchParams.append("isReasoningEnabled", "true") } + url.searchParams.append("enableWebSearch", enableWebSearch.toString()) + // Add toolsList parameter if provided if (toolsList && toolsList.length > 0) { url.searchParams.append("toolsList", JSON.stringify(toolsList)) @@ -623,6 +624,7 @@ export const useChatStream = ( agentIdFromChatParams?: string | null, toolsList?: ToolsListItem[], metadata?: AttachmentMetadata[], + enableWebSearch: boolean = false, ) => { const streamKey = currentStreamKey @@ -638,6 +640,7 @@ export const useChatStream = ( agentIdFromChatParams, toolsList, metadata, + enableWebSearch, ) setStreamInfo(getStreamState(streamKey)) @@ -866,7 +869,6 @@ export const useChatStream = ( }) eventSource.addEventListener(ChatSSEvents.Reasoning, (event) => { - appendReasoningData(streamState, event.data) patchReasoningContent(event.data) }) diff --git a/frontend/src/lib/common.tsx b/frontend/src/lib/common.tsx index b5b6abf1c..898240579 100644 --- a/frontend/src/lib/common.tsx +++ b/frontend/src/lib/common.tsx @@ -7,6 +7,7 @@ import { PlugZap, Github, BookOpen, + Globe, } from "lucide-react" // Added FileText, CalendarDays, PlugZap, Github, BookOpen import DocsSvg from "@/assets/docs.svg" // Added this line import SlidesSvg from "@/assets/slides.svg" @@ -31,6 +32,7 @@ import { ConnectorType, SystemEntity, DataSourceEntity, + WebSearchEntity, } from "shared/types" import { LoadingSpinner } from "@/routes/_authenticated/admin/integrations/google" @@ -124,24 +126,23 @@ export const getIcon = ( return } else if (app === Apps.DataSource && entity === "file") { return - } else if (app === Apps.KnowledgeBase && - entity === SystemEntity.SystemInfo - ) { + } else if (app === Apps.KnowledgeBase && entity === SystemEntity.SystemInfo) { return ( - ) - } - else if ( + } else if ( (app === Apps.Github && entity === ConnectorType.MCP) || entity === SystemEntity.SystemInfo ) { return } else if (entity === SystemEntity.SystemInfo) { return // Fallback for other MCPs + } else if (app === Apps.WebSearch && entity === WebSearchEntity.WebSearch) { + return } else if ( app === Apps.DataSource && entity === DataSourceEntity.DataSourceFile diff --git a/frontend/src/routes/_authenticated/agent.tsx b/frontend/src/routes/_authenticated/agent.tsx index 01e0b86db..d817acfb8 100644 --- a/frontend/src/routes/_authenticated/agent.tsx +++ b/frontend/src/routes/_authenticated/agent.tsx @@ -185,8 +185,7 @@ const availableIntegrationsList: IntegrationSource[] = [ const AGENT_ENTITY_SEARCH_EXCLUSIONS: { app: string; entity: string }[] = [ { app: Apps.Slack, entity: SlackEntity.Message }, - {app:Apps.Slack,entity:SlackEntity.User} - + { app: Apps.Slack, entity: SlackEntity.User }, ] interface User { @@ -199,7 +198,7 @@ interface CollectionItem { id: string collectionId: string path?: string - type?: 'collection' | 'folder' | 'file' + type?: "collection" | "folder" | "file" name?: string } @@ -208,50 +207,57 @@ function isItemSelectedWithInheritance( item: CollectionItem, selectedItemsInCollection: Record>, selectedIntegrations: Record, - selectedItemDetailsInCollection: Record> + selectedItemDetailsInCollection: Record< + string, + Record + >, ): boolean { const collectionId = item.collectionId if (!collectionId) return false - + const selectedSet = selectedItemsInCollection[collectionId] || new Set() - + // Check if item is directly selected if (selectedSet.has(item.id)) { return true } - + // Check if collection is in selectAll mode - const hasCollectionIntegrationSelected = !!selectedIntegrations[`cl_${collectionId}`] - const isCollectionSelectAll = hasCollectionIntegrationSelected && selectedSet.size === 0 + const hasCollectionIntegrationSelected = + !!selectedIntegrations[`cl_${collectionId}`] + const isCollectionSelectAll = + hasCollectionIntegrationSelected && selectedSet.size === 0 if (isCollectionSelectAll) { return true } - + // Check if any parent folder is selected (inheritance) - if (item.path && item.type !== 'collection') { + if (item.path && item.type !== "collection") { const itemDetails = selectedItemDetailsInCollection[collectionId] || {} - + // Check if any selected folder in this collection is a parent of this item for (const selectedId of selectedSet) { const selectedItemDetail = itemDetails[selectedId] - if (selectedItemDetail && selectedItemDetail.type === 'folder') { - const folderPath = selectedItemDetail.path || '' - const itemPath = item.path || '' - + if (selectedItemDetail && selectedItemDetail.type === "folder") { + const folderPath = selectedItemDetail.path || "" + const itemPath = item.path || "" + // Normalize paths by removing leading/trailing slashes - const normalizedFolderPath = folderPath.replace(/^\/+|\/+$/g, '') - const normalizedItemPath = itemPath.replace(/^\/+|\/+$/g, '') - + const normalizedFolderPath = folderPath.replace(/^\/+|\/+$/g, "") + const normalizedItemPath = itemPath.replace(/^\/+|\/+$/g, "") + // Check if this item's path starts with the selected folder's path - if (normalizedItemPath.startsWith(normalizedFolderPath + '/') || - (normalizedFolderPath === '' && normalizedItemPath !== '') || - normalizedItemPath === normalizedFolderPath) { + if ( + normalizedItemPath.startsWith(normalizedFolderPath + "/") || + (normalizedFolderPath === "" && normalizedItemPath !== "") || + normalizedItemPath === normalizedFolderPath + ) { return true } } } } - + return false } @@ -310,14 +316,15 @@ function AgentComponent() { const [selectedItemsInCollection, setSelectedItemsInCollection] = useState< Record> >({}) - const [selectedItemDetailsInCollection, setSelectedItemDetailsInCollection] = useState< - Record> - >({}) + const [selectedItemDetailsInCollection, setSelectedItemDetailsInCollection] = + useState>>({}) // Store mapping of integration IDs to their names and types const [integrationIdToNameMap, setIntegrationIdToNameMap] = useState< Record >({}) - const [navigationPath, setNavigationPath] = useState>([]) + const [navigationPath, setNavigationPath] = useState< + Array<{ id: string; name: string; type: "cl-root" | "cl" | "folder" }> + >([]) const [currentItems, setCurrentItems] = useState([]) const [isLoadingItems, setIsLoadingItems] = useState(false) const [dropdownSearchQuery, setDropdownSearchQuery] = useState("") @@ -338,10 +345,10 @@ function AgentComponent() { query: { query: dropdownSearchQuery, type: "all", // Search collections, folders, and files - limit: 20 - } + limit: 20, + }, }) - + if (response.ok) { const data = await response.json() // Transform the results to match the expected format @@ -358,20 +365,20 @@ function AgentComponent() { mimeType: item.mimeType, fileSize: item.fileSize, description: item.description, - metadata: item.metadata + metadata: item.metadata, })) setSearchResults(transformedResults) } else { setSearchResults([]) } } catch (error) { - console.error('Knowledge base search failed:', error) + console.error("Knowledge base search failed:", error) setSearchResults([]) } finally { setIsSearching(false) } } - + const debounceSearch = setTimeout(performGlobalSearch, 300) return () => clearTimeout(debounceSearch) }, [dropdownSearchQuery]) @@ -424,8 +431,8 @@ function AgentComponent() { const response = await api.search.$get({ query: { query: entitySearchQuery, - app:Apps.Slack, - isAgentIntegSearch:true + app: Apps.Slack, + isAgentIntegSearch: true, }, }) @@ -683,9 +690,9 @@ function AgentComponent() { // Fetch both data sources and collections in parallel const [dsResponse, clResponse] = await Promise.all([ api.datasources.$get(), - api.cl.$get() + api.cl.$get(), ]) - + if (dsResponse.ok) { const data = await dsResponse.json() setFetchedDataSources(data as FetchedDataSource[]) @@ -697,7 +704,7 @@ function AgentComponent() { }) setFetchedDataSources([]) } - + if (clResponse.ok) { const clData = await clResponse.json() setFetchedCollections(clData) @@ -964,7 +971,7 @@ function AgentComponent() { if (!isRagOn) { return dynamicDataSources } - + const collectionSources: IntegrationSource[] = fetchedCollections.map( (cl) => ({ id: `cl_${cl.id}`, @@ -972,7 +979,17 @@ function AgentComponent() { app: "knowledge-base", entity: "cl", icon: ( - + @@ -981,7 +998,11 @@ function AgentComponent() { ), }), ) - return [...availableIntegrationsList, ...dynamicDataSources, ...collectionSources] + return [ + ...availableIntegrationsList, + ...dynamicDataSources, + ...collectionSources, + ] }, [fetchedDataSources, isRagOn, fetchedCollections]) useEffect(() => { @@ -994,77 +1015,98 @@ function AgentComponent() { setAgentPrompt(editingAgent.prompt || "") setIsPublic(editingAgent.isPublic || false) setSelectedModel(editingAgent.model) - + // Fetch integration items for this agent const fetchAgentIntegrationItems = async () => { try { - const response = await api.agent[":agentExternalId"]["integration-items"].$get({ + const response = await api.agent[":agentExternalId"][ + "integration-items" + ].$get({ param: { agentExternalId: editingAgent.externalId }, }) if (response.ok) { const data = await response.json() - const idToNameMapping: Record = {}; + const idToNameMapping: Record< + string, + { name: string; type: string } + > = {} // Extract items and build ID to name mapping - if (data.integrationItems.collection && data.integrationItems.collection.groups) { - for (const [clGroupId, items] of Object.entries(data.integrationItems.collection.groups)) { + if ( + data.integrationItems.collection && + data.integrationItems.collection.groups + ) { + for (const [clGroupId, items] of Object.entries( + data.integrationItems.collection.groups, + )) { if (Array.isArray(items)) { // For knowledge-base items, use the data directly from the API response items.forEach((item: any) => { - const itemType = item.type || "file"; + const itemType = item.type || "file" idToNameMapping[item.id] = { name: item.name || item.id || "Unnamed", - type: itemType - }; - }); + type: itemType, + } + }) } - + // Also add CL group ID to name mapping if available if (clGroupId) { // Try to find the CL name from the fetched collections - const cl = fetchedCollections.find(cl => cl.id === clGroupId); + const cl = fetchedCollections.find( + (cl) => cl.id === clGroupId, + ) if (cl) { idToNameMapping[clGroupId] = { name: cl.name, - type: "collection" - }; + type: "collection", + } } } } } // Update the ID to name mapping state - setIntegrationIdToNameMap(idToNameMapping); - + setIntegrationIdToNameMap(idToNameMapping) + // Process collection items if they exist - if (data.integrationItems.collection && data.integrationItems.collection.groups) { + if ( + data.integrationItems.collection && + data.integrationItems.collection.groups + ) { const clSelections: Record> = {} const clDetails: Record> = {} - + // Process each collection group - for (const [clId, items] of Object.entries(data.integrationItems.collection.groups)) { + for (const [clId, items] of Object.entries( + data.integrationItems.collection.groups, + )) { if (Array.isArray(items) && items.length > 0) { const selectedItems = new Set() const itemDetails: Record = {} - + // Check if this is a collection-level selection - const hasCollectionLevelSelection = items.some((item: any) => item.isCollectionLevel) - + const hasCollectionLevelSelection = items.some( + (item: any) => item.isCollectionLevel, + ) + if (hasCollectionLevelSelection) { // This is a collection-level selection (entire collection selected) // Mark the Collection integration as selected but no specific items - setSelectedIntegrations(prev => ({ + setSelectedIntegrations((prev) => ({ ...prev, - [`cl_${clId}`]: true + [`cl_${clId}`]: true, })) - + // Add collection to name mapping - const collectionItem = items.find((item: any) => item.isCollectionLevel) + const collectionItem = items.find( + (item: any) => item.isCollectionLevel, + ) if (collectionItem) { idToNameMapping[clId] = { name: collectionItem.name, - type: "collection" - }; + type: "collection", + } } } else { // These are specific file/folder selections @@ -1076,56 +1118,59 @@ function AgentComponent() { name: item.name || item.id || "Unnamed", type: item.type || "file", path: item.path, - collectionId: clId + collectionId: clId, } - + // Add to name mapping idToNameMapping[item.id] = { name: item.name || item.id || "Unnamed", - type: item.type || "file" - }; + type: item.type || "file", + } } }) - + if (selectedItems.size > 0) { clSelections[clId] = selectedItems clDetails[clId] = itemDetails - + // Mark the Collection integration as selected - setSelectedIntegrations(prev => ({ + setSelectedIntegrations((prev) => ({ ...prev, - [`cl_${clId}`]: true + [`cl_${clId}`]: true, })) } } - + // Add collection to name mapping if not already added if (!idToNameMapping[clId]) { - const cl = fetchedCollections.find(cl => cl.id === clId); + const cl = fetchedCollections.find((cl) => cl.id === clId) if (cl) { idToNameMapping[clId] = { name: cl.name, - type: "collection" - }; + type: "collection", + } } } } } - + setSelectedItemsInCollection(clSelections) setSelectedItemDetailsInCollection(clDetails) } - + // Update the ID to name mapping state - setIntegrationIdToNameMap(idToNameMapping); + setIntegrationIdToNameMap(idToNameMapping) } else { - console.warn("Failed to fetch agent integration items:", response.statusText) + console.warn( + "Failed to fetch agent integration items:", + response.statusText, + ) } } catch (error) { console.error("Error fetching agent integration items:", error) } } - + fetchAgentIntegrationItems() } }, [editingAgent, viewMode, fetchedCollections]) @@ -1139,34 +1184,42 @@ function AgentComponent() { const currentIntegrations: Record = {} const clSelections: Record> = {} const clDetails: Record> = {} - + allAvailableIntegrations.forEach((int) => { // Handle legacy array format if (Array.isArray(editingAgent.appIntegrations)) { - currentIntegrations[int.id] = editingAgent.appIntegrations.includes(int.id) || false - } else if (editingAgent.appIntegrations && typeof editingAgent.appIntegrations === 'object') { + currentIntegrations[int.id] = + editingAgent.appIntegrations.includes(int.id) || false + } else if ( + editingAgent.appIntegrations && + typeof editingAgent.appIntegrations === "object" + ) { // Handle both old and new object formats - const appIntegrations = editingAgent.appIntegrations as Record - + const appIntegrations = editingAgent.appIntegrations as Record< + string, + any + > + // Check if it's a collection - if (int.id.startsWith('cl_')) { - const clId = int.id.replace('cl_', '') - + if (int.id.startsWith("cl_")) { + const clId = int.id.replace("cl_", "") + // Handle new format: knowledge_base key with itemIds array - if (appIntegrations['knowledge_base']) { - const clConfig = appIntegrations['knowledge_base'] + if (appIntegrations["knowledge_base"]) { + const clConfig = appIntegrations["knowledge_base"] const itemIds = clConfig.itemIds || [] - + // Check if this CL is referenced in the itemIds - const isClSelected = itemIds.some((id: string) => - id === `cl-${clId}` || // Collection-level selection - id.startsWith(`clfd-${clId}`) || // Folder in this collection - id.startsWith(`clf-${clId}`) // File in this collection + const isClSelected = itemIds.some( + (id: string) => + id === `cl-${clId}` || // Collection-level selection + id.startsWith(`clfd-${clId}`) || // Folder in this collection + id.startsWith(`clf-${clId}`), // File in this collection ) - + if (isClSelected) { currentIntegrations[int.id] = true - + // Check if it's a collection-level selection const hasCollectionSelection = itemIds.includes(`cl-${clId}`) if (hasCollectionSelection) { @@ -1174,28 +1227,33 @@ function AgentComponent() { } else { // Filter itemIds that belong to this CL and extract the actual item IDs const clItemIds = itemIds - .filter((itemId: string) => - itemId.startsWith(`clfd-`) || itemId.startsWith(`clf-`) + .filter( + (itemId: string) => + itemId.startsWith(`clfd-`) || itemId.startsWith(`clf-`), ) .map((itemId: string) => { // Extract the actual item ID by removing the prefix if (itemId.startsWith(`clfd-`)) { return itemId.substring(5) // Remove 'clfd-' prefix } else if (itemId.startsWith(`clf-`)) { - return itemId.substring(4) // Remove 'clf-' prefix + return itemId.substring(4) // Remove 'clf-' prefix } return itemId }) - + if (clItemIds.length > 0) { const selectedItems = new Set(clItemIds) clSelections[clId] = selectedItems - + // Create mock item details for display const itemDetailsForCl: Record = {} clItemIds.forEach((itemId: string, index: number) => { - const originalId = itemIds.find((id: string) => id.endsWith(itemId)) - const itemType = originalId?.startsWith(`clfd-`) ? 'folder' : 'file' + const originalId = itemIds.find((id: string) => + id.endsWith(itemId), + ) + const itemType = originalId?.startsWith(`clfd-`) + ? "folder" + : "file" itemDetailsForCl[itemId] = { id: itemId, name: itemId, // Use itemId as name for now @@ -1208,37 +1266,40 @@ function AgentComponent() { } } // Handle legacy format: collection key with itemIds array - else if (appIntegrations['collection']) { - const clConfig = appIntegrations['collection'] + else if (appIntegrations["collection"]) { + const clConfig = appIntegrations["collection"] const itemIds = clConfig.itemIds || [] - + // Check if this CL is referenced in the itemIds - const isClSelected = itemIds.includes(int.name) || // CL name is in itemIds (selectAll case) - itemIds.some((id: string) => id.startsWith(clId)) // Some items from this CL are selected - + const isClSelected = + itemIds.includes(int.name) || // CL name is in itemIds (selectAll case) + itemIds.some((id: string) => id.startsWith(clId)) // Some items from this CL are selected + if (isClSelected) { currentIntegrations[int.id] = true - + // If only CL name is in itemIds, it means selectAll if (itemIds.includes(int.name) && itemIds.length === 1) { clSelections[clId] = new Set() // Empty set means selectAll } else { // Filter itemIds that belong to this CL - const clItemIds = itemIds.filter((id: string) => - id !== int.name && (id.startsWith(clId) || id.includes(clId)) + const clItemIds = itemIds.filter( + (id: string) => + id !== int.name && + (id.startsWith(clId) || id.includes(clId)), ) - + if (clItemIds.length > 0) { const selectedItems = new Set(clItemIds) clSelections[clId] = selectedItems - + // Create mock item details for display const itemDetailsForCl: Record = {} clItemIds.forEach((itemId: string, index: number) => { itemDetailsForCl[itemId] = { id: itemId, name: itemId, // Use itemId as name for now - type: 'file', // Default to file type + type: "file", // Default to file type } }) clDetails[clId] = itemDetailsForCl @@ -1247,23 +1308,26 @@ function AgentComponent() { } } // Handle old format: collections key with nested structure - else if (appIntegrations['collections'] && appIntegrations['collections'][int.name]) { - const clConfig = appIntegrations['collections'][int.name] + else if ( + appIntegrations["collections"] && + appIntegrations["collections"][int.name] + ) { + const clConfig = appIntegrations["collections"][int.name] currentIntegrations[int.id] = true - + // Parse folders to recreate selections if (clConfig.folders && clConfig.folders.length > 0) { const selectedItems = new Set() - + // For each item in folders array, determine if it's a file or folder // Files have extensions in their names, folders do not clConfig.folders.forEach((folder: any, index: number) => { // Determine if this is a file or folder based on file extension in the name const hasFileExtension = /\.[a-zA-Z0-9]+$/.test(folder.name) - const itemType = hasFileExtension ? 'file' : 'folder' + const itemType = hasFileExtension ? "file" : "folder" const itemId = `${itemType}_${folder.name}_${Date.now()}_${index}` selectedItems.add(itemId) - + if (!clDetails[clId]) { clDetails[clId] = {} } @@ -1271,33 +1335,40 @@ function AgentComponent() { id: itemId, name: folder.name, type: itemType, - vespaIds: folder.ids // Store the vespa IDs for reference + vespaIds: folder.ids, // Store the vespa IDs for reference } }) - + clSelections[clId] = selectedItems } else if (clConfig.selectAll) { // If selectAll is true, mark the CL as selected but no specific items clSelections[clId] = new Set() } } - } + } // Handle DataSource key (new format for grouped data sources) - else if (int.app === Apps.DataSource && appIntegrations['DataSource']) { - const dsConfig = appIntegrations['DataSource'] + else if ( + int.app === Apps.DataSource && + appIntegrations["DataSource"] + ) { + const dsConfig = appIntegrations["DataSource"] const itemIds = dsConfig.itemIds || [] - + // Check if this data source is in the itemIds array if (itemIds.includes(int.id)) { currentIntegrations[int.id] = true } - } - else { + } else { // Handle other integrations - check both new format (with selectedAll) and old format if (appIntegrations[int.id]) { - if (typeof appIntegrations[int.id] === 'object' && appIntegrations[int.id].selectedAll !== undefined) { + if ( + typeof appIntegrations[int.id] === "object" && + appIntegrations[int.id].selectedAll !== undefined + ) { // New format with selectedAll property - currentIntegrations[int.id] = appIntegrations[int.id].selectedAll || appIntegrations[int.id].itemIds?.length > 0 + currentIntegrations[int.id] = + appIntegrations[int.id].selectedAll || + appIntegrations[int.id].itemIds?.length > 0 } else { // Old format - just a boolean or truthy value currentIntegrations[int.id] = !!appIntegrations[int.id] @@ -1305,7 +1376,7 @@ function AgentComponent() { } } } - }) + }) setSelectedIntegrations(currentIntegrations) setSelectedItemsInCollection(clSelections) setSelectedItemDetailsInCollection(clDetails) @@ -1348,7 +1419,6 @@ function AgentComponent() { } }, [editingAgent, viewMode, users]) - const handleDeleteAgent = async (agentExternalId: string) => { setConfirmModalTitle("Delete Agent") setConfirmModalMessage( @@ -1394,40 +1464,48 @@ function AgentComponent() { const handleSaveAgent = async () => { // Build the new simplified appIntegrations structure - const appIntegrationsObject: Record = {} + const appIntegrationsObject: Record< + string, + { + itemIds: string[] + selectedAll: boolean + } + > = {} // Collect collection item IDs const collectionItemIds: string[] = [] let hasCollectionSelections = false - + // Collect data source IDs const dataSourceIds: string[] = [] let hasDataSourceSelections = false // Process each selected integration - for (const [integrationId, isSelected] of Object.entries(selectedIntegrations)) { + for (const [integrationId, isSelected] of Object.entries( + selectedIntegrations, + )) { if (isSelected) { - const integration = allAvailableIntegrations.find(int => int.id === integrationId) + const integration = allAvailableIntegrations.find( + (int) => int.id === integrationId, + ) if (!integration) continue - // For collections, collect item IDs with appropriate prefixes - if (integrationId.startsWith('cl_')) { - const collectionId = integrationId.replace('cl_', '') - const selectedItems = selectedItemsInCollection[collectionId] || new Set() - const itemDetails = selectedItemDetailsInCollection[collectionId] || {} - + if (integrationId.startsWith("cl_")) { + const collectionId = integrationId.replace("cl_", "") + const selectedItems = + selectedItemsInCollection[collectionId] || new Set() + const itemDetails = + selectedItemDetailsInCollection[collectionId] || {} + if (selectedItems.size === 0) { // If no specific items are selected, use the collection id with collection prefix - const collectionId = integration.id.replace('cl_', '') + const collectionId = integration.id.replace("cl_", "") collectionItemIds.push(`cl-${collectionId}`) // Collection prefix } else { // If specific items are selected, use their IDs with appropriate prefixes - selectedItems.forEach(itemId => { + selectedItems.forEach((itemId) => { const itemDetail = itemDetails[itemId] - if (itemDetail && itemDetail.type === 'folder') { + if (itemDetail && itemDetail.type === "folder") { // This is a folder within the collection collectionItemIds.push(`clfd-${itemId}`) // Collection folder prefix } else { @@ -1437,17 +1515,20 @@ function AgentComponent() { }) } hasCollectionSelections = true - } + } // For data sources, collect their IDs - else if (integrationId.startsWith('ds-') || integration.app === Apps.DataSource) { + else if ( + integrationId.startsWith("ds-") || + integration.app === Apps.DataSource + ) { dataSourceIds.push(integrationId) hasDataSourceSelections = true - } + } // For other integrations, use the integration ID as key else { appIntegrationsObject[integrationId] = { itemIds: [], - selectedAll: true + selectedAll: true, } } } @@ -1455,17 +1536,17 @@ function AgentComponent() { // Add collection selections if any exist if (hasCollectionSelections) { - appIntegrationsObject['knowledge_base'] = { + appIntegrationsObject["knowledge_base"] = { itemIds: collectionItemIds, - selectedAll: collectionItemIds.length === 0 + selectedAll: collectionItemIds.length === 0, } } - + // Add data source selections if any exist if (hasDataSourceSelections) { - appIntegrationsObject['DataSource'] = { + appIntegrationsObject["DataSource"] = { itemIds: dataSourceIds, - selectedAll: dataSourceIds.length === 0 + selectedAll: dataSourceIds.length === 0, } } @@ -1536,22 +1617,22 @@ function AgentComponent() { const toggleIntegrationSelection = (integrationId: string) => { setSelectedIntegrations((prev) => { const newValue = !prev[integrationId] - + // If it's a collection integration and we're deselecting it, clear its items - if (integrationId.startsWith('cl_') && !newValue) { - const clId = integrationId.replace('cl_', '') - setSelectedItemsInCollection(prevItems => { + if (integrationId.startsWith("cl_") && !newValue) { + const clId = integrationId.replace("cl_", "") + setSelectedItemsInCollection((prevItems) => { const newState = { ...prevItems } delete newState[clId] return newState }) - setSelectedItemDetailsInCollection(prevDetails => { + setSelectedItemDetailsInCollection((prevDetails) => { const newState = { ...prevDetails } delete newState[clId] return newState }) } - + return { ...prev, [integrationId]: newValue, @@ -1563,14 +1644,14 @@ function AgentComponent() { // Check if it's a CL item (format: clId_itemId where itemId can contain underscores) // We need to find the actual CL ID from the selected integrations let isClItem = false - let clId = '' - let itemId = '' - + let clId = "" + let itemId = "" + // Check if this is a CL item by looking for a pattern where the ID starts with a CL ID for (const [integId] of Object.entries(selectedIntegrations)) { - if (integId.startsWith('cl_') && selectedIntegrations[integId]) { - const currentClId = integId.replace('cl_', '') - if (integrationId.startsWith(currentClId + '_')) { + if (integId.startsWith("cl_") && selectedIntegrations[integId]) { + const currentClId = integId.replace("cl_", "") + if (integrationId.startsWith(currentClId + "_")) { isClItem = true clId = currentClId itemId = integrationId.substring(currentClId.length + 1) // Remove clId and the underscore @@ -1578,21 +1659,21 @@ function AgentComponent() { } } } - + if (isClItem && clId && itemId) { // Remove the specific item from the CL - setSelectedItemsInCollection(prev => { + setSelectedItemsInCollection((prev) => { const newState = { ...prev } if (newState[clId]) { const newSet = new Set(newState[clId]) newSet.delete(itemId) - + if (newSet.size === 0) { delete newState[clId] // Also deselect the CL integration if no items are selected - setSelectedIntegrations(prevInt => ({ + setSelectedIntegrations((prevInt) => ({ ...prevInt, - [`cl_${clId}`]: false + [`cl_${clId}`]: false, })) } else { newState[clId] = newSet @@ -1600,9 +1681,9 @@ function AgentComponent() { } return newState }) - + // Remove item details - setSelectedItemDetailsInCollection(prev => { + setSelectedItemDetailsInCollection((prev) => { const newState = { ...prev } if (newState[clId] && newState[clId][itemId]) { delete newState[clId][itemId] @@ -1618,16 +1699,16 @@ function AgentComponent() { ...prev, [integrationId]: false, })) - + // If it's a collection integration, also clear its selections - if (integrationId.startsWith('cl_')) { - const clId = integrationId.replace('cl_', '') - setSelectedItemsInCollection(prev => { + if (integrationId.startsWith("cl_")) { + const clId = integrationId.replace("cl_", "") + setSelectedItemsInCollection((prev) => { const newState = { ...prev } delete newState[clId] return newState }) - setSelectedItemDetailsInCollection(prev => { + setSelectedItemDetailsInCollection((prev) => { const newState = { ...prev } delete newState[clId] return newState @@ -1642,7 +1723,7 @@ function AgentComponent() { (int) => (clearedSelection[int.id] = false), ) setSelectedIntegrations(clearedSelection) - + // Also clear selected items and their details for all Collections setSelectedItemsInCollection({}) setSelectedItemDetailsInCollection({}) @@ -1653,77 +1734,121 @@ function AgentComponent() { id: string name: string icon: React.ReactNode - type?: 'file' | 'folder' | 'integration' | 'cl' + type?: "file" | "folder" | "integration" | "cl" clId?: string clName?: string }> = [] - + // Add regular integrations allAvailableIntegrations.forEach((integration) => { - if (selectedIntegrations[integration.id] && !integration.id.startsWith('cl_')) { + if ( + selectedIntegrations[integration.id] && + !integration.id.startsWith("cl_") + ) { result.push({ ...integration, - type: 'integration' + type: "integration", }) } }) - + // Handle collections allAvailableIntegrations.forEach((integration) => { - if (integration.id.startsWith('cl_') && selectedIntegrations[integration.id]) { - const clId = integration.id.replace('cl_', '') + if ( + integration.id.startsWith("cl_") && + selectedIntegrations[integration.id] + ) { + const clId = integration.id.replace("cl_", "") const selectedItems = selectedItemsInCollection[clId] || new Set() - + if (selectedItems.size === 0) { // If no specific items are selected, show the whole CL pill result.push({ ...integration, - type: 'cl' + type: "cl", }) } else { // If specific items are selected, show individual file/folder pills const itemDetails = selectedItemDetailsInCollection[clId] || {} - - selectedItems.forEach(itemId => { + + selectedItems.forEach((itemId) => { const item = itemDetails[itemId] if (item) { // Use the name from the mapping if available, otherwise use the item name - const displayName = integrationIdToNameMap[itemId]?.name || item.name; - + const displayName = + integrationIdToNameMap[itemId]?.name || item.name + // Determine the icon based on the type from the mapping or the item type - const itemType = integrationIdToNameMap[itemId]?.type || item.type; - const itemIcon = itemType === 'folder' ? ( - - - - ) : itemType === 'collection' ? ( - - - - - ) : ( - - - - - ); - + const itemType = integrationIdToNameMap[itemId]?.type || item.type + const itemIcon = + itemType === "folder" ? ( + + + + ) : itemType === "collection" ? ( + + + + + ) : ( + + + + + ) + result.push({ id: `${clId}_${itemId}`, name: displayName, icon: itemIcon, type: item.type, clId: clId, - clName: integration.name + clName: integration.name, }) } }) } } }) - + return result - }, [selectedIntegrations, allAvailableIntegrations, selectedItemsInCollection, selectedItemDetailsInCollection, integrationIdToNameMap]) + }, [ + selectedIntegrations, + allAvailableIntegrations, + selectedItemsInCollection, + selectedItemDetailsInCollection, + integrationIdToNameMap, + ]) useEffect(() => { if (!isRagOn) { @@ -2624,30 +2749,51 @@ function AgentComponent() { setDropdownSearchQuery("") } else { // Navigate back one level - const newPath = navigationPath.slice(0, -1) + const newPath = navigationPath.slice( + 0, + -1, + ) setNavigationPath(newPath) - - if (newPath.length === 1 && newPath[0].type === 'cl-root') { + + if ( + newPath.length === 1 && + newPath[0].type === "cl-root" + ) { // Back to CL listing setCurrentItems([]) } else if (newPath.length > 1) { // Navigate to parent folder - const clId = newPath.find(item => item.type === 'cl')?.id - const parentId = newPath[newPath.length - 1]?.id === clId ? null : newPath[newPath.length - 1]?.id - + const clId = newPath.find( + (item) => item.type === "cl", + )?.id + const parentId = + newPath[newPath.length - 1]?.id === + clId + ? null + : newPath[newPath.length - 1]?.id + if (clId) { setIsLoadingItems(true) - api.cl[":clId"].items.$get({ - param: { clId: clId }, - query: parentId ? { parentId } : {} - }).then((response: Response) => { - if (response.ok) { - response.json().then((data: any[]) => { - setCurrentItems(data) - setIsLoadingItems(false) - }) - } - }).catch(() => setIsLoadingItems(false)) + api.cl[":clId"].items + .$get({ + param: { clId: clId }, + query: parentId + ? { parentId } + : {}, + }) + .then((response: Response) => { + if (response.ok) { + response + .json() + .then((data: any[]) => { + setCurrentItems(data) + setIsLoadingItems(false) + }) + } + }) + .catch(() => + setIsLoadingItems(false), + ) } } } @@ -2659,7 +2805,7 @@ function AgentComponent() { )} {navigationPath.length > 0 ? (
- { setNavigationPath([]) @@ -2673,43 +2819,97 @@ function AgentComponent() { // Show up to 3 items in the breadcrumb if (navigationPath.length > 0) { // Get the last 3 items or all if less than 3 - const itemsToShow = navigationPath.length <= 3 - ? navigationPath - : navigationPath.slice(navigationPath.length - 3); - + const itemsToShow = + navigationPath.length <= 3 + ? navigationPath + : navigationPath.slice( + navigationPath.length - 3, + ) + return itemsToShow.map((item, index) => ( - / - + / + + { - if (index < itemsToShow.length - 1) { + if ( + index < + itemsToShow.length - 1 + ) { // Navigate to this item - const newPathIndex = navigationPath.findIndex(p => p.id === item.id); + const newPathIndex = + navigationPath.findIndex( + (p) => p.id === item.id, + ) if (newPathIndex >= 0) { - const newPath = navigationPath.slice(0, newPathIndex + 1); - setNavigationPath(newPath); - - if (newPath.length === 1 && newPath[0].type === 'cl-root') { - setCurrentItems([]); - } else if (newPath.length > 1) { - const clId = newPath.find(item => item.type === 'cl')?.id; - const parentId = newPath[newPath.length - 1]?.id === clId ? null : newPath[newPath.length - 1]?.id; - + const newPath = + navigationPath.slice( + 0, + newPathIndex + 1, + ) + setNavigationPath(newPath) + + if ( + newPath.length === 1 && + newPath[0].type === + "cl-root" + ) { + setCurrentItems([]) + } else if ( + newPath.length > 1 + ) { + const clId = newPath.find( + (item) => + item.type === "cl", + )?.id + const parentId = + newPath[ + newPath.length - 1 + ]?.id === clId + ? null + : newPath[ + newPath.length - 1 + ]?.id + if (clId) { - setIsLoadingItems(true); - api.cl[":clId"].items.$get({ - param: { clId: clId }, - query: parentId ? { parentId } : {} - }).then((response: Response) => { - if (response.ok) { - response.json().then((data: any[]) => { - setCurrentItems(data); - setIsLoadingItems(false); - }); - } - }).catch(() => setIsLoadingItems(false)); + setIsLoadingItems(true) + api.cl[":clId"].items + .$get({ + param: { clId: clId }, + query: parentId + ? { parentId } + : {}, + }) + .then( + ( + response: Response, + ) => { + if (response.ok) { + response + .json() + .then( + ( + data: any[], + ) => { + setCurrentItems( + data, + ) + setIsLoadingItems( + false, + ) + }, + ) + } + }, + ) + .catch(() => + setIsLoadingItems( + false, + ), + ) } } } @@ -2719,9 +2919,9 @@ function AgentComponent() { {item.name} - )); + )) } - return null; + return null })()}
) : ( @@ -2743,622 +2943,1001 @@ function AgentComponent() { )}
- {navigationPath.length === 0 ? ( - // Main menu - (() => { - const collections = allAvailableIntegrations.filter(integration => - integration.id.startsWith('cl_') - ) - const otherIntegrations = allAvailableIntegrations.filter(integration => - !integration.id.startsWith('cl_') - ) + {navigationPath.length === 0 + ? // Main menu + (() => { + const collections = + allAvailableIntegrations.filter( + (integration) => + integration.id.startsWith("cl_"), + ) + const otherIntegrations = + allAvailableIntegrations.filter( + (integration) => + !integration.id.startsWith("cl_"), + ) + + return ( + <> + {/* Regular integrations */} + {otherIntegrations.map((integration) => { + const isGoogleDrive = + integration.app === Apps.GoogleDrive && + integration.entity === "file" + const showChevron = isGoogleDrive - return ( - <> - {/* Regular integrations */} - {otherIntegrations.map((integration) => { - const isGoogleDrive = integration.app === Apps.GoogleDrive && integration.entity === "file" - const showChevron = isGoogleDrive - - return ( + return ( + { + e.preventDefault() + toggleIntegrationSelection( + integration.id, + ) + }} + className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" + > +
+ {}} + className="w-4 h-4 mr-3" + /> + + {integration.icon} + + + {integration.name} + +
+ {showChevron && ( + + )} +
+ ) + })} + + {/* Collections item */} + {collections.length > 0 && ( { e.preventDefault() - toggleIntegrationSelection(integration.id) + setNavigationPath([ + { + id: "cl-root", + name: "Collections", + type: "cl-root", + }, + ]) + setDropdownSearchQuery("") }} className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" >
- {}} - className="w-4 h-4 mr-3" - /> - - {integration.icon} + + + Collections - {integration.name}
- {showChevron && ( - - )} +
+ )} + + ) + })() + : // Unified Collections section - handles both CL listing and file/folder navigation + (() => { + // const knowledgeBases = allAvailableIntegrations.filter(integration => + // integration.id.startsWith('cl_') + // ) + + // Unified navigation functions + const navigateToCl = async ( + clId: string, + clName: string, + ) => { + // Update navigation path based on current context + const newPath = + navigationPath.length === 1 && + navigationPath[0].type === "cl-root" + ? [ + { + id: "cl-root", + name: "Collection", + type: "cl-root" as const, + }, + { + id: clId, + name: clName, + type: "cl" as const, + }, + ] + : [ + { + id: clId, + name: clName, + type: "cl" as const, + }, + ] + + setNavigationPath(newPath) + setIsLoadingItems(true) + try { + const response = await api.cl[ + ":clId" + ].items.$get({ + param: { clId: clId }, + }) + if (response.ok) { + const data = await response.json() + setCurrentItems(data) + } + } catch (error) { + console.error( + "Failed to fetch CL items:", + error, ) - })} - - {/* Collections item */} - {collections.length > 0 && ( - { - e.preventDefault() - setNavigationPath([{ id: 'cl-root', name: 'Collections', type: 'cl-root' }]) - setDropdownSearchQuery("") - }} - className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" - > -
- - Collections -
- -
- )} - - ) - })() - ) : ( - // Unified Collections section - handles both CL listing and file/folder navigation - (() => { - // const knowledgeBases = allAvailableIntegrations.filter(integration => - // integration.id.startsWith('cl_') - // ) - - // Unified navigation functions - const navigateToCl = async (clId: string, clName: string) => { - // Update navigation path based on current context - const newPath = navigationPath.length === 1 && navigationPath[0].type === 'cl-root' - ? [ - { id: 'cl-root', name: 'Collection', type: 'cl-root' as const }, - { id: clId, name: clName, type: 'cl' as const } - ] - : [{ id: clId, name: clName, type: 'cl' as const }] - - setNavigationPath(newPath) - setIsLoadingItems(true) - try { - const response = await api.cl[":clId"].items.$get({ - param: { clId: clId } - }) - if (response.ok) { - const data = await response.json() - setCurrentItems(data) + } finally { + setIsLoadingItems(false) } - } catch (error) { - console.error('Failed to fetch CL items:', error) - } finally { - setIsLoadingItems(false) } - } - const navigateToFolder = async (folderId: string, folderName: string) => { - const clId = navigationPath.find(item => item.type === 'cl')?.id - if (!clId) return - - setNavigationPath(prev => [...prev, { id: folderId, name: folderName, type: 'folder' }]) - setIsLoadingItems(true) - try { - const response = await api.cl[":clId"].items.$get({ - param: { clId }, - query: { parentId: folderId } - }) - if (response.ok) { - const data = await response.json() - setCurrentItems(data) + const navigateToFolder = async ( + folderId: string, + folderName: string, + ) => { + const clId = navigationPath.find( + (item) => item.type === "cl", + )?.id + if (!clId) return + + setNavigationPath((prev) => [ + ...prev, + { + id: folderId, + name: folderName, + type: "folder", + }, + ]) + setIsLoadingItems(true) + try { + const response = await api.cl[ + ":clId" + ].items.$get({ + param: { clId }, + query: { parentId: folderId }, + }) + if (response.ok) { + const data = await response.json() + setCurrentItems(data) + } + } catch (error) { + console.error( + "Failed to fetch folder items:", + error, + ) + } finally { + setIsLoadingItems(false) } - } catch (error) { - console.error('Failed to fetch folder items:', error) - } finally { - setIsLoadingItems(false) } - } - // Determine if we're showing Collection list or Collection contents - const isShowingKbList = navigationPath.length === 1 && navigationPath[0].type === 'cl-root' - const isShowingKbContents = navigationPath.length > 1 || (navigationPath.length === 1 && navigationPath[0].type === 'cl') - - return ( - <> - {/* Single unified search input */} - {(isShowingKbList || isShowingKbContents) && ( -
-
- - setDropdownSearchQuery(e.target.value)} - className="w-full pl-10 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border-0 focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400" - onClick={(e) => e.stopPropagation()} - /> - {dropdownSearchQuery && ( - - )} + // Determine if we're showing Collection list or Collection contents + const isShowingKbList = + navigationPath.length === 1 && + navigationPath[0].type === "cl-root" + const isShowingKbContents = + navigationPath.length > 1 || + (navigationPath.length === 1 && + navigationPath[0].type === "cl") + + return ( + <> + {/* Single unified search input */} + {(isShowingKbList || + isShowingKbContents) && ( +
+
+ + + setDropdownSearchQuery( + e.target.value, + ) + } + className="w-full pl-10 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border-0 focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400" + onClick={(e) => e.stopPropagation()} + /> + {dropdownSearchQuery && ( + + )} +
-
- )} + )} - {/* Content area - unified global search */} - {(() => { - // If there's a search query, always show global search results - if (dropdownSearchQuery.trim()) { - return ( -
- {isSearching ? ( -
- Searching... -
- ) : searchResults.length > 0 ? ( - searchResults.map((result: any) => { - // Check if the item is directly selected vs inherited from parent - const isDirectlySelected = result.type === 'collection' - ? selectedIntegrations[`cl_${result.id}`] - : selectedItemsInCollection[result.collectionId]?.has(result.id) - - const isSelected = result.type === 'collection' - ? selectedIntegrations[`cl_${result.id}`] - : isItemSelectedWithInheritance( - result, - selectedItemsInCollection, - selectedIntegrations, - selectedItemDetailsInCollection - ) - - const isInherited = isSelected && !isDirectlySelected - - const handleResultSelect = () => { - // Don't allow selection changes for inherited items - if (isInherited) return - - if (result.type === 'collection') { - // Toggle collection selection - const integrationId = `cl_${result.id}` - toggleIntegrationSelection(integrationId) - } else if (result.type === 'folder' || result.type === 'file') { - // For folders and files, first make sure the collection is selected - const collectionIntegrationId = `cl_${result.collectionId}` - - // Ensure collection is selected - if (!selectedIntegrations[collectionIntegrationId]) { - toggleIntegrationSelection(collectionIntegrationId) - } - - // Then handle the specific item selection - const clId = result.collectionId - const itemId = result.id - - setSelectedItemsInCollection(prev => { - const currentSelection = prev[clId] || new Set() - const newSelection = new Set(currentSelection) - - if (newSelection.has(itemId)) { - newSelection.delete(itemId) - } else { - newSelection.add(itemId) - } - - return { - ...prev, - [clId]: newSelection - } - }) - - setSelectedItemDetailsInCollection(prev => { - const newDetails = { ...prev } - if (!newDetails[clId]) { - newDetails[clId] = {} - } - newDetails[clId][itemId] = { - id: itemId, - name: result.name, - type: result.type, - path: result.path, - collectionName: result.collectionName + {/* Content area - unified global search */} + {(() => { + // If there's a search query, always show global search results + if (dropdownSearchQuery.trim()) { + return ( +
+ {isSearching ? ( +
+ Searching... +
+ ) : searchResults.length > 0 ? ( + searchResults.map( + (result: any) => { + // Check if the item is directly selected vs inherited from parent + const isDirectlySelected = + result.type === "collection" + ? selectedIntegrations[ + `cl_${result.id}` + ] + : selectedItemsInCollection[ + result.collectionId + ]?.has(result.id) + + const isSelected = + result.type === "collection" + ? selectedIntegrations[ + `cl_${result.id}` + ] + : isItemSelectedWithInheritance( + result, + selectedItemsInCollection, + selectedIntegrations, + selectedItemDetailsInCollection, + ) + + const isInherited = + isSelected && + !isDirectlySelected + + const handleResultSelect = + () => { + // Don't allow selection changes for inherited items + if (isInherited) return + + if ( + result.type === + "collection" + ) { + // Toggle collection selection + const integrationId = `cl_${result.id}` + toggleIntegrationSelection( + integrationId, + ) + } else if ( + result.type === + "folder" || + result.type === "file" + ) { + // For folders and files, first make sure the collection is selected + const collectionIntegrationId = `cl_${result.collectionId}` + + // Ensure collection is selected + if ( + !selectedIntegrations[ + collectionIntegrationId + ] + ) { + toggleIntegrationSelection( + collectionIntegrationId, + ) + } + + // Then handle the specific item selection + const clId = + result.collectionId + const itemId = result.id + + setSelectedItemsInCollection( + (prev) => { + const currentSelection = + prev[clId] || + new Set() + const newSelection = + new Set( + currentSelection, + ) + + if ( + newSelection.has( + itemId, + ) + ) { + newSelection.delete( + itemId, + ) + } else { + newSelection.add( + itemId, + ) + } + + return { + ...prev, + [clId]: + newSelection, + } + }, + ) + + setSelectedItemDetailsInCollection( + (prev) => { + const newDetails = { + ...prev, + } + if ( + !newDetails[clId] + ) { + newDetails[clId] = + {} + } + newDetails[clId][ + itemId + ] = { + id: itemId, + name: result.name, + type: result.type, + path: result.path, + collectionName: + result.collectionName, + } + return newDetails + }, + ) + } + + // Close search and clear query + setDropdownSearchQuery("") + setSearchResults([]) } - return newDetails - }) - } - - // Close search and clear query - setDropdownSearchQuery("") - setSearchResults([]) - } - - return ( -
- {}} - className={`w-4 h-4 mr-3 ${isInherited ? 'opacity-60' : ''}`} - /> -
+ + return ( +
+ {}} + className={`w-4 h-4 mr-3 ${isInherited ? "opacity-60" : ""}`} + /> +
+
+ + {result.name} + + + {result.type} + + {isInherited && ( + + Selected + + )} +
+ {result.collectionName && + result.type !== + "collection" && ( +
+ in{" "} + { + result.collectionName + } + {result.path && + ` / ${result.path}`} +
+ )} + {result.description && ( +
+ {result.description} +
+ )} +
+
+ ) + }, + ) + ) : ( +
+ No results found for " + {dropdownSearchQuery}" +
+ )} +
+ ) + } + + // If no search query, show navigation-based content + if (navigationPath.length === 0) { + // Main menu - show regular integrations and Collections option + const knowledgeBases = + allAvailableIntegrations.filter( + (integration) => + integration.id.startsWith("cl_"), + ) + const otherIntegrations = + allAvailableIntegrations.filter( + (integration) => + !integration.id.startsWith("cl_"), + ) + const hasSelectedKB = + knowledgeBases.some( + (cl) => selectedIntegrations[cl.id], + ) + + return ( + <> + {/* Regular integrations */} + {otherIntegrations.map( + (integration) => { + const isGoogleDrive = + integration.app === + Apps.GoogleDrive && + integration.entity === "file" + const showChevron = + isGoogleDrive + + return ( + { + e.preventDefault() + toggleIntegrationSelection( + integration.id, + ) + }} + className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" + >
- - {result.name} + {}} + className="w-4 h-4 mr-3" + /> + + {integration.icon} - - {result.type} + + {integration.name} - {isInherited && ( - - Selected - - )}
- {result.collectionName && result.type !== 'collection' && ( -
- in {result.collectionName} - {result.path && ` / ${result.path}`} -
- )} - {result.description && ( -
- {result.description} -
+ {showChevron && ( + )} -
+ + ) + }, + )} + + {/* Collections item */} + {knowledgeBases.length > 0 && ( + { + e.preventDefault() + setNavigationPath([ + { + id: "cl-root", + name: "Collections", + type: "cl-root", + }, + ]) + setDropdownSearchQuery("") + }} + className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" + > +
+ {}} + className="w-4 h-4 mr-3" + /> + + + Collections +
- ) - }) - ) : ( -
- No results found for "{dropdownSearchQuery}" -
- )} -
- ) - } - - // If no search query, show navigation-based content - if (navigationPath.length === 0) { - // Main menu - show regular integrations and Collections option - const knowledgeBases = allAvailableIntegrations.filter(integration => - integration.id.startsWith('cl_') - ) - const otherIntegrations = allAvailableIntegrations.filter(integration => - !integration.id.startsWith('cl_') - ) - const hasSelectedKB = knowledgeBases.some(cl => selectedIntegrations[cl.id]) + + + )} + + ) + } else if ( + navigationPath.length === 1 && + navigationPath[0].type === "cl-root" + ) { + // Show collections list + const knowledgeBases = + allAvailableIntegrations.filter( + (integration) => + integration.id.startsWith("cl_"), + ) + + return knowledgeBases.map( + (integration) => { + const clId = integration.id.replace( + "cl_", + "", + ) - return ( - <> - {/* Regular integrations */} - {otherIntegrations.map((integration) => { - const isGoogleDrive = integration.app === Apps.GoogleDrive && integration.entity === "file" - const showChevron = isGoogleDrive - return ( { e.preventDefault() - toggleIntegrationSelection(integration.id) + // Don't navigate when clicking the checkbox area }} className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" > -
+
{}} + checked={ + !!selectedIntegrations[ + integration.id + ] + } + onChange={(e) => { + e.stopPropagation() + toggleIntegrationSelection( + integration.id, + ) + }} className="w-4 h-4 mr-3" + onClick={(e) => + e.stopPropagation() + } /> {integration.icon} - {integration.name} + { + e.stopPropagation() + navigateToCl( + clId, + integration.name, + ) + }} + > + {integration.name} +
- {showChevron && ( - - )} + { + e.stopPropagation() + navigateToCl( + clId, + integration.name, + ) + }} + /> ) - })} - - {/* Collections item */} - {knowledgeBases.length > 0 && ( - { - e.preventDefault() - setNavigationPath([{ id: 'cl-root', name: 'Collections', type: 'cl-root' }]) - setDropdownSearchQuery("") - }} - className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" - > -
- {}} - className="w-4 h-4 mr-3" - /> - - Collections -
- -
- )} - - ) - } else if (navigationPath.length === 1 && navigationPath[0].type === 'cl-root') { - // Show collections list - const knowledgeBases = allAvailableIntegrations.filter(integration => - integration.id.startsWith('cl_') - ) - - return knowledgeBases.map((integration) => { - const clId = integration.id.replace('cl_', '') - - return ( - { - e.preventDefault() - // Don't navigate when clicking the checkbox area - }} - className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" - > -
- { - e.stopPropagation() - toggleIntegrationSelection(integration.id) - }} - className="w-4 h-4 mr-3" - onClick={(e) => e.stopPropagation()} - /> - - {integration.icon} - - { - e.stopPropagation() - navigateToCl(clId, integration.name) - }} - > - {integration.name} - -
- { - e.stopPropagation() - navigateToCl(clId, integration.name) - }} - /> -
+ }, ) - }) - } else { - // Show Collection contents (files/folders) - return ( -
- {isLoadingItems ? ( -
- Loading... -
- ) : currentItems.length > 0 ? ( - currentItems.map((item: any) => ( -
{ - if (item.type === 'folder') { - // When navigating to a folder, if it's selected, auto-select all children - navigateToFolder(item.id, item.name) - } - }} - > - {(() => { - const clId = navigationPath.find(item => item.type === 'cl')?.id - if (!clId) return null - - const selectedSet = selectedItemsInCollection[clId] || new Set() - const isSelected = selectedSet.has(item.id) - - // Check if any parent folder is selected (which would make this item inherit selection) - const isInheritedFromParent = (() => { - // Get all parent folder IDs from the navigation path - // When we're inside a folder, that folder's ID is in the navigation path - const parentFolders = navigationPath - .filter(pathItem => pathItem.type === 'folder') - .map(pathItem => pathItem.id) - - // Also check if the current collection itself is selected (selectAll case) - const currentClId = navigationPath.find(item => item.type === 'cl')?.id - const hasCollectionIntegrationSelected = currentClId && !!selectedIntegrations[`cl_${currentClId}`] - const isCollectionSelectAll = hasCollectionIntegrationSelected && selectedSet.size === 0 - - // Check if any parent folder in the current path is selected - const hasSelectedParentFolder = parentFolders.some(parentId => selectedSet.has(parentId)) - - // Item should be inherited if: - // 1. Any parent folder is selected, OR - // 2. The collection is in selectAll mode (collection selected but no specific items) - return hasSelectedParentFolder || isCollectionSelectAll - })() - - const finalIsSelected: boolean = Boolean(isSelected || isInheritedFromParent) - const isDisabled: boolean = Boolean(isInheritedFromParent && !isSelected) - - return ( - { - e.stopPropagation() - if (isDisabled) return // Prevent changes if inherited from parent - - const isCurrentlySelected = selectedSet.has(item.id) - - if (item.type === 'folder' && !isCurrentlySelected) { - // When selecting a folder, we need to handle its children - setSelectedItemsInCollection(prev => { - const newState = { ...prev } - if (!newState[clId]) { - newState[clId] = new Set() - } - - const selectedSet = new Set(newState[clId]) - selectedSet.add(item.id) - - newState[clId] = selectedSet - return newState - }) - - // Store item details - setSelectedItemDetailsInCollection(prev => { - const newState = { ...prev } - if (!newState[clId]) { - newState[clId] = {} - } - newState[clId][item.id] = item - return newState - }) - } else if (item.type === 'folder' && isCurrentlySelected) { - // When deselecting a folder, remove it from the selection set - setSelectedItemsInCollection(prev => { - const newState = { ...prev } - if (!newState[clId]) return newState - - const selectedSet = new Set(newState[clId]) - selectedSet.delete(item.id) - - newState[clId] = selectedSet - return newState - }) - - // Remove item details - setSelectedItemDetailsInCollection(prev => { - const newState = { ...prev } - if (newState[clId] && newState[clId][item.id]) { - delete newState[clId][item.id] - } - return newState - }) - } else { - // Handle regular file selection - setSelectedItemsInCollection(prev => { - const newState = { ...prev } - if (!newState[clId]) { - newState[clId] = new Set() - } - - const selectedSet = new Set(newState[clId]) - if (selectedSet.has(item.id)) { - selectedSet.delete(item.id) - } else { - selectedSet.add(item.id) - } - - newState[clId] = selectedSet - return newState - }) - - // Also store/remove item details - setSelectedItemDetailsInCollection(prev => { - const newState = { ...prev } - if (!newState[clId]) { - newState[clId] = {} - } - - if (isCurrentlySelected) { - delete newState[clId][item.id] - } else { - newState[clId][item.id] = item - } - - return newState - }) + } else { + // Show Collection contents (files/folders) + return ( +
+ {isLoadingItems ? ( +
+ Loading... +
+ ) : currentItems.length > 0 ? ( + currentItems.map((item: any) => ( +
{ + if ( + item.type === "folder" + ) { + // When navigating to a folder, if it's selected, auto-select all children + navigateToFolder( + item.id, + item.name, + ) + } + }} + > + {(() => { + const clId = + navigationPath.find( + (item) => + item.type === "cl", + )?.id + if (!clId) return null + + const selectedSet = + selectedItemsInCollection[ + clId + ] || new Set() + const isSelected = + selectedSet.has(item.id) + + // Check if any parent folder is selected (which would make this item inherit selection) + const isInheritedFromParent = + (() => { + // Get all parent folder IDs from the navigation path + // When we're inside a folder, that folder's ID is in the navigation path + const parentFolders = + navigationPath + .filter( + (pathItem) => + pathItem.type === + "folder", + ) + .map( + (pathItem) => + pathItem.id, + ) + + // Also check if the current collection itself is selected (selectAll case) + const currentClId = + navigationPath.find( + (item) => + item.type === + "cl", + )?.id + const hasCollectionIntegrationSelected = + currentClId && + !!selectedIntegrations[ + `cl_${currentClId}` + ] + const isCollectionSelectAll = + hasCollectionIntegrationSelected && + selectedSet.size === 0 + + // Check if any parent folder in the current path is selected + const hasSelectedParentFolder = + parentFolders.some( + (parentId) => + selectedSet.has( + parentId, + ), + ) + + // Item should be inherited if: + // 1. Any parent folder is selected, OR + // 2. The collection is in selectAll mode (collection selected but no specific items) + return ( + hasSelectedParentFolder || + isCollectionSelectAll + ) + })() + + const finalIsSelected: boolean = + Boolean( + isSelected || + isInheritedFromParent, + ) + const isDisabled: boolean = + Boolean( + isInheritedFromParent && + !isSelected, + ) + + return ( + { - const clIntegrationId = `cl_${clId}` - const currentSelectedSet = selectedItemsInCollection[clId] || new Set() - const newSelectedSet = new Set(currentSelectedSet) - - if (isCurrentlySelected) { - newSelectedSet.delete(item.id) + disabled={isDisabled} + onChange={(e) => { + e.stopPropagation() + if (isDisabled) return // Prevent changes if inherited from parent + + const isCurrentlySelected = + selectedSet.has( + item.id, + ) + + if ( + item.type === + "folder" && + !isCurrentlySelected + ) { + // When selecting a folder, we need to handle its children + setSelectedItemsInCollection( + (prev) => { + const newState = + { ...prev } + if ( + !newState[ + clId + ] + ) { + newState[ + clId + ] = new Set() + } + + const selectedSet = + new Set( + newState[ + clId + ], + ) + selectedSet.add( + item.id, + ) + + newState[clId] = + selectedSet + return newState + }, + ) + + // Store item details + setSelectedItemDetailsInCollection( + (prev) => { + const newState = + { ...prev } + if ( + !newState[ + clId + ] + ) { + newState[ + clId + ] = {} + } + newState[clId][ + item.id + ] = item + return newState + }, + ) + } else if ( + item.type === + "folder" && + isCurrentlySelected + ) { + // When deselecting a folder, remove it from the selection set + setSelectedItemsInCollection( + (prev) => { + const newState = + { ...prev } + if ( + !newState[ + clId + ] + ) + return newState + + const selectedSet = + new Set( + newState[ + clId + ], + ) + selectedSet.delete( + item.id, + ) + + newState[clId] = + selectedSet + return newState + }, + ) + + // Remove item details + setSelectedItemDetailsInCollection( + (prev) => { + const newState = + { ...prev } + if ( + newState[ + clId + ] && + newState[ + clId + ][item.id] + ) { + delete newState[ + clId + ][item.id] + } + return newState + }, + ) } else { - newSelectedSet.add(item.id) + // Handle regular file selection + setSelectedItemsInCollection( + (prev) => { + const newState = + { ...prev } + if ( + !newState[ + clId + ] + ) { + newState[ + clId + ] = new Set() + } + + const selectedSet = + new Set( + newState[ + clId + ], + ) + if ( + selectedSet.has( + item.id, + ) + ) { + selectedSet.delete( + item.id, + ) + } else { + selectedSet.add( + item.id, + ) + } + + newState[clId] = + selectedSet + return newState + }, + ) + + // Also store/remove item details + setSelectedItemDetailsInCollection( + (prev) => { + const newState = + { ...prev } + if ( + !newState[ + clId + ] + ) { + newState[ + clId + ] = {} + } + + if ( + isCurrentlySelected + ) { + delete newState[ + clId + ][item.id] + } else { + newState[ + clId + ][item.id] = + item + } + + return newState + }, + ) } - - return { - ...prev, - [clIntegrationId]: newSelectedSet.size > 0 - } - }) - }} - className={`w-4 h-4 mr-3 ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`} - onClick={(e) => e.stopPropagation()} - /> - ) - })()} - {item.type === 'folder' && ( - - - - )} - - {item.name} - - {item.type === 'folder' && ( - - )} + + // Auto-select/deselect the Collection integration + setSelectedIntegrations( + (prev) => { + const clIntegrationId = `cl_${clId}` + const currentSelectedSet = + selectedItemsInCollection[ + clId + ] || new Set() + const newSelectedSet = + new Set( + currentSelectedSet, + ) + + if ( + isCurrentlySelected + ) { + newSelectedSet.delete( + item.id, + ) + } else { + newSelectedSet.add( + item.id, + ) + } + + return { + ...prev, + [clIntegrationId]: + newSelectedSet.size > + 0, + } + }, + ) + }} + className={`w-4 h-4 mr-3 ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`} + onClick={(e) => + e.stopPropagation() + } + /> + ) + })()} + {item.type === "folder" && ( + + + + )} + + {item.name} + + {item.type === "folder" && ( + + )} +
+ )) + ) : ( +
+ No items found
- )) - ) : ( -
- No items found -
- )} -
- ) - } - - return null - })()} - - ) - })() - )} + )} +
+ ) + } + + return null + })()} + + ) + })()}

- Collections appear in the submenu when selecting integrations. + Collections appear in the submenu when selecting + integrations.

diff --git a/frontend/src/routes/_authenticated/chat.tsx b/frontend/src/routes/_authenticated/chat.tsx index 6976bd6a4..d23c03305 100644 --- a/frontend/src/routes/_authenticated/chat.tsx +++ b/frontend/src/routes/_authenticated/chat.tsx @@ -714,6 +714,7 @@ export const ChatPage = ({ sourcesArray, chatParams.agentId, chatParams.toolsList, + chatParams.enableWebSearch, ) hasHandledQueryParam.current = true router.navigate({ @@ -743,9 +744,10 @@ export const ChatPage = ({ const handleSend = async ( messageToSend: string, metadata?: AttachmentMetadata[], - selectedSources: string[] = [], + selectedSources?: string[], agentIdFromChatBox?: string | null, toolsList?: ToolsListItem[], + enableWebSearch?: boolean, ) => { if (!messageToSend || isStreaming || retryIsStreaming) return @@ -774,12 +776,13 @@ export const ChatPage = ({ try { await startStream( messageToSend, - selectedSources, + selectedSources || [], isReasoningActive, isAgenticMode, agentIdToUse, toolsList, metadata, + enableWebSearch, ) } catch (error) { // If there's an error, clear the optimistically added message from cache @@ -2087,249 +2090,209 @@ interface VirtualizedMessagesProps { const ESTIMATED_MESSAGE_HEIGHT = 200 // Increased estimate for better performance const OVERSCAN = 3 // Reduced overscan for better performance -const VirtualizedMessages = React.forwardRef(({ - messages, - currentResp, - showSources, - currentMessageId, - feedbackMap, - isStreaming, - retryIsStreaming, - isSharedChat, - isDebugMode, - disableRetry, - dots, - setShowSources, - setCurrentCitations, - setCurrentMessageId, - handleRetry, - handleShowRagTrace, - handleFeedback, - handleShare, - handleSend, - scrollToBottom, - chatId, - userHasScrolled, - setUserHasScrolled, - onCitationClick, - isCitationPreviewOpen, - setIsCitationPreviewOpen, - setSelectedCitation, - chatBoxRef, -}, ref) => { - const parentRef = useRef(null) - const lastScrollTop = useRef(0) - - // Create items array including messages and current response - const allItems = useMemo(() => { - const items = [...messages] - if (currentResp) { - items.push({ - externalId: currentResp.messageId || "current-resp", - message: currentResp.resp, - messageRole: "assistant" as const, - sources: currentResp.sources || [], - imageCitations: currentResp.imageCitations || [], - thinking: currentResp.thinking || "", - citationMap: currentResp.citationMap, - isStreaming: true, - attachments: [], - }) - } - return items - }, [messages, currentResp]) - - const rowVirtualizer = useVirtualizer({ - count: allItems.length, - getScrollElement: () => (typeof ref === 'object' && ref?.current) || parentRef.current, - estimateSize: () => ESTIMATED_MESSAGE_HEIGHT, - overscan: OVERSCAN, - measureElement: (element) => { - // Get accurate height measurements for better virtualization - return element?.getBoundingClientRect().height ?? ESTIMATED_MESSAGE_HEIGHT +const VirtualizedMessages = React.forwardRef< + HTMLDivElement, + VirtualizedMessagesProps +>( + ( + { + messages, + currentResp, + showSources, + currentMessageId, + feedbackMap, + isStreaming, + retryIsStreaming, + isSharedChat, + isDebugMode, + disableRetry, + dots, + setShowSources, + setCurrentCitations, + setCurrentMessageId, + handleRetry, + handleShowRagTrace, + handleFeedback, + handleShare, + handleSend, + scrollToBottom, + chatId, + userHasScrolled, + setUserHasScrolled, + onCitationClick, + isCitationPreviewOpen, + setIsCitationPreviewOpen, + setSelectedCitation, + chatBoxRef, }, - }) - - // Auto-scroll to bottom when new messages arrive (only if user hasn't manually scrolled) - useEffect(() => { - if (!userHasScrolled && allItems.length > 0) { - // Let the main scroll effect handle this, just ensure we're at the end - const container = (typeof ref === 'object' && ref?.current) || parentRef.current - if (container) { - const timeoutId = setTimeout(() => { - container.scrollTop = container.scrollHeight - }, 50) - return () => clearTimeout(timeoutId) + ref, + ) => { + const parentRef = useRef(null) + const lastScrollTop = useRef(0) + + // Create items array including messages and current response + const allItems = useMemo(() => { + const items = [...messages] + if (currentResp) { + items.push({ + externalId: currentResp.messageId || "current-resp", + message: currentResp.resp, + messageRole: "assistant" as const, + sources: currentResp.sources || [], + imageCitations: currentResp.imageCitations || [], + thinking: currentResp.thinking || "", + citationMap: currentResp.citationMap, + isStreaming: true, + attachments: [], + }) } - } - }, [allItems.length, userHasScrolled, ref]) + return items + }, [messages, currentResp]) + + const rowVirtualizer = useVirtualizer({ + count: allItems.length, + getScrollElement: () => + (typeof ref === "object" && ref?.current) || parentRef.current, + estimateSize: () => ESTIMATED_MESSAGE_HEIGHT, + overscan: OVERSCAN, + measureElement: (element) => { + // Get accurate height measurements for better virtualization + return ( + element?.getBoundingClientRect().height ?? ESTIMATED_MESSAGE_HEIGHT + ) + }, + }) - // Initialize scroll to bottom for new chats - useEffect(() => { - if (allItems.length > 0) { - const container = (typeof ref === 'object' && ref?.current) || parentRef.current - if (container) { - // Initial scroll to bottom - container.scrollTop = container.scrollHeight - } - } - }, []) // Only run once on mount - - // Detect user scrolling - improved logic to prevent conflicts - const handleScroll = useCallback( - (e: React.UIEvent) => { - const element = e.currentTarget - const scrollTop = element.scrollTop - const scrollHeight = element.scrollHeight - const clientHeight = element.clientHeight - - // Calculate if we're at the bottom with a reasonable threshold - const isAtBottom = scrollTop >= scrollHeight - clientHeight - 50 - - // Update user scroll state based on position - if (isAtBottom) { - // User is at bottom, allow auto-scroll - setUserHasScrolled(false) - } else if (scrollTop < lastScrollTop.current) { - // User scrolled up, disable auto-scroll - setUserHasScrolled(true) + // Auto-scroll to bottom when new messages arrive (only if user hasn't manually scrolled) + useEffect(() => { + if (!userHasScrolled && allItems.length > 0) { + // Let the main scroll effect handle this, just ensure we're at the end + const container = + (typeof ref === "object" && ref?.current) || parentRef.current + if (container) { + const timeoutId = setTimeout(() => { + container.scrollTop = container.scrollHeight + }, 50) + return () => clearTimeout(timeoutId) + } } + }, [allItems.length, userHasScrolled, ref]) - lastScrollTop.current = scrollTop - }, - [setUserHasScrolled], - ) - - return ( -
{ - // Update parentRef for internal use - ;(parentRef as any).current = node - // Forward the ref to the parent component - if (typeof ref === 'function') { - ref(node) - } else if (ref) { - ;(ref as any).current = node + // Initialize scroll to bottom for new chats + useEffect(() => { + if (allItems.length > 0) { + const container = + (typeof ref === "object" && ref?.current) || parentRef.current + if (container) { + // Initial scroll to bottom + container.scrollTop = container.scrollHeight + } + } + }, []) // Only run once on mount + + // Detect user scrolling - improved logic to prevent conflicts + const handleScroll = useCallback( + (e: React.UIEvent) => { + const element = e.currentTarget + const scrollTop = element.scrollTop + const scrollHeight = element.scrollHeight + const clientHeight = element.clientHeight + + // Calculate if we're at the bottom with a reasonable threshold + const isAtBottom = scrollTop >= scrollHeight - clientHeight - 50 + + // Update user scroll state based on position + if (isAtBottom) { + // User is at bottom, allow auto-scroll + setUserHasScrolled(false) + } else if (scrollTop < lastScrollTop.current) { + // User scrolled up, disable auto-scroll + setUserHasScrolled(true) } - }} - className="h-full w-full overflow-auto flex flex-col items-center" - onScroll={handleScroll} - style={{ - height: "100%", - width: "100%", - }} - > -
-
- {rowVirtualizer.getVirtualItems().map((virtualItem) => { - const message = allItems[virtualItem.index] - const index = virtualItem.index - const isSourcesVisible = - showSources && currentMessageId === message.externalId - const userMessageWithErr = - message.messageRole === "user" && message?.errorMessage - const isLastAssistantMessage = - message.messageRole === "assistant" && - !isStreaming && - !retryIsStreaming && - !isSharedChat && - message.externalId && - index === messages.length - 1 - - return ( -
- - { - if ( - showSources && - currentMessageId === message.externalId - ) { - setShowSources(false) - setCurrentCitations([]) - setCurrentMessageId(null) - } else { - setCurrentCitations(message?.sources || []) - setShowSources(true) - setCurrentMessageId(message.externalId) - // Close citation preview when opening sources - setIsCitationPreviewOpen(false) - setSelectedCitation(null) - } - }} - sourcesVisible={isSourcesVisible} - isStreaming={ - message.externalId === "current-resp" - ? isStreaming - : false - } - isDebugMode={isDebugMode} - onShowRagTrace={handleShowRagTrace} - feedbackStatus={feedbackMap[message.externalId!] || null} - onFeedback={!isSharedChat ? handleFeedback : undefined} - onShare={!isSharedChat && handleShare ? () => handleShare() : undefined} - disableRetry={disableRetry} - attachments={message.attachments || []} - onCitationClick={onCitationClick} - isCitationPreviewOpen={isCitationPreviewOpen} - /> - {userMessageWithErr && ( + lastScrollTop.current = scrollTop + }, + [setUserHasScrolled], + ) + + return ( +
{ + // Update parentRef for internal use + ;(parentRef as any).current = node + // Forward the ref to the parent component + if (typeof ref === "function") { + ref(node) + } else if (ref) { + ;(ref as any).current = node + } + }} + className="h-full w-full overflow-auto flex flex-col items-center" + onScroll={handleScroll} + style={{ + height: "100%", + width: "100%", + }} + > +
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const message = allItems[virtualItem.index] + const index = virtualItem.index + const isSourcesVisible = + showSources && currentMessageId === message.externalId + const userMessageWithErr = + message.messageRole === "user" && message?.errorMessage + const isLastAssistantMessage = + message.messageRole === "assistant" && + !isStreaming && + !retryIsStreaming && + !isSharedChat && + message.externalId && + index === messages.length - 1 + + return ( +
+ { if ( showSources && @@ -2348,44 +2311,107 @@ const VirtualizedMessages = React.forwardRef handleShare() : undefined} + onShare={ + !isSharedChat && handleShare + ? () => handleShare() + : undefined + } disableRetry={disableRetry} attachments={message.attachments || []} onCitationClick={onCitationClick} isCitationPreviewOpen={isCitationPreviewOpen} /> - )} - {/* Show follow-up questions only for the latest assistant message */} - {isLastAssistantMessage && chatId && ( - { - // Use ChatBox's sendMessage method which includes all internal state - // (tools, connectors, agent ID, etc.) - chatBoxRef.current?.sendMessage(question) - }} - isStreaming={isStreaming || retryIsStreaming} - onQuestionsLoaded={scrollToBottom} - /> - )} - -
- ) - })} + {userMessageWithErr && ( + { + if ( + showSources && + currentMessageId === message.externalId + ) { + setShowSources(false) + setCurrentCitations([]) + setCurrentMessageId(null) + } else { + setCurrentCitations(message?.sources || []) + setShowSources(true) + setCurrentMessageId(message.externalId) + // Close citation preview when opening sources + setIsCitationPreviewOpen(false) + setSelectedCitation(null) + } + }} + sourcesVisible={isSourcesVisible} + isStreaming={isStreaming} + isDebugMode={isDebugMode} + onShowRagTrace={handleShowRagTrace} + feedbackStatus={ + feedbackMap[message.externalId!] || null + } + onFeedback={!isSharedChat ? handleFeedback : undefined} + onShare={ + !isSharedChat && handleShare + ? () => handleShare() + : undefined + } + disableRetry={disableRetry} + attachments={message.attachments || []} + onCitationClick={onCitationClick} + isCitationPreviewOpen={isCitationPreviewOpen} + /> + )} + + {/* Show follow-up questions only for the latest assistant message */} + {isLastAssistantMessage && chatId && ( + { + // Use ChatBox's sendMessage method which includes all internal state + // (tools, connectors, agent ID, etc.) + chatBoxRef.current?.sendMessage(question) + }} + isStreaming={isStreaming || retryIsStreaming} + onQuestionsLoaded={scrollToBottom} + /> + )} + +
+ ) + })} +
-
- ) -}) + ) + }, +) -VirtualizedMessages.displayName = 'VirtualizedMessages' +VirtualizedMessages.displayName = "VirtualizedMessages" export const ChatMessage = ({ message, @@ -2767,6 +2793,11 @@ const chatParams = z.object({ shareToken: z.string().optional(), // Added shareToken for shared chats // @ts-ignore metadata: z.array(attachmentMetadataSchema).optional(), + enableWebSearch: z + .string() + .transform((val) => val === "false") + .optional() + .default("false"), }) type XyneChat = z.infer @@ -2791,4 +2822,4 @@ export const Route = createFileRoute("/_authenticated/chat")({ ) }, errorComponent: errorComponent, -}) \ No newline at end of file +}) diff --git a/frontend/src/routes/_authenticated/index.tsx b/frontend/src/routes/_authenticated/index.tsx index 26ef4ea1a..7d94e587f 100644 --- a/frontend/src/routes/_authenticated/index.tsx +++ b/frontend/src/routes/_authenticated/index.tsx @@ -152,8 +152,8 @@ const Index = () => { const searchParams = useSearch({ from: "/_authenticated/" }) useEffect(() => { - setPersistedAgentId(searchParams.agentId || null); - }, [searchParams.agentId]); + setPersistedAgentId(searchParams.agentId || null) + }, [searchParams.agentId]) useEffect(() => { if (!autocompleteQuery) { @@ -225,6 +225,7 @@ const Index = () => { selectedSources?: string[], agentId?: string | null, toolsList?: ToolsListItem[], + enableWebSearch?: boolean, ) => { if (messageToSend.trim()) { const searchParams: { @@ -235,6 +236,7 @@ const Index = () => { toolsList?: ToolsListItem[] agentic?: boolean metadata?: AttachmentMetadata[] + enableWebSearch?: boolean } = { q: encodeURIComponent(messageToSend.trim()), } @@ -247,8 +249,8 @@ const Index = () => { } // If agentId is provided, use it, otherwise use the persisted agent ID from the URL if (agentId || persistedAgentId) { - searchParams.agentId = agentId || persistedAgentId as string - } + searchParams.agentId = agentId || (persistedAgentId as string) + } if (isAgenticMode) { searchParams.agentic = true } @@ -262,6 +264,10 @@ const Index = () => { searchParams.toolsList = toolsList } + if (enableWebSearch) { + searchParams.enableWebSearch = enableWebSearch + } + navigate({ to: "/chat", search: searchParams, diff --git a/server/ai/provider/gemini.ts b/server/ai/provider/gemini.ts index 1498793f4..36aa42680 100644 --- a/server/ai/provider/gemini.ts +++ b/server/ai/provider/gemini.ts @@ -6,7 +6,13 @@ import { } from "@google/genai" import BaseProvider from "@/ai/provider/base" import type { Message } from "@aws-sdk/client-bedrock-runtime" -import { type ModelParams, type ConverseResponse, AIProviders } from "../types" +import { + type ModelParams, + type ConverseResponse, + AIProviders, + type WebSearchSource, + type GroundingSupport, +} from "../types" import { getLogger } from "@/logger" import { Subsystem } from "@/types" import path from "path" @@ -107,6 +113,7 @@ export class GeminiAIProvider extends BaseProvider { constructor(client: GoogleGenAI) { super(client, AIProviders.GoogleAI) } + async converse( messages: Message[], params: ModelParams, @@ -125,12 +132,21 @@ export class GeminiAIProvider extends BaseProvider { parts: [{ text: v.content?.[0]?.text || "" }], })) + const tools = [] + if (params.webSearch) { + tools.push({ + googleSearch: {}, + }) + } + const chat = ai.chats.create({ model: modelParams.modelId, history, config: { maxOutputTokens: modelParams.maxTokens, temperature: modelParams.temperature, + // Add tools configuration for web search + tools: tools.length > 0 ? tools : undefined, thinkingConfig: { includeThoughts: params.reasoning, thinkingBudget: params.reasoning ? -1 : 0, @@ -142,7 +158,10 @@ export class GeminiAIProvider extends BaseProvider { text: modelParams.systemPrompt + "\n\n" + - "Important: In case you don't have the context, you can use the images in the context to answer questions.", + "Important: In case you don't have the context, you can use the images in the context to answer questions." + + (params.webSearch + ? "\n\nYou have access to web search for up-to-date information when needed." + : ""), }, ], }, @@ -175,7 +194,34 @@ export class GeminiAIProvider extends BaseProvider { const text = response.text const cost = 0 - return { text, cost } + let sources: WebSearchSource[] = [] + let groundingSupports: GroundingSupport[] = [] + const groundingMetadata = response.candidates?.[0]?.groundingMetadata + if (groundingMetadata?.groundingChunks) { + sources = groundingMetadata.groundingChunks + .filter((chunk: any) => chunk.web) // Only include web sources + .map((chunk: any) => ({ + uri: chunk.web.uri, + title: chunk.web.title, + searchQuery: groundingMetadata.webSearchQueries?.[0] || undefined, + })) + } + + // Extract grounding supports + if (groundingMetadata?.groundingSupports) { + groundingSupports = groundingMetadata.groundingSupports.map( + (support: any) => ({ + segment: { + startIndex: support.segment.startIndex, + endIndex: support.segment.endIndex, + text: support.segment.text, + }, + groundingChunkIndices: support.groundingChunkIndices || [], + }), + ) + } + + return { text, cost, sources, groundingSupports } } catch (error) { Logger.error("Converse Error:", error) throw new Error(`Failed to get response from GenAI: ${error}`) @@ -200,12 +246,20 @@ export class GeminiAIProvider extends BaseProvider { parts: [{ text: v.content?.[0]?.text || "" }], })) + const tools = [] + if (params.webSearch) { + tools.push({ + googleSearch: {}, + }) + } + const chat = ai.chats.create({ model: modelParams.modelId, history, config: { maxOutputTokens: modelParams.maxTokens, temperature: modelParams.temperature, + tools: tools.length > 0 ? tools : undefined, thinkingConfig: { includeThoughts: params.reasoning, thinkingBudget: params.reasoning ? -1 : 0, @@ -217,7 +271,10 @@ export class GeminiAIProvider extends BaseProvider { text: modelParams.systemPrompt + "\n\n" + - "Important: In case you don't have the context, you can use the images in the context to answer questions.", + "Important: In case you don't have the context, you can use the images in the context to answer questions." + + (params.webSearch + ? "\n\nYou have access to web search for up-to-date information when needed." + : ""), }, ], }, @@ -249,9 +306,48 @@ export class GeminiAIProvider extends BaseProvider { let isThinkingStarted = false let wasThinkingInPreviousChunk = false + let accumulatedSources: any[] = [] + let accumulatedGroundingSupports: GroundingSupport[] = [] for await (const chunk of stream) { let chunkText = "" + // Extract sources from grounding metadata if available + const groundingMetadata = chunk.candidates?.[0]?.groundingMetadata + if (groundingMetadata?.groundingChunks) { + const chunkSources = groundingMetadata.groundingChunks + .filter((chunk: any) => chunk.web) // Only include web sources + .map((chunk: any) => ({ + uri: chunk.web.uri, + title: chunk.web.title, + searchQuery: groundingMetadata.webSearchQueries?.[0] || undefined, + })) + + // Merge sources (avoid duplicates based on URI) + chunkSources.forEach((source) => { + if ( + !accumulatedSources.some( + (existing) => existing.uri === source.uri, + ) + ) { + accumulatedSources.push(source) + } + }) + } + + // Extract grounding supports + if (groundingMetadata?.groundingSupports) { + const chunkGroundingSupports = + groundingMetadata.groundingSupports.map((support: any) => ({ + segment: { + startIndex: support.segment.startIndex, + endIndex: support.segment.endIndex, + text: support.segment.text, + }, + groundingChunkIndices: support.groundingChunkIndices || [], + })) + + accumulatedGroundingSupports.push(...chunkGroundingSupports) + } // Check if this chunk contains thinking content const thinkingPart = chunk.candidates?.[0]?.content?.parts?.find( @@ -284,6 +380,12 @@ export class GeminiAIProvider extends BaseProvider { yield { text: chunkText, cost: 0, + sources: + accumulatedSources.length > 0 ? accumulatedSources : undefined, + groundingSupports: + accumulatedGroundingSupports.length > 0 + ? accumulatedGroundingSupports + : undefined, } } } diff --git a/server/ai/provider/index.ts b/server/ai/provider/index.ts index 0c5b528ee..8c8c86801 100644 --- a/server/ai/provider/index.ts +++ b/server/ai/provider/index.ts @@ -95,7 +95,7 @@ import { Fireworks } from "@/ai/provider/fireworksClient" import { FireworksProvider } from "@/ai/provider/fireworks" import { GoogleGenAI } from "@google/genai" import { GeminiAIProvider } from "@/ai/provider/gemini" -import { VertexAiProvider } from "@/ai/provider/vertex_ai" +import { VertexAiProvider, VertexProvider } from "@/ai/provider/vertex_ai" import { agentAnalyzeInitialResultsOrRewriteSystemPrompt, agentAnalyzeInitialResultsOrRewriteV2SystemPrompt, @@ -280,10 +280,21 @@ const initializeProviders = (): void => { } if (VertexProjectId && VertexRegion) { + const vertexProviderType = process.env[ + "VERTEX_PROVIDER" + ] as keyof typeof VertexProvider + const provider = + vertexProviderType && VertexProvider[vertexProviderType] + ? VertexProvider[vertexProviderType] + : VertexProvider.ANTHROPIC + vertexProvider = new VertexAiProvider({ projectId: VertexProjectId, region: VertexRegion, + provider: provider, }) + + Logger.info(`Initialized VertexAI provider with ${provider} backend`) } if (!OpenAIKey && !TogetherApiKey && aiProviderBaseUrl) { @@ -1782,3 +1793,49 @@ export const generateFollowUpQuestions = async ( return { followUpQuestions: [] } } } + +export const webSearchQuestion = ( + query: string, + userCtx: string, + params: ModelParams, +): AsyncIterableIterator => { + try { + if (!params.modelId) { + params.modelId = defaultBestModel + } + const webSearchSystemPrompt = + "You are a helpful AI assistant with access to web search. Use web search when you need current information or real-time data to answer the user's question accurately." + params.webSearch = true + + if (!params.systemPrompt) { + params.systemPrompt = !isAgentPromptEmpty(params.agentPrompt) + ? webSearchSystemPrompt + "\n\n" + parseAgentPrompt(params.agentPrompt) + : webSearchSystemPrompt + } + + const baseMessage: Message = { + role: MessageRole.User, + content: [{ text: query }], + } + const messages: Message[] = params.messages + ? [...params.messages, baseMessage] + : [baseMessage] + + if (!config.VertexProjectId || !config.VertexRegion) { + Logger.warn( + "VertexProjectId/VertexRegion not configured, moving with default provider.", + ) + return getProviderByModel(params.modelId).converseStream(messages, params) + } + const vertexGoogleProvider = new VertexAiProvider({ + projectId: config.VertexProjectId!, + region: config.VertexRegion!, + provider: VertexProvider.GOOGLE, + }) + + return vertexGoogleProvider.converseStream(messages, params) + } catch (error) { + Logger.error(error, "Error in webSearchQuestion") + throw error + } +} diff --git a/server/ai/provider/vertex_ai.ts b/server/ai/provider/vertex_ai.ts index f34ea086e..b757de0f0 100644 --- a/server/ai/provider/vertex_ai.ts +++ b/server/ai/provider/vertex_ai.ts @@ -3,21 +3,29 @@ import fs from "fs" import path from "path" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { VertexAI, type Tool } from "@google-cloud/vertexai" import { getLogger } from "@/logger" import { type Message } from "@aws-sdk/client-bedrock-runtime" import { AIProviders, type ConverseResponse, type ModelParams, + type WebSearchSource, } from "@/ai/types" import BaseProvider, { findImageByName } from "@/ai/provider/base" import { Subsystem } from "@/types" import config from "@/config" +import { createLabeledImageContent } from "../utils" const { MAX_IMAGE_SIZE_BYTES } = config const Logger = getLogger(Subsystem.AI) +export enum VertexProvider { + ANTHROPIC = "anthropic", + GOOGLE = "google", +} + const buildVertexAIImageParts = async (imagePaths: string[]) => { const baseDir = path.resolve( process.env.IMAGE_DIR || "downloads/xyne_images_db", @@ -56,19 +64,57 @@ const buildVertexAIImageParts = async (imagePaths: string[]) => { const results = await Promise.all(imagePromises) return results.filter(Boolean) } - export class VertexAiProvider extends BaseProvider { - client: AnthropicVertex + client: AnthropicVertex | VertexAI + provider: VertexProvider + + constructor({ + projectId, + region, + provider = VertexProvider.ANTHROPIC, + }: { + projectId: string + region: string + provider?: VertexProvider + }) { + let client: AnthropicVertex | VertexAI + + if (provider === VertexProvider.GOOGLE) { + client = new VertexAI({ project: projectId, location: region }) + } else { + client = new AnthropicVertex({ projectId, region }) + } - constructor({ projectId, region }: { projectId: string; region: string }) { - const client = new AnthropicVertex({ projectId, region }) super(client, AIProviders.VertexAI) this.client = client + this.provider = provider } async converse( messages: Message[], params: ModelParams, + ): Promise { + if (this.provider === VertexProvider.GOOGLE) { + return this.converseGoogle(messages, params) + } else { + return this.converseAnthropic(messages, params) + } + } + + async *converseStream( + messages: Message[], + params: ModelParams, + ): AsyncIterableIterator { + if (this.provider === VertexProvider.GOOGLE) { + yield* this.converseStreamGoogle(messages, params) + } else { + yield* this.converseStreamAnthropic(messages, params) + } + } + + private async converseAnthropic( + messages: Message[], + params: ModelParams, ): Promise { const { modelId, systemPrompt, maxTokens, temperature } = this.getModelParams(params) @@ -77,7 +123,8 @@ export class VertexAiProvider extends BaseProvider { : [] const transformedMessages = this.injectImages(messages, imageParts) - const response = await this.client.beta.messages.create({ + const client = this.client as AnthropicVertex + const response = await client.beta.messages.create({ model: modelId, max_tokens: maxTokens, temperature, @@ -95,7 +142,7 @@ export class VertexAiProvider extends BaseProvider { return { text, cost } } - async *converseStream( + private async *converseStreamAnthropic( messages: Message[], params: ModelParams, ): AsyncIterableIterator { @@ -106,7 +153,8 @@ export class VertexAiProvider extends BaseProvider { : [] const transformedMessages = this.injectImages(messages, imageParts) - const stream = await this.client.beta.messages.create({ + const client = this.client as AnthropicVertex + const stream = await client.beta.messages.create({ model: modelId, max_tokens: maxTokens, temperature, @@ -158,6 +206,254 @@ export class VertexAiProvider extends BaseProvider { } } + private async converseGoogle( + messages: Message[], + params: ModelParams, + ): Promise { + const { modelId, systemPrompt, maxTokens, temperature } = + this.getModelParams(params) + + try { + const imageParts = params.imageFileNames?.length + ? await buildVertexAIImageParts(params.imageFileNames) + : [] + + const history = messages.map((v) => ({ + role: v.role === "assistant" ? "model" : "user", + parts: [{ text: v.content?.[0]?.text || "" }], + })) + + const tools: any[] = [] + + if (params.webSearch) { + tools.push({ + googleSearch: {}, + }) + } + + const client = this.client as VertexAI + const model = client.getGenerativeModel({ + model: params.modelId, + generationConfig: { + maxOutputTokens: maxTokens, + temperature: temperature, + }, + tools: tools.length > 0 ? tools : undefined, + systemInstruction: { + role: "system", + parts: [ + { + text: + systemPrompt + + "\n\n" + + "Important: In case you don't have the context, you can use the images in the context to answer questions." + + (params.webSearch + ? "\n\nYou have access to web search for up-to-date information when needed." + : ""), + }, + ], + }, + }) + + const chat = model.startChat({ history }) + + const lastMessage = messages[messages.length - 1] + const allBlocks = lastMessage?.content || [] + + let messageParts + if (lastMessage?.role == "user" && imageParts.length > 0) { + // only build labeled image content when we actually have images + const textBlocks = allBlocks.filter((c) => "text" in c) + const otherBlocks = allBlocks.filter((c) => !("text" in c)) + const latestText = textBlocks.map((tb) => tb.text).join("\n") + + messageParts = createLabeledImageContent( + latestText, + otherBlocks, + imageParts, + params.imageFileNames || [], + ) + } else { + // otherwise just pass along the raw blocks + messageParts = allBlocks.map((block) => ({ text: block.text })) + } + + const response = await chat.sendMessage(messageParts) + + // Extract text from response + const candidates = response.response.candidates || [] + const textParts = candidates[0]?.content?.parts || [] + const text = textParts + .filter((part: any) => part.text) + .map((part: any) => part.text) + .join("") + + const cost = 0 + + let sources: WebSearchSource[] = [] + const groundingMetadata = + response.response.candidates?.[0]?.groundingMetadata + if (groundingMetadata?.groundingChunks) { + sources = groundingMetadata.groundingChunks + .filter((chunk: any) => chunk.web) // Only include web sources + .map((chunk: any) => ({ + uri: chunk.web.uri, + title: chunk.web.title, + searchQuery: groundingMetadata.webSearchQueries?.[0] || undefined, + })) + } + + return { text, cost, sources } + } catch (error) { + Logger.error("Vertex AI Converse Error:", error) + throw new Error(`Failed to get response from Vertex AI: ${error}`) + } + } + + private async *converseStreamGoogle( + messages: Message[], + params: ModelParams, + ): AsyncIterableIterator { + const modelParams = this.getModelParams(params) + + try { + const client = this.client as VertexAI + + const imageParts = params.imageFileNames?.length + ? await buildVertexAIImageParts(params.imageFileNames) + : [] + + const history = messages.map((v) => ({ + role: v.role === "assistant" ? "model" : "user", + parts: [{ text: v.content?.[0]?.text || "" }], + })) + + const tools: any[] = [] + + // web search grounding + if (params.webSearch) { + tools.push({ + googleSearch: {}, + }) + } + + const model = client.getGenerativeModel({ + model: modelParams.modelId, + generationConfig: { + maxOutputTokens: modelParams.maxTokens, + temperature: modelParams.temperature, + }, + tools: tools.length > 0 ? tools : undefined, + systemInstruction: { + role: "system", + parts: [ + { + text: + modelParams.systemPrompt + + "\n\n" + + "Important: In case you don't have the context, you can use the images in the context to answer questions." + + (params.webSearch + ? "\n\nYou have access to web search for up-to-date information when needed." + : ""), + }, + ], + }, + }) + + const chat = model.startChat({ history }) + + const lastMessage = messages[messages.length - 1] + const allBlocks = lastMessage?.content || [] + + let messageParts + if (lastMessage?.role == "user" && imageParts.length > 0) { + // only build labeled image content when we actually have images + const textBlocks = allBlocks.filter((c) => "text" in c) + const otherBlocks = allBlocks.filter((c) => !("text" in c)) + const latestText = textBlocks.map((tb) => tb.text).join("\n") + + messageParts = createLabeledImageContent( + latestText, + otherBlocks, + imageParts, + params.imageFileNames || [], + ) + } else { + // otherwise just pass along the raw blocks + messageParts = allBlocks.map((block) => ({ text: block.text })) + } + + const result = await chat.sendMessageStream(messageParts) + + let accumulatedSources: any[] = [] + let accumulatedGroundingSupports: any[] = [] + + for await (const chunk of result.stream) { + let chunkText = "" + const groundingMetadata = chunk.candidates?.[0]?.groundingMetadata + if (groundingMetadata?.groundingChunks) { + const chunkSources = groundingMetadata.groundingChunks + .filter((chunk: any) => chunk.web) // Only include web sources + .map((chunk: any) => ({ + uri: chunk.web.uri, + title: chunk.web.title, + searchQuery: groundingMetadata.webSearchQueries?.[0] || undefined, + })) + + // Merge sources (avoid duplicates based on URI) + chunkSources.forEach((source: any) => { + if ( + !accumulatedSources.some( + (existing) => existing.uri === source.uri, + ) + ) { + accumulatedSources.push(source) + } + }) + } + + // Extract grounding supports with proper type checking + if (groundingMetadata?.groundingSupports) { + const chunkGroundingSupports = groundingMetadata.groundingSupports + .filter((support: any) => support.segment) // Only include supports with segments + .map((support: any) => ({ + segment: { + startIndex: support.segment.startIndex || 0, + endIndex: support.segment.endIndex || 0, + text: support.segment.text || "", + }, + groundingChunkIndices: support.groundingChunkIndices || [], + })) + + accumulatedGroundingSupports.push(...chunkGroundingSupports) + } + + if (chunk.candidates?.[0]?.content?.parts) { + const textParts = chunk.candidates[0].content.parts + .filter((part: any) => part.text) + .map((part: any) => part.text) + chunkText += textParts.join("") + } + + if (chunkText) { + yield { + text: chunkText, + cost: 0, + sources: + accumulatedSources.length > 0 ? accumulatedSources : undefined, + groundingSupports: + accumulatedGroundingSupports.length > 0 + ? accumulatedGroundingSupports + : undefined, + } + } + } + } catch (error) { + Logger.error("Streaming Error:", error) + throw new Error(`Failed to get response from Vertex AI: ${error}`) + } + } + private injectImages(messages: any[], imageParts: any[]): any[] { const lastUserIndex = [...messages] .reverse() diff --git a/server/ai/types.ts b/server/ai/types.ts index 358fa279a..605fcc513 100644 --- a/server/ai/types.ts +++ b/server/ai/types.ts @@ -80,6 +80,7 @@ export interface ModelParams { prompt?: string agentPrompt?: string imageFileNames?: string[] + webSearch?: boolean } export interface ConverseResponse { @@ -87,6 +88,23 @@ export interface ConverseResponse { metadata?: any cost?: number reasoning?: boolean + sources?: WebSearchSource[] + groundingSupports?: GroundingSupport[] +} + +export interface WebSearchSource { + uri: string + title: string + searchQuery?: string +} + +export interface GroundingSupport { + segment: { + startIndex: number + endIndex: number + text: string + } + groundingChunkIndices: number[] } export interface LLMProvider { diff --git a/server/api/chat/chat.ts b/server/api/chat/chat.ts index 32eb88048..0823297b7 100644 --- a/server/api/chat/chat.ts +++ b/server/api/chat/chat.ts @@ -19,6 +19,7 @@ import { generateSynthesisBasedOnToolOutput, extractEmailsFromContext, generateFollowUpQuestions, + webSearchQuestion, } from "@/ai/provider" import { generateFollowUpQuestionsSystemPrompt } from "@/ai/prompts" import { getDateForAI } from "@/utils/index" @@ -36,6 +37,7 @@ import { type QueryRouterResponse, type TemporalClassifier, type UserQuery, + type WebSearchSource, } from "@/ai/types" import config from "@/config" import { @@ -138,6 +140,7 @@ import { SlackEntity, SystemEntity, userSchema, + WebSearchEntity, type Entity, type VespaChatMessage, type VespaEvent, @@ -199,6 +202,7 @@ import { getChannelIdsFromAgentPrompt, parseAppSelections, isAppSelectionMap, + findOptimalCitationInsertionPoint, } from "./utils" import { getRecentChainBreakClassifications, @@ -222,6 +226,7 @@ import { getPublicAgentsByUser, type SharedAgentUsageData, } from "@/db/sharedAgentUsage" +import type { GroundingSupport } from "@google/genai" const METADATA_NO_DOCUMENTS_FOUND = "METADATA_NO_DOCUMENTS_FOUND_INTERNAL" const METADATA_FALLBACK_TO_RAG = "METADATA_FALLBACK_TO_RAG_INTERNAL" @@ -2902,10 +2907,10 @@ async function* processResultsForMetadata( } return yield* processIterator( - iterator, - items, - 0, - config.isReasoning && userRequestsReasoning, + iterator, + items, + 0, + config.isReasoning && userRequestsReasoning, ) } @@ -3408,7 +3413,6 @@ async function* generateMetadataQueryAnswer( agentPrompt, ) return - } else if ( isFilteredItemSearch && isValidAppOrEntity && @@ -3902,6 +3906,87 @@ function buildTopicConversationThread( return conversationThread } +function processWebSearchCitations( + answer: string, + allSources: WebSearchSource[], + finalGroundingSupports: GroundingSupport[], + citations: Citation[], + citationMap: Record, + sourceIndex: number, +): { + updatedAnswer: string + newCitations: Citation[] + newCitationMap: Record + updatedSourceIndex: number +} | null { + if (finalGroundingSupports.length > 0 && allSources.length > 0) { + let answerWithCitations = answer + let newCitations: Citation[] = [] + let newCitationMap: Record = {} + let urlToIndexMap: Map = new Map() + + for (const support of finalGroundingSupports) { + const segment = support.segment + const groundingChunkIndices = support.groundingChunkIndices || [] + + let citationText = "" + for (const chunkIndex of groundingChunkIndices) { + if (allSources[chunkIndex]) { + const source = allSources[chunkIndex] + + let citationIndex: number + if (urlToIndexMap.has(source.uri)) { + // Reuse existing citation index + citationIndex = urlToIndexMap.get(source.uri)! + } else { + citationIndex = sourceIndex + const webSearchCitation: Citation = { + docId: `websearch_${sourceIndex}`, + title: source.title, + url: source.uri, + app: Apps.WebSearch, + entity: WebSearchEntity.WebSearch, + } + + newCitations.push(webSearchCitation) + newCitationMap[sourceIndex] = + citations.length + newCitations.length - 1 + urlToIndexMap.set(source.uri, sourceIndex) + sourceIndex++ + } + + citationText += ` [${citationIndex}]` + } + } + + if ( + citationText && + segment?.endIndex !== undefined && + segment.endIndex <= answerWithCitations.length + ) { + // Find optimal insertion point that respects word boundaries + const optimalIndex = findOptimalCitationInsertionPoint( + answerWithCitations, + segment.endIndex, + ) + answerWithCitations = + answerWithCitations.slice(0, optimalIndex) + + citationText + + answerWithCitations.slice(optimalIndex) + } + } + + return { + updatedAnswer: answerWithCitations, + newCitations, + newCitationMap, + updatedSourceIndex: sourceIndex, + } + } + + return null +} + export const MessageApi = async (c: Context) => { // we will use this in catch // if the value exists then we send the error to the frontend via it @@ -3931,9 +4016,10 @@ export const MessageApi = async (c: Context) => { modelId, isReasoningEnabled, agentId, + enableWebSearch, }: MessageReqType = body const agentPromptValue = agentId && isCuid(agentId) ? agentId : undefined // Use undefined if not a valid CUID - if (isAgentic) { + if (isAgentic && !enableWebSearch) { Logger.info(`Routing to MessageWithToolsApi`) return MessageWithToolsApi(c) } @@ -3953,7 +4039,7 @@ export const MessageApi = async (c: Context) => { agentPromptValue, userAndWorkspaceCheck.workspace.id, ) - if (!isAgentic && agentDetails) { + if (!isAgentic && !enableWebSearch && agentDetails) { Logger.info(`Routing to AgentMessageApi for agent ${agentPromptValue}.`) return AgentMessageApi(c) } @@ -4459,69 +4545,103 @@ export const MessageApi = async (c: Context) => { const llmFormattedMessages: Message[] = formatMessagesForLLM( topicConversationThread, ) - // Extract previous classification for pagination and follow-up queries let previousClassification: QueryRouterLLMResponse | null = null if (filteredMessages.length >= 1) { const previousUserMessage = filteredMessages[filteredMessages.length - 2] - if (previousUserMessage?.queryRouterClassification && previousUserMessage.messageRole === "user") { + if ( + previousUserMessage?.queryRouterClassification && + previousUserMessage.messageRole === "user" + ) { try { const parsedClassification = - typeof previousUserMessage.queryRouterClassification === "string" - ? JSON.parse(previousUserMessage.queryRouterClassification) + typeof previousUserMessage.queryRouterClassification === + "string" + ? JSON.parse( + previousUserMessage.queryRouterClassification, + ) : previousUserMessage.queryRouterClassification - previousClassification = parsedClassification as QueryRouterLLMResponse - Logger.info(`Found previous classification: ${JSON.stringify(previousClassification)}`) + previousClassification = + parsedClassification as QueryRouterLLMResponse + Logger.info( + `Found previous classification: ${JSON.stringify(previousClassification)}`, + ) } catch (error) { - Logger.error(`Error parsing previous classification: ${error}`) + Logger.error( + `Error parsing previous classification: ${error}`, + ) } } } // Get chain break classifications for context - const chainBreakClassifications = getRecentChainBreakClassifications(messages) - const formattedChainBreaks = formatChainBreaksForPrompt(chainBreakClassifications) - + const chainBreakClassifications = + getRecentChainBreakClassifications(messages) + const formattedChainBreaks = formatChainBreaksForPrompt( + chainBreakClassifications, + ) + loggerWithChild({ email: email }).info( - `Chain break analysis complete: Found ${chainBreakClassifications.length} chain break classifications, Formatted: ${formattedChainBreaks ? 'YES' : 'NO'}` - ); - + `Chain break analysis complete: Found ${chainBreakClassifications.length} chain break classifications, Formatted: ${formattedChainBreaks ? "YES" : "NO"}`, + ) + loggerWithChild({ email: email }).info( `Found ${chainBreakClassifications.length} chain break classifications for context`, ) - const searchOrAnswerIterator = - generateSearchQueryOrAnswerFromConversation( - message, - ctx, - { - modelId: - ragPipelineConfig[RagPipelineStages.AnswerOrSearch].modelId, - stream: true, - json: true, - agentPrompt: agentPromptValue, - reasoning: - userRequestsReasoning && - ragPipelineConfig[RagPipelineStages.AnswerOrSearch] - .reasoning, - messages: llmFormattedMessages, - // agentPrompt: agentPrompt, // agentPrompt here is the original from request, might be empty string - // AgentMessageApi/CombinedAgentSlackApi handle fetching full agent details - // For this non-agent RAG path, we don't pass an agent prompt. - }, - undefined, - previousClassification, - formattedChainBreaks, + const webSearchEnabled = enableWebSearch ?? false + let searchOrAnswerIterator + if (webSearchEnabled) { + loggerWithChild({ email: email }).info( + "Using web search for the question", ) + searchOrAnswerIterator = webSearchQuestion(message, ctx, { + modelId: Models.Gemini_2_5_Flash, + stream: true, + json: false, + agentPrompt: agentPromptValue, + reasoning: + userRequestsReasoning && + ragPipelineConfig[RagPipelineStages.AnswerOrSearch].reasoning, + messages: llmFormattedMessages, + webSearch: true, + }) + } else { + searchOrAnswerIterator = + generateSearchQueryOrAnswerFromConversation( + message, + ctx, + { + modelId: + ragPipelineConfig[RagPipelineStages.AnswerOrSearch] + .modelId, + stream: true, + json: true, + agentPrompt: agentPromptValue, + reasoning: + userRequestsReasoning && + ragPipelineConfig[RagPipelineStages.AnswerOrSearch] + .reasoning, + messages: llmFormattedMessages, + // agentPrompt: agentPrompt, // agentPrompt here is the original from request, might be empty string + // AgentMessageApi/CombinedAgentSlackApi handle fetching full agent details + // For this non-agent RAG path, we don't pass an agent prompt. + }, + undefined, + previousClassification, + formattedChainBreaks, + ) + } + // TODO: for now if the answer is from the conversation itself we don't // add any citations for it, we can refer to the original message for citations // one more bug is now llm automatically copies the citation text sometimes without any reference // leads to [NaN] in the answer let currentAnswer = "" let answer = "" - let citations = [] + let citations: Citation[] = [] let imageCitations: any[] = [] let citationMap: Record = {} let queryFilters = { @@ -4551,92 +4671,186 @@ export const MessageApi = async (c: Context) => { ragPipelineConfig[RagPipelineStages.AnswerOrSearch].reasoning let buffer = "" const conversationSpan = streamSpan.startSpan("conversation_search") - for await (const chunk of searchOrAnswerIterator) { - if (stream.closed) { - loggerWithChild({ email: email }).info( - "[MessageApi] Stream closed during conversation search loop. Breaking.", - ) - wasStreamClosedPrematurely = true - break + + if (webSearchEnabled) { + loggerWithChild({ email: email }).info( + "Processing web search response", + ) + + stream.writeSSE({ + event: ChatSSEvents.Start, + data: "", + }) + + let sourceIndex = 0 + let allSources: WebSearchSource[] = [] + let finalGroundingSupports: GroundingSupport[] = [] + + for await (const chunk of searchOrAnswerIterator) { + if (stream.closed) { + loggerWithChild({ email: email }).info( + "[MessageApi] Stream closed during web search loop. Breaking.", + ) + wasStreamClosedPrematurely = true + break + } + // TODO: Handle websearch reasoning + if (chunk.text) { + answer += chunk.text + stream.writeSSE({ + event: ChatSSEvents.ResponseUpdate, + data: chunk.text, + }) + } + + if (chunk.sources && chunk.sources.length > 0) { + chunk.sources.forEach((source) => { + if ( + !allSources.some( + (existing) => existing.uri === source.uri, + ) + ) { + allSources.push(source) + } + }) + } + + if ( + chunk.groundingSupports && + chunk.groundingSupports.length > 0 + ) { + finalGroundingSupports = chunk.groundingSupports + } + + if (chunk.cost) { + costArr.push(chunk.cost) + } + if (chunk.metadata?.usage) { + tokenArr.push({ + inputTokens: chunk.metadata.usage.inputTokens || 0, + outputTokens: chunk.metadata.usage.outputTokens || 0, + }) + } } - if (chunk.text) { - if (reasoning) { - if (thinking && !chunk.text.includes(EndThinkingToken)) { - thinking += chunk.text - stream.writeSSE({ - event: ChatSSEvents.Reasoning, - data: chunk.text, - }) - } else { - // first time - if (!chunk.text.includes(StartThinkingToken)) { - let token = chunk.text - if (chunk.text.includes(EndThinkingToken)) { - token = chunk.text.split(EndThinkingToken)[0] - thinking += token - } else { - thinking += token - } + + // Web search citations from Gemini are provided only in the final streamed chunk, + // so processing them after streaming completes + const citationResult = processWebSearchCitations( + answer, + allSources, + finalGroundingSupports, + citations, + citationMap, + sourceIndex, + ) + + if (citationResult) { + answer = citationResult.updatedAnswer + sourceIndex = citationResult.updatedSourceIndex + + if (citationResult.newCitations.length > 0) { + citations.push(...citationResult.newCitations) + Object.assign(citationMap, citationResult.newCitationMap) + + stream.writeSSE({ + event: ChatSSEvents.CitationsUpdate, + data: JSON.stringify({ + contextChunks: citations, + citationMap: citationMap, + }), + }) + } + } + + parsed.answer = answer + } else { + for await (const chunk of searchOrAnswerIterator) { + if (stream.closed) { + loggerWithChild({ email: email }).info( + "[MessageApi] Stream closed during conversation search loop. Breaking.", + ) + wasStreamClosedPrematurely = true + break + } + if (chunk.text) { + if (reasoning) { + if (thinking && !chunk.text.includes(EndThinkingToken)) { + thinking += chunk.text stream.writeSSE({ event: ChatSSEvents.Reasoning, - data: token, + data: chunk.text, }) - } - } - } - if (reasoning && chunk.text.includes(EndThinkingToken)) { - reasoning = false - chunk.text = chunk.text.split(EndThinkingToken)[1].trim() - } - if (!reasoning) { - buffer += chunk.text - try { - parsed = jsonParseLLMOutput(buffer) || {} - if (parsed.answer && currentAnswer !== parsed.answer) { - if (currentAnswer === "") { - loggerWithChild({ email: email }).info( - "We were able to find the answer/respond to users query in the conversation itself so not applying RAG", - ) - stream.writeSSE({ - event: ChatSSEvents.Start, - data: "", - }) - // First valid answer - send the whole thing - stream.writeSSE({ - event: ChatSSEvents.ResponseUpdate, - data: parsed.answer, - }) - } else { - // Subsequent chunks - send only the new part - const newText = parsed.answer.slice( - currentAnswer.length, - ) + } else { + // first time + if (!chunk.text.includes(StartThinkingToken)) { + let token = chunk.text + if (chunk.text.includes(EndThinkingToken)) { + token = chunk.text.split(EndThinkingToken)[0] + thinking += token + } else { + thinking += token + } stream.writeSSE({ - event: ChatSSEvents.ResponseUpdate, - data: newText, + event: ChatSSEvents.Reasoning, + data: token, }) } - currentAnswer = parsed.answer } - } catch (err) { - const errMessage = (err as Error).message - loggerWithChild({ email: email }).error( - err, - `Error while parsing LLM output ${errMessage}`, - ) - continue + } + if (reasoning && chunk.text.includes(EndThinkingToken)) { + reasoning = false + chunk.text = chunk.text.split(EndThinkingToken)[1].trim() + } + if (!reasoning) { + buffer += chunk.text + try { + parsed = jsonParseLLMOutput(buffer) || {} + if (parsed.answer && currentAnswer !== parsed.answer) { + if (currentAnswer === "") { + loggerWithChild({ email: email }).info( + "We were able to find the answer/respond to users query in the conversation itself so not applying RAG", + ) + stream.writeSSE({ + event: ChatSSEvents.Start, + data: "", + }) + // First valid answer - send the whole thing + stream.writeSSE({ + event: ChatSSEvents.ResponseUpdate, + data: parsed.answer, + }) + } else { + // Subsequent chunks - send only the new part + const newText = parsed.answer.slice( + currentAnswer.length, + ) + stream.writeSSE({ + event: ChatSSEvents.ResponseUpdate, + data: newText, + }) + } + currentAnswer = parsed.answer + } + } catch (err) { + const errMessage = (err as Error).message + loggerWithChild({ email: email }).error( + err, + `Error while parsing LLM output ${errMessage}`, + ) + continue + } } } - } - if (chunk.cost) { - costArr.push(chunk.cost) - } - // Track token usage from metadata - if (chunk.metadata?.usage) { - tokenArr.push({ - inputTokens: chunk.metadata.usage.inputTokens || 0, - outputTokens: chunk.metadata.usage.outputTokens || 0, - }) + if (chunk.cost) { + costArr.push(chunk.cost) + } + // Track token usage from metadata + if (chunk.metadata?.usage) { + tokenArr.push({ + inputTokens: chunk.metadata.usage.inputTokens || 0, + outputTokens: chunk.metadata.usage.outputTokens || 0, + }) + } } } @@ -4668,7 +4882,7 @@ export const MessageApi = async (c: Context) => { startTime, count, offset: offset || 0, - intent: intent || {} + intent: intent || {}, }, } as QueryRouterLLMResponse @@ -4716,10 +4930,11 @@ export const MessageApi = async (c: Context) => { // - Preserved app/entity from previous query // - Updated count/pagination info // - All the smart follow-up logic from the LLM - + // Only check for fileIds if we need file context const lastUserMessage = messages[messages.length - 3] // Assistant is at -2, last user is at -3 - const parsedMessage = selectMessageSchema.safeParse(lastUserMessage) + const parsedMessage = + selectMessageSchema.safeParse(lastUserMessage) if (parsedMessage.error) { loggerWithChild({ email: email }).error( @@ -5289,7 +5504,7 @@ export const MessageRetryApi = async (c: Context) => { // If retrying an assistant message, get attachments from the previous user message if (!isUserMessage && conversation && conversation.length > 0) { - const prevUserMessage = conversation[conversation.length - 1] + const prevUserMessage = conversation[conversation.length - 1] if (prevUserMessage.messageRole === "user") { attachmentMetadata = await getAttachmentsByMessageId( db, @@ -5694,27 +5909,41 @@ export const MessageRetryApi = async (c: Context) => { let previousClassification: QueryRouterLLMResponse | null = null if (conversation.length > 0) { const previousUserMessage = conversation[conversation.length - 1] // In retry context, previous user message is at -1 - if (previousUserMessage?.queryRouterClassification && previousUserMessage.messageRole === "user") { + if ( + previousUserMessage?.queryRouterClassification && + previousUserMessage.messageRole === "user" + ) { try { const parsedClassification = - typeof previousUserMessage.queryRouterClassification === "string" - ? JSON.parse(previousUserMessage.queryRouterClassification) + typeof previousUserMessage.queryRouterClassification === + "string" + ? JSON.parse( + previousUserMessage.queryRouterClassification, + ) : previousUserMessage.queryRouterClassification - previousClassification = parsedClassification as QueryRouterLLMResponse - Logger.info(`Found previous classification in retry: ${JSON.stringify(previousClassification)}`) + previousClassification = + parsedClassification as QueryRouterLLMResponse + Logger.info( + `Found previous classification in retry: ${JSON.stringify(previousClassification)}`, + ) } catch (error) { - Logger.error(`Error parsing previous classification in retry: ${error}`) + Logger.error( + `Error parsing previous classification in retry: ${error}`, + ) } } } // Add chain break analysis for retry context - const messagesForChainBreak = isUserMessage - ? [...conversation, originalMessage] // Include the user message being retried - : conversation // For assistant retry, conversation already has the right scope - - const chainBreakClassifications = getRecentChainBreakClassifications(messagesForChainBreak) - const formattedChainBreaks = formatChainBreaksForPrompt(chainBreakClassifications) + const messagesForChainBreak = isUserMessage + ? [...conversation, originalMessage] // Include the user message being retried + : conversation // For assistant retry, conversation already has the right scope + + const chainBreakClassifications = + getRecentChainBreakClassifications(messagesForChainBreak) + const formattedChainBreaks = formatChainBreaksForPrompt( + chainBreakClassifications, + ) const searchSpan = streamSpan.startSpan("conversation_search") const searchOrAnswerIterator = @@ -5891,8 +6120,8 @@ export const MessageRetryApi = async (c: Context) => { sortDirection, startTime, count, - offset: parsed.filters.offset || 0, - intent: parsed.filters.intent || {} + offset: parsed.filters.offset || 0, + intent: parsed.filters.intent || {}, }, } as QueryRouterLLMResponse diff --git a/server/api/chat/utils.ts b/server/api/chat/utils.ts index 9027370dd..d10016201 100644 --- a/server/api/chat/utils.ts +++ b/server/api/chat/utils.ts @@ -886,9 +886,9 @@ function isValidAppSelection(value: any): value is AppSelection { } export interface ChainBreakClassification { - messageIndex: number; - classification: QueryRouterLLMResponse; - query: string; + messageIndex: number + classification: QueryRouterLLMResponse + query: string } function parseQueryRouterClassification( @@ -901,7 +901,11 @@ function parseQueryRouterClassification( typeof queryRouterClassification === "string" ? JSON.parse(queryRouterClassification) : queryRouterClassification - if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) { + if ( + Array.isArray(parsed) || + typeof parsed !== "object" || + parsed === null + ) { return null } return parsed as QueryRouterLLMResponse @@ -914,52 +918,73 @@ function parseQueryRouterClassification( } } -export function getRecentChainBreakClassifications(messages: SelectMessage[]): ChainBreakClassification[] { - const chainBreaks = extractChainBreakClassifications(messages); - const recentChainBreaks = chainBreaks.slice(0, 2); // limit to the last 2 chain breaks - getLoggerWithChild(Subsystem.Chat)().info(`[ChainBreak] Found ${recentChainBreaks.length} recent chain breaks`); - return recentChainBreaks; +export function getRecentChainBreakClassifications( + messages: SelectMessage[], +): ChainBreakClassification[] { + const chainBreaks = extractChainBreakClassifications(messages) + const recentChainBreaks = chainBreaks.slice(0, 2) // limit to the last 2 chain breaks + getLoggerWithChild(Subsystem.Chat)().info( + `[ChainBreak] Found ${recentChainBreaks.length} recent chain breaks`, + ) + return recentChainBreaks } -export function extractChainBreakClassifications(messages: SelectMessage[]): ChainBreakClassification[] { - const chainBreaks: ChainBreakClassification[] = []; +export function extractChainBreakClassifications( + messages: SelectMessage[], +): ChainBreakClassification[] { + const chainBreaks: ChainBreakClassification[] = [] messages.forEach((message, index) => { // Only process user messages with classifications - if (message.messageRole === 'user' && message.queryRouterClassification) { - const currentClassification = parseQueryRouterClassification(message.queryRouterClassification, index); - if (!currentClassification) return; + if (message.messageRole === "user" && message.queryRouterClassification) { + const currentClassification = parseQueryRouterClassification( + message.queryRouterClassification, + index, + ) + if (!currentClassification) return // Skip if this is the first user message (no previous user message available) - if (index < 2) return; + if (index < 2) return // Get the previous user message - const previousUserMessage = messages[index - 2]; - if (!previousUserMessage || previousUserMessage.messageRole !== 'user' || !previousUserMessage.queryRouterClassification) return; + const previousUserMessage = messages[index - 2] + if ( + !previousUserMessage || + previousUserMessage.messageRole !== "user" || + !previousUserMessage.queryRouterClassification + ) + return - const prevClassification = parseQueryRouterClassification(previousUserMessage.queryRouterClassification, index - 2); - if (!prevClassification) return; + const prevClassification = parseQueryRouterClassification( + previousUserMessage.queryRouterClassification, + index - 2, + ) + if (!prevClassification) return // If the current message is NOT a follow-up, store the previous user message's classification as a chain break if (currentClassification.isFollowUp === false) { chainBreaks.push({ messageIndex: index - 2, classification: prevClassification, - query: previousUserMessage.message || '' - }); - getLoggerWithChild(Subsystem.Chat)().info(`[ChainBreak] Chain break detected: "${previousUserMessage.message}" → "${message.message}"`); + query: previousUserMessage.message || "", + }) + getLoggerWithChild(Subsystem.Chat)().info( + `[ChainBreak] Chain break detected: "${previousUserMessage.message}" → "${message.message}"`, + ) } } - }); + }) - return chainBreaks.reverse(); + return chainBreaks.reverse() } -export function formatChainBreaksForPrompt(chainBreaks: ChainBreakClassification[]) { +export function formatChainBreaksForPrompt( + chainBreaks: ChainBreakClassification[], +) { if (chainBreaks.length === 0) { - return null; + return null } - + const formatted = { availableChainBreaks: chainBreaks.map((chainBreak, index) => ({ chainIndex: index + 1, @@ -967,7 +992,54 @@ export function formatChainBreaksForPrompt(chainBreaks: ChainBreakClassification originalQuery: chainBreak.query, classification: chainBreak.classification, })), - usage: 'These are previous conversation chains that were broken. The current query might relate to one of these earlier topics.' - }; - return formatted; + usage: + "These are previous conversation chains that were broken. The current query might relate to one of these earlier topics.", + } + return formatted +} + +export function findOptimalCitationInsertionPoint( + text: string, + targetIndex: number, +): number { + if (targetIndex >= text.length) { + return text.length + } + + if (targetIndex <= 0) { + return 0 + } + + const charAtTarget = text[targetIndex] + const charBeforeTarget = text[targetIndex - 1] + + // Word boundaries: space, punctuation, or start/end of text + const isWordBoundary = (char: string) => /[\s\.,;:!?\-\(\)\[\]{}"]/.test(char) + + if (isWordBoundary(charBeforeTarget) || isWordBoundary(charAtTarget)) { + return targetIndex + } + + let leftBoundary = targetIndex + let rightBoundary = targetIndex + + // Search backwards for a word boundary + while (leftBoundary > 0 && !isWordBoundary(text[leftBoundary - 1])) { + leftBoundary-- + } + + // Search forwards for a word boundary + while (rightBoundary < text.length && !isWordBoundary(text[rightBoundary])) { + rightBoundary++ + } + + const leftDistance = targetIndex - leftBoundary + const rightDistance = rightBoundary - targetIndex + + // Prefer the closer boundary, but lean towards right boundary (end of word) for better readability + if (leftDistance <= rightDistance || rightBoundary >= text.length) { + return leftBoundary + } else { + return rightBoundary + } } diff --git a/server/api/search.ts b/server/api/search.ts index 59e0a1084..6255ea5a5 100644 --- a/server/api/search.ts +++ b/server/api/search.ts @@ -187,6 +187,13 @@ export const messageSchema = z.object({ return val.toLowerCase() === "true" }), agentId: z.string().optional(), + enableWebSearch: z + .string() + .optional() + .transform((val) => { + if (!val) return false + return val.toLowerCase() === "true" + }), toolsList: z.preprocess( (val) => { if (typeof val === "string") { diff --git a/server/package.json b/server/package.json index 50abf2c98..05d6c16ab 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "@anthropic-ai/vertex-sdk": "^0.13.1", "@aws-sdk/client-bedrock": "^3.797.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", + "@google-cloud/vertexai": "^1.10.0", "@google/genai": "^1.9.0", "@hono/event-emitter": "^2.0.0", "@hono/oauth-providers": "^0.6.2", diff --git a/server/search/types.ts b/server/search/types.ts index 10ce90d0c..2d5834aca 100644 --- a/server/search/types.ts +++ b/server/search/types.ts @@ -59,6 +59,7 @@ export enum Apps { Xyne = "xyne", DataSource = "data-source", KnowledgeBase = "KnowledgeBase", + WebSearch = "web-search", } export const isValidApp = (app: string): boolean => { @@ -88,6 +89,9 @@ export const isValidEntity = (entity: string): boolean => { .map((v) => v.toLowerCase()) .includes(normalizedEntity) || Object.values(SlackEntity) + .map((v) => v.toLowerCase()) + .includes(normalizedEntity) || + Object.values(WebSearchEntity) .map((v) => v.toLowerCase()) .includes(normalizedEntity) : // Object.values(NotionEntity).map(v => v.toLowerCase()).includes(normalizedEntity) @@ -169,10 +173,10 @@ export enum MailAttachmentEntity { NotValid = "notvalid", } export enum KnowledgeBaseEntity { - File = "file", // Files within collections - Folder = 'folder', // Folders within collections - Collection = 'collection', // Collections (main containers) - KnowledgeBase = 'knowledgebase', // Legacy alias for collection + File = "file", // Files within collections + Folder = "folder", // Folders within collections + Collection = "collection", // Collections (main containers) + KnowledgeBase = "knowledgebase", // Legacy alias for collection } export const isMailAttachment = (entity: Entity): boolean => @@ -202,8 +206,14 @@ export enum SystemEntity { export enum DataSourceEntity { DataSourceFile = "data_source_file", } + +export enum WebSearchEntity { + WebSearch = "websearch", +} + export const SystemEntitySchema = z.nativeEnum(SystemEntity) export const DataSourceEntitySchema = z.nativeEnum(DataSourceEntity) +export const WebSearchEntitySchema = z.nativeEnum(WebSearchEntity) export const entitySchema = z.union([ SystemEntitySchema, PeopleEntitySchema, @@ -214,6 +224,7 @@ export const entitySchema = z.union([ MailAttachmentEntitySchema, ChatEntitySchema, DataSourceEntitySchema, + WebSearchEntitySchema, ]) export type Entity = @@ -226,6 +237,7 @@ export type Entity = | MailAttachmentEntity | SlackEntity | DataSourceEntity + | WebSearchEntity export type WorkspaceEntity = DriveEntity diff --git a/server/search/vespa.ts b/server/search/vespa.ts index 571fe2362..9e434b42a 100644 --- a/server/search/vespa.ts +++ b/server/search/vespa.ts @@ -843,8 +843,7 @@ export const HybridDefaultProfileForAgent = async ( )` } - const buildCollectionFileYQL = async () => { - + const buildCollectionFileYQL = async () => { // Extract all IDs from the key-value pairs const collectionIds: string[] = [] const collectionFolderIds: string[] = [] @@ -897,8 +896,9 @@ export const HybridDefaultProfileForAgent = async ( conditions.push(fileCondition) } } - - const finalCondition = conditions.length > 0 ? `(${conditions.join(" or ")})` : "true" + + const finalCondition = + conditions.length > 0 ? `(${conditions.join(" or ")})` : "true" // Collection files use clId for collections and docId for folders/files return ` ( diff --git a/server/shared/types.ts b/server/shared/types.ts index 1dacd8fa1..1ef13c07a 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -33,6 +33,7 @@ export { SystemEntity, dataSourceFileSchema, DataSourceEntity, + WebSearchEntity, } from "search/types" export type { Entity, VespaDataSourceFile, VespaGetResult } from "search/types" @@ -261,27 +262,27 @@ export const FileResponseSchema = VespaFileSchema.pick({ .strip() export const KbFileResponseSchema = VespaKbFileSchemaBase.pick({ - docId: true, - fileName: true, - app: true, - entity: true, - createdBy: true, - updatedAt: true, - itemId: true, - clId: true, - mimeType: true, + docId: true, + fileName: true, + app: true, + entity: true, + createdBy: true, + updatedAt: true, + itemId: true, + clId: true, + mimeType: true, +}) + .extend({ + app: z.literal(Apps.KnowledgeBase), + type: z.literal(KbItemsSchema), + chunk: z.string().optional(), + chunkIndex: z.number().optional(), + chunks_summary: z.array(scoredChunk).optional(), + relevance: z.number(), + matchfeatures: z.any().optional(), // Add matchfeatures + rankfeatures: z.any().optional(), }) - .extend({ - app: z.literal(Apps.KnowledgeBase), - type: z.literal(KbItemsSchema), - chunk: z.string().optional(), - chunkIndex: z.number().optional(), - chunks_summary: z.array(scoredChunk).optional(), - relevance: z.number(), - matchfeatures: z.any().optional(), // Add matchfeatures - rankfeatures: z.any().optional(), - }) - .strip() + .strip() export const EventResponseSchema = VespaEventSchema.pick({ docId: true, name: true, @@ -478,7 +479,7 @@ export interface AgentReasoningStepEnhanced { stepId?: string stepSummary?: string aiGeneratedSummary?: string - status?: 'in_progress' | 'completed' | 'failed' + status?: "in_progress" | "completed" | "failed" timestamp?: number iteration?: number isIterationSummary?: boolean @@ -501,12 +502,14 @@ export interface AgentReasoningToolSelected extends AgentReasoningStepEnhanced { toolName: AgentToolName | string // string for flexibility if new tools are added without enum update } -export interface AgentReasoningToolParameters extends AgentReasoningStepEnhanced { +export interface AgentReasoningToolParameters + extends AgentReasoningStepEnhanced { type: AgentReasoningStepType.ToolParameters parameters: Record // Parameters as an object } -export interface AgentReasoningToolExecuting extends AgentReasoningStepEnhanced { +export interface AgentReasoningToolExecuting + extends AgentReasoningStepEnhanced { type: AgentReasoningStepType.ToolExecuting toolName: AgentToolName | string } @@ -524,17 +527,20 @@ export interface AgentReasoningSynthesis extends AgentReasoningStepEnhanced { details: string // e.g., "Synthesizing answer from X fragments..." } -export interface AgentReasoningValidationError extends AgentReasoningStepEnhanced { +export interface AgentReasoningValidationError + extends AgentReasoningStepEnhanced { type: AgentReasoningStepType.ValidationError details: string // e.g., "Single result validation failed (POOR_MATCH #X). Will continue searching." } -export interface AgentReasoningBroadeningSearch extends AgentReasoningStepEnhanced { +export interface AgentReasoningBroadeningSearch + extends AgentReasoningStepEnhanced { type: AgentReasoningStepType.BroadeningSearch details: string // e.g., "Specific search failed validation X times. Attempting to broaden search." } -export interface AgentReasoningAnalyzingQuery extends AgentReasoningStepEnhanced { +export interface AgentReasoningAnalyzingQuery + extends AgentReasoningStepEnhanced { type: AgentReasoningStepType.AnalyzingQuery details: string // e.g., "Analyzing your question..." }