diff --git a/src/app/LightspeedChatbot/LightspeedChatbot.tsx b/src/app/LightspeedChatbot/LightspeedChatbot.tsx index ca4c42d..3a30294 100644 --- a/src/app/LightspeedChatbot/LightspeedChatbot.tsx +++ b/src/app/LightspeedChatbot/LightspeedChatbot.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useDocumentTitle } from '@app/utils/useDocumentTitle'; -import { Bullseye, DropdownGroup, DropdownItem, DropdownList, Title, TitleSizes } from '@patternfly/react-core'; +import { Bullseye, DropdownGroup, DropdownItem, DropdownList, Flex, FlexItem, Title, TitleSizes } from '@patternfly/react-core'; // Chatbot components import ChatbotToggle from '@patternfly/chatbot/dist/dynamic/ChatbotToggle'; @@ -20,6 +20,9 @@ import ChatbotHeader, { ChatbotHeaderSelectorDropdown, ChatbotHeaderTitle } from '@patternfly/chatbot/dist/dynamic/ChatbotHeader'; +import FileDropZone from '@patternfly/chatbot/dist/dynamic/FileDropZone'; +import FileDetailsLabel from '@patternfly/chatbot/dist/dynamic/FileDetailsLabel'; +import ChatbotAlert from '@patternfly/chatbot/dist/dynamic/ChatbotAlert'; // Icons import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; @@ -29,9 +32,7 @@ import OutlinedWindowRestoreIcon from '@patternfly/react-icons/dist/esm/icons/ou // Local imports import { useChatbot } from './hooks/useChatbot'; import { ToolExecutionCards } from './components/ToolExecutionCards'; -import { FOOTNOTE_PROPS, INITIAL_CONVERSATIONS, INITIAL_WELCOME_PROMPTS } from './constants'; -import { findMatchingItems } from './utils/helpers'; -import { Conversation } from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { FOOTNOTE_PROPS, INITIAL_WELCOME_PROMPTS } from './constants'; /** * Main Lightspeed Chatbot Component @@ -40,13 +41,13 @@ import { Conversation } from '@patternfly/chatbot/dist/dynamic/ChatbotConversati * - Model selection * - Streaming responses * - Tool execution tracking + * - File attachments * - Conversation history * - Multiple display modes (overlay, docked, fullscreen) */ const LightspeedChatbot: React.FunctionComponent = () => { useDocumentTitle('Lightspeed Chatbot'); - // Use the custom hook for all chatbot logic const { chatbotVisible, displayMode, @@ -56,18 +57,30 @@ const LightspeedChatbot: React.FunctionComponent = () => { isSendButtonDisabled, isDrawerOpen, conversations, + currentConversationId, announcement, toolExecutions, scrollToBottomRef, + attachedFiles, + isLoadingFile, + fileError, + showFileAlert, onSelectModel, onSelectDisplayMode, handleSend, + handleAttach, + handleFileDrop, + onAttachmentClose, + onCloseFileAlert, + handleTextInputChange, + handleConversationSelect, setChatbotVisible, setMessages, - setConversations, setCurrentConversationId, setIsDrawerOpen - } = useChatbot(); + } = useChatbot(); + + // Enhanced message rendering with tool execution support const renderMessages = () => { @@ -113,29 +126,23 @@ const LightspeedChatbot: React.FunctionComponent = () => { displayMode={displayMode} onDrawerToggle={() => { setIsDrawerOpen(!isDrawerOpen); - setConversations(INITIAL_CONVERSATIONS); }} isDrawerOpen={isDrawerOpen} setIsDrawerOpen={setIsDrawerOpen} - activeItemId="1" - // eslint-disable-next-line no-console - onSelectActiveItem={(e, selectedItem) => console.log(`Selected history item with id ${selectedItem}`)} + activeItemId={currentConversationId} + onSelectActiveItem={(e, selectedItem) => { + console.log(`Selected history item with id ${selectedItem}`); + if (typeof selectedItem === 'string') { + handleConversationSelect(selectedItem); + } + }} conversations={conversations} onNewChat={() => { setIsDrawerOpen(!isDrawerOpen); setMessages([]); - setConversations(INITIAL_CONVERSATIONS); setCurrentConversationId(''); }} - handleTextInputChange={(value: string) => { - if (value === '') { - setConversations(INITIAL_CONVERSATIONS); - } - // this is where you would perform search on the items in the drawer - // and update the state - const newConversations: { [key: string]: Conversation[] } = findMatchingItems(value); - setConversations(newConversations); - }} + handleTextInputChange={handleTextInputChange} drawerContent={ <> @@ -145,7 +152,7 @@ const LightspeedChatbot: React.FunctionComponent = () => { displayMode={displayMode} showOnFullScreen={horizontalLogo} showOnDefault={iconLogo} - > + /> @@ -196,24 +203,50 @@ const LightspeedChatbot: React.FunctionComponent = () => { - - {/* Update the announcement prop on MessageBox whenever a new message is sent - so that users of assistive devices receive sufficient context */} - - - {/* Display all messages */} - {renderMessages()} - {/* Scroll reference at the bottom of all messages for proper streaming behavior */} -
- - + + + + {showFileAlert && ( + + {fileError} + + )} + + {renderMessages()} +
+ + + + + {attachedFiles.map((file, index) => ( + + onAttachmentClose(index)} + /> + + ))} + @@ -221,7 +254,7 @@ const LightspeedChatbot: React.FunctionComponent = () => { } - > + /> ); diff --git a/src/app/LightspeedChatbot/components/ToolExecutionCards.tsx b/src/app/LightspeedChatbot/components/ToolExecutionCards.tsx index 97ae463..4bc115c 100644 --- a/src/app/LightspeedChatbot/components/ToolExecutionCards.tsx +++ b/src/app/LightspeedChatbot/components/ToolExecutionCards.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { Card, CardBody, CardTitle, Flex, FlexItem } from '@patternfly/react-core'; import { ToolExecutionCardsProps } from '../types'; /** @@ -12,15 +12,22 @@ export const ToolExecutionCards: React.FC = ({ tools }) } return ( - + {tools.map((tool, index) => ( - - Tool Execution - - Using tool: {tool} - - + + + Tool Execution + + Using tool: {tool} + + + ))} - + ); }; \ No newline at end of file diff --git a/src/app/LightspeedChatbot/constants.ts b/src/app/LightspeedChatbot/constants.ts index c3fdff5..ab75635 100644 --- a/src/app/LightspeedChatbot/constants.ts +++ b/src/app/LightspeedChatbot/constants.ts @@ -1,5 +1,6 @@ import { MessageProps } from '@patternfly/chatbot/dist/dynamic/Message'; import { WelcomePrompt } from '@patternfly/chatbot/dist/dynamic/ChatbotWelcomePrompt'; +import { Conversation } from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; // API Configuration export const API_BASE_URL = 'http://localhost:8080'; @@ -13,7 +14,7 @@ export const BOT_AVATAR = // Initial states export const INITIAL_MESSAGES: MessageProps[] = []; export const INITIAL_WELCOME_PROMPTS: WelcomePrompt[] = []; -export const INITIAL_CONVERSATIONS = {}; +export const INITIAL_CONVERSATIONS: Conversation[] = []; // Default system prompt export const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.'; diff --git a/src/app/LightspeedChatbot/hooks/useChatbot.ts b/src/app/LightspeedChatbot/hooks/useChatbot.ts index 56f1af4..2148936 100644 --- a/src/app/LightspeedChatbot/hooks/useChatbot.ts +++ b/src/app/LightspeedChatbot/hooks/useChatbot.ts @@ -2,14 +2,15 @@ import React from 'react'; import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; import { MessageProps } from '@patternfly/chatbot/dist/dynamic/Message'; import { Conversation } from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { DropEvent, DropdownItem, DropdownList } from '@patternfly/react-core'; -import { Model, QueryRequest, StreamTokenData, StreamEndData } from '../types'; +import { Model, QueryRequest, StreamTokenData, StreamEndData, ConversationResponse } from '../types'; import { INITIAL_MESSAGES, INITIAL_CONVERSATIONS, USER_AVATAR, BOT_AVATAR, DEFAULT_SYSTEM_PROMPT } from '../constants'; -import { fetchModels, sendStreamingQuery } from '../services/api'; +import { fetchModels, sendStreamingQuery, fetchConversation, deleteConversation } from '../services/api'; import { generateId, findMatchingItems, copyToClipboard } from '../utils/helpers'; export const useChatbot = () => { - // State management + // Core state const [chatbotVisible, setChatbotVisible] = React.useState(false); const [displayMode, setDisplayMode] = React.useState(ChatbotDisplayMode.default); const [messages, setMessages] = React.useState(INITIAL_MESSAGES); @@ -18,13 +19,18 @@ export const useChatbot = () => { const [availableModels, setAvailableModels] = React.useState([]); const [isSendButtonDisabled, setIsSendButtonDisabled] = React.useState(false); const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); - const [conversations, setConversations] = React.useState( - INITIAL_CONVERSATIONS, - ); + const [conversations, setConversations] = React.useState(INITIAL_CONVERSATIONS); + const [allConversations, setAllConversations] = React.useState(INITIAL_CONVERSATIONS); const [announcement, setAnnouncement] = React.useState(); const [currentConversationId, setCurrentConversationId] = React.useState(''); const [toolExecutions, setToolExecutions] = React.useState<{ [messageId: string]: string[] }>({}); + // Attachment state - now supports multiple files + const [attachedFiles, setAttachedFiles] = React.useState([]); + const [isLoadingFile, setIsLoadingFile] = React.useState(false); + const [fileError, setFileError] = React.useState(); + const [showFileAlert, setShowFileAlert] = React.useState(false); + const scrollToBottomRef = React.useRef(null); // Load available models on component mount @@ -49,18 +55,103 @@ export const useChatbot = () => { } }, [messages]); - // Event handlers - const onSelectModel = ( - _event: React.MouseEvent | undefined, - value: string | number | undefined, - ) => { + // Load conversation messages when a conversation is selected + const loadConversationMessages = async (conversationId: string) => { + if (!conversationId) return; + + try { + const conversationData: ConversationResponse = await fetchConversation(conversationId); + + // Convert API response to MessageProps format + const convertedMessages: MessageProps[] = []; + + for (const chatEntry of conversationData.chat_history) { + for (const message of chatEntry.messages) { + const messageId = generateId(); + const messageProps: MessageProps = { + id: messageId, + role: message.type === 'user' ? 'user' : 'bot', + content: message.content, + name: message.type === 'user' ? 'User' : 'Lightspeed AI', + avatar: message.type === 'user' ? USER_AVATAR : BOT_AVATAR, + isLoading: false, + }; + + if (message.type === 'assistant') { + messageProps.actions = { + copy: { onClick: () => copyToClipboard(message.content) }, + share: { onClick: () => {} }, + listen: { onClick: () => {} }, + }; + } + + convertedMessages.push(messageProps); + } + } + + setMessages(convertedMessages); + setCurrentConversationId(conversationId); + } catch (error) { + console.error('Error loading conversation:', error); + setAnnouncement('Failed to load conversation. Please try again.'); + } + }; + + // Remove a conversation from the list + const removeConversation = async (conversationId: string) => { + try { + // Call the API to delete the conversation + await deleteConversation(conversationId); + + // Update local state after successful API call + setAllConversations((prev) => prev.filter((conv) => conv.id !== conversationId)); + setConversations((prev) => prev.filter((conv) => conv.id !== conversationId)); + + // If we're deleting the current conversation, clear the messages and conversation ID + if (currentConversationId === conversationId) { + setMessages([]); + setCurrentConversationId(''); + } + + setAnnouncement('Conversation removed successfully.'); + console.log('Conversation removed successfully.'); + } catch (error) { + console.error('Error removing conversation:', error); + setAnnouncement('Failed to remove conversation. Please try again.'); + } + }; + + // Add a conversation to the list (when a new conversation is created) + const addConversation = (conversationId: string, firstMessage: string) => { + const newConversation: Conversation = { + id: conversationId, + text: firstMessage.substring(0, 50) + (firstMessage.length > 50 ? '...' : ''), + menuItems: [ + React.createElement(DropdownList, { + key: 'list-1', + children: React.createElement( + DropdownItem, + { + value: 'Delete', + id: 'Delete', + onClick: () => removeConversation(conversationId), + }, + 'Delete', + ), + }), + ], + }; + + setAllConversations((prev) => [newConversation, ...prev]); + setConversations((prev) => [newConversation, ...prev]); + }; + + // Selection handlers + const onSelectModel = (_event?: React.MouseEvent, value?: string | number) => { setSelectedModel(value as string); }; - const onSelectDisplayMode = ( - _event: React.MouseEvent | undefined, - value: string | number | undefined, - ) => { + const onSelectDisplayMode = (_event?: React.MouseEvent, value?: string | number) => { setDisplayMode(value as ChatbotDisplayMode); }; @@ -70,40 +161,144 @@ export const useChatbot = () => { const onDrawerToggle = () => { setIsDrawerOpen(!isDrawerOpen); - setConversations(INITIAL_CONVERSATIONS); }; const onNewChat = () => { setIsDrawerOpen(!isDrawerOpen); setMessages([]); - setConversations(INITIAL_CONVERSATIONS); setCurrentConversationId(''); }; const handleTextInputChange = (value: string) => { - if (value === '') { - setConversations(INITIAL_CONVERSATIONS); - return; - } // Search conversations based on input - const newConversations = findMatchingItems(value); + const newConversations = findMatchingItems(value, allConversations); setConversations(newConversations); }; + const handleConversationSelect = (conversationId: string) => { + loadConversationMessages(conversationId); + setIsDrawerOpen(false); + }; + + // File handling + const readFile = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const handleFile = (fileArr: File[]) => { + setIsLoadingFile(true); + + // Validate total file count (max 5 files) + if (attachedFiles.length + fileArr.length > 5) { + setShowFileAlert(true); + setFileError('Maximum 5 files allowed. Please remove some files before adding more.'); + setIsLoadingFile(false); + return; + } + + // Validate each file size (25MB limit) + for (const file of fileArr) { + if (file.size > 25000000) { + setShowFileAlert(true); + setFileError(`File "${file.name}" is too large. File size must be less than 25MB.`); + setIsLoadingFile(false); + return; + } + } + + // Check for duplicate files + const duplicateFiles = fileArr.filter((newFile) => + attachedFiles.some((existingFile) => existingFile.name === newFile.name && existingFile.size === newFile.size), + ); + + if (duplicateFiles.length > 0) { + setShowFileAlert(true); + setFileError(`File "${duplicateFiles[0].name}" is already attached.`); + setIsLoadingFile(false); + return; + } + + // Process all files + Promise.all(fileArr.map((file) => readFile(file))) + .then(() => { + setAttachedFiles((prev) => [...prev, ...fileArr]); + setShowFileAlert(false); + setFileError(undefined); + // Simulate loading delay for better UX + setTimeout(() => { + setIsLoadingFile(false); + }, 500); + }) + .catch((error: DOMException) => { + setFileError(`Failed to read files: ${error.message}`); + setShowFileAlert(true); + setIsLoadingFile(false); + }); + }; + + const handleAttach = (files: File[]) => { + handleFile(files); + }; + + const handleFileDrop = (event: DropEvent, files: File[]) => { + handleFile(files); + }; + + const onAttachmentClose = (fileIndex: number) => { + setAttachedFiles((prev) => prev.filter((_, index) => index !== fileIndex)); + }; + + const onCloseFileAlert = () => { + setShowFileAlert(false); + setFileError(undefined); + }; + const handleSend = async (message: string | number) => { setIsSendButtonDisabled(true); const messageContent = String(message); + // Read file contents if attachments exist + let fileContents: string[] = []; + if (attachedFiles.length > 0) { + try { + const fileContentPromises = attachedFiles.map((file) => readFile(file)); + const contents = await Promise.all(fileContentPromises); + fileContents = contents.map((content) => content as string); + } catch (error) { + console.error('Error reading files:', error); + setFileError('Failed to read file content'); + setShowFileAlert(true); + setIsSendButtonDisabled(false); + return; + } + } + // Create new messages array with user message const newMessages: MessageProps[] = [...messages]; - newMessages.push({ + const userMessage: MessageProps = { id: generateId(), role: 'user', content: messageContent, name: 'User', avatar: USER_AVATAR, isLoading: false, - }); + }; + + // Add attachment information if files are attached + if (attachedFiles.length > 0 && fileContents.length > 0) { + userMessage.attachments = attachedFiles.map((file, index) => ({ + name: file.name, + id: generateId(), + // No onClose callback for sent attachments - they should be permanent + })); + } + + console.log(userMessage); + newMessages.push(userMessage); // Add bot message placeholder const botMessageId = generateId(); @@ -119,6 +314,12 @@ export const useChatbot = () => { setMessages(newMessages); setAnnouncement(`Message from User: ${messageContent}. Message from Lightspeed AI is loading.`); + // Clear attachments from footer after sending + if (attachedFiles.length > 0 && fileContents.length > 0) { + setAttachedFiles([]); + setIsLoadingFile(false); + } + try { const queryRequest: QueryRequest = { query: messageContent, @@ -126,6 +327,14 @@ export const useChatbot = () => { model: selectedModel || undefined, provider: selectedProvider || undefined, system_prompt: DEFAULT_SYSTEM_PROMPT, + attachments: + attachedFiles.length > 0 && fileContents.length > 0 + ? attachedFiles.map((file, index) => ({ + attachment_type: 'log', + content_type: file.type, + content: fileContents[index], + })) + : undefined, }; let streamingContent = ''; @@ -163,6 +372,11 @@ export const useChatbot = () => { (conversationId: string) => { finalConversationId = conversationId; setCurrentConversationId(conversationId); + + // Add conversation to list if it's a new one + if (!currentConversationId && conversationId) { + addConversation(conversationId, messageContent); + } }, // onEnd callback (endData: StreamEndData) => { @@ -212,7 +426,7 @@ export const useChatbot = () => { }; return { - // State + // Core state chatbotVisible, displayMode, messages, @@ -227,6 +441,12 @@ export const useChatbot = () => { toolExecutions, scrollToBottomRef, + // Attachment state - updated for multiple files + attachedFiles, + isLoadingFile, + fileError, + showFileAlert, + // Actions onSelectModel, onSelectDisplayMode, @@ -234,20 +454,20 @@ export const useChatbot = () => { onDrawerToggle, onNewChat, handleTextInputChange, + handleConversationSelect, handleSend, - // Setters (needed for direct state updates) + // Attachment actions + handleAttach, + handleFileDrop, + onAttachmentClose, + onCloseFileAlert, + + // Setters (for direct state updates) setChatbotVisible, - setDisplayMode, setMessages, - setSelectedModel, - setSelectedProvider, - setAvailableModels, - setIsSendButtonDisabled, - setIsDrawerOpen, setConversations, - setAnnouncement, setCurrentConversationId, - setToolExecutions, + setIsDrawerOpen, }; }; diff --git a/src/app/LightspeedChatbot/services/api.ts b/src/app/LightspeedChatbot/services/api.ts index 2bdb70c..a77f69e 100644 --- a/src/app/LightspeedChatbot/services/api.ts +++ b/src/app/LightspeedChatbot/services/api.ts @@ -7,6 +7,7 @@ import { StreamStartData, StreamTokenData, StreamEndData, + ConversationResponse, } from '../types'; /** @@ -35,18 +36,7 @@ export const fetchModels = async (): Promise => { return models; } catch (error) { console.error('Error fetching models:', error); - // Return fallback models for testing - return [ - { - identifier: 'test-model', - metadata: {}, - api_model_type: 'llm', - provider_id: 'test', - provider_resource_id: 'test-model', - type: 'model', - model_type: 'llm', - }, - ]; + return []; } }; @@ -147,3 +137,56 @@ export const sendStreamingQuery = async ( throw error; } }; + +/** + * Fetches a conversation by ID from the API + * @param conversationId The ID of the conversation to fetch + * @returns Promise The conversation data + */ +export const fetchConversation = async (conversationId: string): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/v1/conversations/${conversationId}`, { + method: 'GET', + }); + + console.log('Conversation response status:', response.status); + + if (!response.ok) { + console.error('Conversation API error:', response.status, response.statusText); + throw new Error(`Failed to fetch conversation: ${response.status}`); + } + + const data = await response.json(); + console.log('Conversation response data:', data); + + return data; + } catch (error) { + console.error('Error fetching conversation:', error); + throw error; + } +}; + +/** + * Deletes a conversation by ID from the API + * @param conversationId The ID of the conversation to delete + * @returns Promise + */ +export const deleteConversation = async (conversationId: string): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/v1/conversations/${conversationId}`, { + method: 'DELETE', + }); + + console.log('Delete conversation response status:', response.status); + + if (!response.ok) { + console.error('Delete conversation API error:', response.status, response.statusText); + throw new Error(`Failed to delete conversation: ${response.status}`); + } + + console.log('Conversation deleted successfully:', conversationId); + } catch (error) { + console.error('Error deleting conversation:', error); + throw error; + } +}; diff --git a/src/app/LightspeedChatbot/types.ts b/src/app/LightspeedChatbot/types.ts index 1d384b8..813566c 100644 --- a/src/app/LightspeedChatbot/types.ts +++ b/src/app/LightspeedChatbot/types.ts @@ -27,6 +27,19 @@ export interface QueryResponse { response: string; } +// Conversation history types +export interface ConversationResponse { + conversation_id: string; + chat_history: Array<{ + messages: Array<{ + content: string; + type: 'user' | 'assistant'; + }>; + started_at: string; + completed_at?: string; + }>; +} + // Streaming types export interface StreamEvent { event: 'start' | 'token' | 'end'; diff --git a/src/app/LightspeedChatbot/utils/helpers.ts b/src/app/LightspeedChatbot/utils/helpers.ts index 2da531e..418be31 100644 --- a/src/app/LightspeedChatbot/utils/helpers.ts +++ b/src/app/LightspeedChatbot/utils/helpers.ts @@ -13,12 +13,19 @@ export const generateId = (): string => { /** * Finds matching conversation items based on search value * @param targetValue The search string - * @returns Matching conversations object + * @param conversations The conversations to search through + * @returns Matching conversations array */ -export const findMatchingItems = (targetValue: string): { [key: string]: Conversation[] } => { - // Since we start with empty conversations, return empty object - // In a real implementation, you would filter conversations based on targetValue - return {}; +export const findMatchingItems = (targetValue: string, conversations: Conversation[]): Conversation[] => { + if (!targetValue.trim()) { + return conversations; + } + + return conversations.filter( + (conversation) => + conversation.text?.toLowerCase().includes(targetValue.toLowerCase()) || + conversation.id?.toLowerCase().includes(targetValue.toLowerCase()), + ); }; /**