+ {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()),
+ );
};
/**