/* # SPDX-FileCopyrightText: Copyright (c) 1993-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. */ import React, { useState, useEffect, useRef } from 'react'; import styles from '@/styles/Sidebar.module.css'; interface Model { id: string; name: string; } interface ChatMetadata { name: string; } interface SidebarProps { showIngestion: boolean; setShowIngestion: (value: boolean) => void; refreshTrigger?: number; currentChatId: string | null; onChatChange: (chatId: string) => Promise; } export default function Sidebar({ showIngestion, setShowIngestion, refreshTrigger = 0, currentChatId, onChatChange }: SidebarProps) { const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); const [expandedSections, setExpandedSections] = useState>(new Set(["config", "history"])); const [isLoading, setIsLoading] = useState(false); const [availableSources, setAvailableSources] = useState([]); const [selectedSources, setSelectedSources] = useState([]); const [selectedModel, setSelectedModel] = useState(""); const [isLoadingSources, setIsLoadingSources] = useState(false); const [availableModels, setAvailableModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); const [chats, setChats] = useState([]); const [isLoadingChats, setIsLoadingChats] = useState(false); const [chatMetadata, setChatMetadata] = useState>({}); // Add ref for chat list const chatListRef = useRef(null); // Load initial configuration useEffect(() => { const loadInitialConfig = async () => { try { setIsLoading(true); // Get selected model const modelResponse = await fetch("/api/selected_model"); if (modelResponse.ok) { const { model } = await modelResponse.json(); setSelectedModel(model); } // Get selected sources const sourcesResponse = await fetch("/api/selected_sources"); if (sourcesResponse.ok) { const { sources } = await sourcesResponse.json(); setSelectedSources(sources); } // Get available models await fetchAvailableModels(); // Get sources await fetchSources(); // Get chats if history section is expanded (which it is by default) if (expandedSections.has('history')) { await fetchChats(); } } catch (error) { console.error("Error loading initial config:", error); } finally { setIsLoading(false); } }; loadInitialConfig(); }, []); // Fetch available models const fetchAvailableModels = async () => { try { setIsLoadingModels(true); const response = await fetch("/api/available_models"); if (!response.ok) { const errorText = await response.text(); console.error(`Error fetching available models: ${response.status} - ${errorText}`); return; } const data = await response.json(); const models = data.models.map((modelId: string) => ({ id: modelId, name: modelId.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') })); setAvailableModels(models); } catch (error) { console.error("Error fetching available models:", error); } finally { setIsLoadingModels(false); } }; // Fetch available sources const fetchSources = async () => { try { setIsLoadingSources(true); console.log("Fetching sources..."); const response = await fetch("/api/sources"); if (!response.ok) { const errorText = await response.text(); console.error(`Error fetching sources: ${response.status} - ${errorText}`); setAvailableSources([]); return; } const data = await response.json(); console.log("Sources fetched:", data.sources); setAvailableSources(data.sources || []); } catch (error) { console.error("Error fetching sources:", error); setAvailableSources([]); } finally { setIsLoadingSources(false); } }; // Get sources on initial load and when the context section is expanded useEffect(() => { if (expandedSections.has('context')) { fetchSources(); } }, [expandedSections]); // Refresh sources when refreshTrigger changes (document ingestion) useEffect(() => { if (refreshTrigger > 0) { // Only refresh if not the initial render fetchSources(); } }, [refreshTrigger]); // Add function to fetch chat metadata const fetchChatMetadata = async (chatId: string) => { try { const response = await fetch(`/api/chat/${chatId}/metadata`); if (response.ok) { const metadata = await response.json(); setChatMetadata(prev => ({ ...prev, [chatId]: metadata })); } } catch (error) { console.error(`Error fetching metadata for chat ${chatId}:`, error); } }; // Update fetchChats to also fetch metadata const fetchChats = async () => { try { console.log("fetchChats: Starting to fetch chats..."); setIsLoadingChats(true); const response = await fetch("/api/chats"); if (response.ok) { const data = await response.json(); console.log("fetchChats: Received chats:", data.chats); setChats(data.chats); // Fetch metadata for each chat await Promise.all(data.chats.map(fetchChatMetadata)); console.log("fetchChats: Completed fetching all chat metadata"); } else { console.error("fetchChats: Failed to fetch chats, status:", response.status); } } catch (error) { console.error("Error fetching chats:", error); } finally { setIsLoadingChats(false); } }; // Fetch chats when history section is expanded useEffect(() => { if (expandedSections.has('history')) { fetchChats(); } }, [expandedSections]); // Update highlight position when currentChatId changes useEffect(() => { if (currentChatId && chatListRef.current) { const activeChat = chatListRef.current.querySelector(`.${styles.active}`) as HTMLElement; if (activeChat) { const offset = activeChat.offsetTop; chatListRef.current.style.setProperty('--highlight-offset', `${offset}px`); } } }, [currentChatId, chats]); // Add new effect to handle initial position and chat list loading useEffect(() => { if (chatListRef.current && currentChatId && chats.length > 0) { // Small delay to ensure DOM is ready setTimeout(() => { const activeChat = chatListRef.current?.querySelector(`.${styles.active}`) as HTMLElement; if (activeChat) { const offset = activeChat.offsetTop; chatListRef.current?.style.setProperty('--highlight-offset', `${offset}px`); } }, 50); } }, [isVisible, expandedSections.has('history'), chats.length]); const handleClose = () => { setIsClosing(true); setTimeout(() => { setIsVisible(false); setIsClosing(false); }, 500); // Match the new animation duration }; const toggleSidebar = () => { if (!isVisible) { setIsVisible(true); setIsClosing(false); fetchSources(); // Also fetch chats when sidebar opens if (expandedSections.has('history')) { fetchChats(); } } else { handleClose(); } }; const toggleSection = (section: string) => { const newExpandedSections = new Set(expandedSections); if (newExpandedSections.has(section)) { newExpandedSections.delete(section); } else { newExpandedSections.add(section); // Get sources when context section is expanded if (section === 'context') { fetchSources(); } } setExpandedSections(newExpandedSections); }; const isSectionExpanded = (section: string) => { return expandedSections.has(section); }; const handleSourceToggle = async (source: string) => { let newSelectedSources: string[]; if (selectedSources.includes(source)) { // Remove source if already selected newSelectedSources = selectedSources.filter(s => s !== source); } else { // Add source if not selected newSelectedSources = [...selectedSources, source]; } setSelectedSources(newSelectedSources); try { const response = await fetch("/api/selected_sources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(newSelectedSources) }); if (!response.ok) { console.error("Failed to update selected sources"); // Revert the local state if the update failed setSelectedSources(selectedSources); } } catch (error) { console.error("Error updating selected sources:", error); // Revert the local state if the update failed setSelectedSources(selectedSources); } }; const handleChatSelect = async (chatId: string) => { try { await onChatChange(chatId); // Close sidebar on mobile after selection if (window.innerWidth < 768) { handleClose(); } } catch (error) { console.error("Error selecting chat:", error); } }; const handleRenameChat = async (chatId: string, currentName: string) => { const newName = prompt("Enter new chat name:", currentName); if (newName && newName.trim() && newName !== currentName) { try { const response = await fetch("/api/chat/rename", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, new_name: newName.trim() }) }); if (!response.ok) { console.error("Failed to rename chat"); return; } // Fetch updated metadata for the renamed chat await fetchChatMetadata(chatId); } catch (error) { console.error("Error renaming chat:", error); } } }; const handleDeleteChat = async (chatId: string) => { try { // Delete the chat const response = await fetch(`/api/chat/${chatId}`, { method: "DELETE" }); if (!response.ok) { console.error("Failed to delete chat"); return; } // Refresh chat list await fetchChats(); // If we deleted the current chat if (currentChatId === chatId) { // Get updated list of chats const chatsResponse = await fetch("/api/chats"); const { chats: remainingChats } = await chatsResponse.json(); if (remainingChats.length > 0) { // Switch to another chat await onChatChange(remainingChats[0]); } else { // No chats left, create a new one await handleNewChat(); } } } catch (error) { console.error("Error deleting chat:", error); } }; const handleNewChat = async () => { try { console.log("handleNewChat: Starting new chat creation..."); // Create new chat using backend endpoint const response = await fetch("/api/chat/new", { method: "POST" }); if (!response.ok) { console.error("Failed to create new chat"); return; } const data = await response.json(); console.log("handleNewChat: Created new chat:", data.chat_id); // First, refresh the chat list to ensure the new chat is available console.log("handleNewChat: Refreshing chat list..."); await fetchChats(); console.log("handleNewChat: Chat list refreshed"); // Then change to the new chat await onChatChange(data.chat_id); console.log("handleNewChat: Changed to new chat"); // Close sidebar on mobile if (window.innerWidth < 768) { handleClose(); } // Add a small delay to ensure the DOM has updated, then trigger highlight animation setTimeout(() => { if (chatListRef.current) { const activeChat = chatListRef.current.querySelector(`.${styles.active}`) as HTMLElement; if (activeChat) { const offset = activeChat.offsetTop; chatListRef.current.style.setProperty('--highlight-offset', `${offset}px`); } } }, 100); // Increased delay for more reliability } catch (error) { console.error("Error creating new chat:", error); } }; const handleClearAllChats = async () => { // Show confirmation dialog const confirmClear = window.confirm( `Are you sure you want to clear all ${chats.length} chat conversations? This action cannot be undone.` ); if (!confirmClear) { return; } try { const response = await fetch("/api/chats/clear", { method: "DELETE" }); if (!response.ok) { console.error("Failed to clear all chats"); alert("Failed to clear chats. Please try again."); return; } const data = await response.json(); // Switch to the new chat created by the backend await onChatChange(data.new_chat_id); // Refresh chat list await fetchChats(); // Close sidebar on mobile if (window.innerWidth < 768) { handleClose(); } console.log(`Successfully cleared ${data.cleared_count} chats`); } catch (error) { console.error("Error clearing all chats:", error); alert("An error occurred while clearing chats. Please try again."); } }; const handleModelChange = async (event: React.ChangeEvent) => { const newModel = event.target.value; const newModelLower = newModel.toLowerCase(); setSelectedModel(newModel); try { console.log("Updating selected model to:", newModel); const response = await fetch("/api/selected_model", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: newModel }) }); if (!response.ok) { console.error("Failed to update selected model"); // Revert the local state if the update failed setSelectedModel(selectedModel); } } catch (error) { console.error("Error updating selected model:", error); // Revert the local state if the update failed setSelectedModel(selectedModel); } }; return ( <> {isVisible && ( <>

Spark Chat

{/* Model Selection */}
toggleSection('model')} >

Model

{/* Context */}
toggleSection('context')} >

Context

{availableSources.length === 0 ? (
No sources available
) : ( availableSources.map(source => (
handleSourceToggle(source)} />
)) )}
{/* Chat History */}
toggleSection('history')} >

Chat History

{isLoadingChats ? (
Loading chats...
) : chats.length === 0 ? (
No previous chats
) : ( chats.map((chatId) => (
handleChatSelect(chatId)} >
{chatMetadata[chatId]?.name || chatId.slice(0, 8)}
)) )}
{/* Chat Action Buttons at bottom */}
)} ); }