web / frontend /src /components /chat /ChatInterface.tsx
Chandima Prabhath
feat: Add chat components and modals for enhanced user interaction
1904e4c
raw
history blame
21.7 kB
import { useState, useRef, useEffect } from "react";
import { Send, Plus, PanelLeft, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { apiService, Message as APIMessage } from "@/services/apiService";
import { toast } from "@/components/ui/sonner";
import { ChatBubble } from "@/components/chat/ChatBubble";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { storage, STORAGE_KEYS } from "@/lib/storage";
import { Message, Chat } from "@/types/chat";
import { ProfileModal } from "../modals/ProfileModal";
import { ChatSidebar } from "./ChatSidebar";
import { ChatInputArea } from "./ChatInputArea";
import { WelcomeScreen } from "./WelcomeScreen";
import { DeleteChatDialog } from "./DeleteChatDialog";
interface ChatInterfaceProps {
onOpenSettings: () => void;
onOpenSources: () => void;
}
const WELCOME_MESSAGE = "Hello! I'm Insight AI, How can I help you today?";
const generateId = () => Math.random().toString(36).substring(2, 11);
export const ChatInterface = ({ onOpenSettings, onOpenSources }: ChatInterfaceProps) => {
const [chats, setChats] = useState<Chat[]>(() => {
const savedChats = storage.get<Chat[]>(STORAGE_KEYS.CHATS);
if (savedChats) {
try {
return savedChats.map((chat: any) => ({
...chat,
messages: chat.messages.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp),
sender: msg.sender as "user" | "system"
})),
createdAt: new Date(chat.createdAt),
updatedAt: new Date(chat.updatedAt)
}));
} catch (error) {
console.error("Failed to parse saved chats:", error);
return [];
}
}
return [];
});
const [activeChat, setActiveChat] = useState<Chat | null>(() => {
if (chats.length > 0) {
return chats[0];
}
});
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [chatToDelete, setChatToDelete] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Save chats to storage whenever they change
if (activeChat && !chats.find(chat => chat.id === activeChat.id)) {
setChats([activeChat, ...chats]);
}
const allChats = activeChat
? [
activeChat,
...chats.filter(chat => chat.id !== activeChat.id)
]
: chats;
storage.set(STORAGE_KEYS.CHATS, allChats);
}, [chats, activeChat]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [activeChat?.messages]);
useEffect(() => {
// Focus input when component mounts or when loading ends
if (!isLoading) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isLoading]);
// Handle new chat and chat selection events
useEffect(() => {
const handleNewChat = () => createNewChat();
const handleSelectChat = (e: Event) => {
const customEvent = e as CustomEvent;
const chatId = customEvent.detail?.chatId;
if (chatId) {
selectChat(chatId);
}
};
const handleDeleteChat = (e: Event) => {
const customEvent = e as CustomEvent;
const chatId = customEvent.detail?.chatId;
if (chatId) {
setChatToDelete(chatId);
}
};
document.addEventListener("insight:new-chat", handleNewChat);
document.addEventListener("insight:select-chat", handleSelectChat);
document.addEventListener("insight:delete-chat", handleDeleteChat);
return () => {
document.removeEventListener("insight:new-chat", handleNewChat);
document.removeEventListener("insight:select-chat", handleSelectChat);
document.removeEventListener("insight:delete-chat", handleDeleteChat);
};
}, [chats, activeChat]);
const generateChatTitle = async (query: string) => {
if (!activeChat || activeChat.title !== "New Chat") return;
setIsGeneratingTitle(true);
try {
const response = await apiService.generateTitle(query);
if (response.title) {
// Update the active chat with the new title
setActiveChat(prevChat => {
if (!prevChat) return null;
const updatedChat = { ...prevChat, title: response.title };
// Update chats list
setChats(prevChats =>
prevChats.map(chat =>
chat.id === updatedChat.id ? updatedChat : chat
)
);
return updatedChat;
});
}
} catch (error) {
console.error("Error generating chat title:", error);
// Fallback to using query as title
if (activeChat.title === "New Chat") {
setActiveChat(prevChat => {
if (!prevChat) return null;
const updatedChat = {
...prevChat,
title: query.slice(0, 30) + (query.length > 30 ? '...' : '')
};
setChats(prevChats =>
prevChats.map(chat =>
chat.id === updatedChat.id ? updatedChat : chat
)
);
return updatedChat;
});
}
} finally {
setIsGeneratingTitle(false);
}
};
const handleSendMessage = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!inputValue.trim() || !activeChat) return;
const userMessage: Message = {
id: generateId(),
content: inputValue,
sender: "user",
timestamp: new Date()
};
const loadingMessage: Message = {
id: generateId(),
content: "",
sender: "system",
timestamp: new Date(),
isLoading: true
};
// Update active chat with new messages
const updatedChat = {
...activeChat,
messages: [...activeChat.messages, userMessage, loadingMessage],
updatedAt: new Date()
};
setActiveChat(updatedChat);
setInputValue("");
setIsLoading(true);
try {
// Prepare chat history for the API
const chatHistory: APIMessage[] = updatedChat.messages
.filter(msg => !msg.isLoading && msg.content) // filter out loading messages
.slice(0, -1) // exclude the loading message we just added
.map(msg => ({
role: msg.sender === "user" ? "user" : "assistant",
content: msg.content
}));
const response = await apiService.queryRulings({
query: userMessage.content,
chat_history: chatHistory
});
// Replace loading message with actual response
const updatedMessages = updatedChat.messages.map(msg =>
msg.id === loadingMessage.id
? {
...msg,
content: response.answer,
isLoading: false,
result: response.retrieved_sources
}
: msg
);
const finalChat = {
...updatedChat,
messages: updatedMessages,
};
setActiveChat(finalChat);
// Update chats list
setChats(prevChats =>
prevChats.map(chat =>
chat.id === finalChat.id ? finalChat : chat
)
);
// Generate title if this is a new chat
if (updatedChat.title === "New Chat" && updatedChat.messages.length <= 3) {
generateChatTitle(userMessage.content);
}
} catch (error) {
console.error("Error querying AI:", error);
// Replace loading message with error
const updatedMessages = updatedChat.messages.map(msg =>
msg.id === loadingMessage.id
? {
...msg,
content: "I'm sorry, I couldn't process your request. Please try again.",
isLoading: false,
error: true
}
: msg
);
setActiveChat({
...updatedChat,
messages: updatedMessages
});
toast.error("Failed to process your request");
} finally {
setIsLoading(false);
// Refocus the input after sending message
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
};
const createNewChat = () => {
const newChat: Chat = {
id: generateId(),
title: "New Chat",
messages: [
{
id: "welcomems",
content: WELCOME_MESSAGE,
sender: "system" as const,
timestamp: new Date()
}
],
createdAt: new Date(),
updatedAt: new Date()
};
setActiveChat(newChat);
setChats(prev => [newChat, ...prev]);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
setIsSidebarOpen(false);
};
const selectChat = (chatId: string) => {
const selectedChat = chats.find(chat => chat.id === chatId);
if (selectedChat) {
setActiveChat(selectedChat);
setIsSidebarOpen(false);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
};
const deleteChat = (chatId: string) => {
const updatedChats = chats.filter(chat => chat.id !== chatId);
setChats(updatedChats);
// If we're deleting the active chat, switch to another one
if (activeChat?.id === chatId) {
setActiveChat(updatedChats.length > 0 ? updatedChats[0] : null);
// If no chats left, create a new one
if (updatedChats.length === 0) {
createNewChat();
}
}
// Clear the chat being deleted
setChatToDelete(null);
};
const handleDeleteMessage = (messageId: string) => {
if (!activeChat) return;
// Find the index of the message to delete
const messageIndex = activeChat.messages.findIndex(msg => msg.id === messageId);
if (messageIndex === -1) return;
// Determine if we need to delete a pair (user message + assistant response)
const isUserMessage = activeChat.messages[messageIndex].sender === "user";
const updatedMessages = [...activeChat.messages];
if (isUserMessage && messageIndex + 1 < updatedMessages.length &&
updatedMessages[messageIndex + 1].sender === "system") {
// Remove both the user message and the following assistant response
updatedMessages.splice(messageIndex, 2);
} else if (!isUserMessage && messageIndex > 0 &&
updatedMessages[messageIndex - 1].sender === "user") {
// Remove both the assistant message and the preceding user message
updatedMessages.splice(messageIndex - 1, 2);
} else {
// Just remove the single message
updatedMessages.splice(messageIndex, 1);
}
const updatedChat = {
...activeChat,
messages: updatedMessages,
updatedAt: new Date()
};
setActiveChat(updatedChat);
// Update chats list
setChats(prevChats =>
prevChats.map(chat =>
chat.id === updatedChat.id ? updatedChat : chat
)
);
};
const handleRegenerateMessage = (messageId: string) => {
if (!activeChat) return;
// Find the system message that needs to be regenerated
const messageIndex = activeChat.messages.findIndex(
msg => msg.id === messageId && msg.sender === "system"
);
if (messageIndex < 0) return;
const message = activeChat.messages[messageIndex];
// Find the last user message before this system message
let userMessageContent = "";
let userMessageIndex = -1;
for (let i = messageIndex - 1; i >= 0; i--) {
if (activeChat.messages[i].sender === "user") {
userMessageContent = activeChat.messages[i].content;
userMessageIndex = i;
break;
}
}
if (!userMessageContent) return;
// Create a new variation
const variationId = generateId();
const now = new Date();
// Prepare the updated message with a loading variation
const variations = message.variations || [];
const existingVariations = variations.map(v => ({ ...v }));
// Create a new variations array with the loading state
const updatedVariations = [
...existingVariations,
{ id: variationId, content: "", timestamp: now }
];
// Update the message with loading state
const updatedMessages = [...activeChat.messages];
updatedMessages[messageIndex] = {
...updatedMessages[messageIndex],
isLoading: true,
variations: updatedVariations,
activeVariation: variationId
};
const updatedChat = {
...activeChat,
messages: updatedMessages
};
setActiveChat(updatedChat);
setIsLoading(true);
// Prepare chat history for the API
// Include only the messages up to the user message that triggered the original response
const chatHistory: APIMessage[] = activeChat.messages
.slice(0, userMessageIndex)
.filter(msg => !msg.isLoading && msg.content)
.map(msg => ({
role: msg.sender === "user" ? "user" : "assistant",
content: msg.content
}));
// Send the API request
apiService.queryRulings({
query: userMessageContent,
chat_history: chatHistory
})
.then(response => {
// Update the variation with the actual response
const finalVariations = updatedMessages[messageIndex].variations!.map(v =>
v.id === variationId
? { ...v, content: response.answer, timestamp: new Date() }
: v
);
const finalMessages = [...updatedMessages];
finalMessages[messageIndex] = {
...finalMessages[messageIndex],
variations: finalVariations,
isLoading: false,
error: false,
result: response.retrieved_sources,
activeVariation: variationId
};
const finalChat = {
...updatedChat,
messages: finalMessages
};
setActiveChat(finalChat);
setChats(prevChats =>
prevChats.map(chat =>
chat.id === finalChat.id ? finalChat : chat
)
);
})
.catch(error => {
console.error("Error regenerating response:", error);
// Remove the failed variation
const finalVariations = updatedMessages[messageIndex].variations!.filter(v =>
v.id !== variationId
);
const finalMessages = [...updatedMessages];
finalMessages[messageIndex] = {
...finalMessages[messageIndex],
variations: finalVariations,
isLoading: false,
activeVariation: finalVariations.length > 0 ? finalVariations[0].id : undefined
};
setActiveChat({
...updatedChat,
messages: finalMessages
});
toast.error("Failed to generate variation");
})
.finally(() => {
setIsLoading(false);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
});
};
const handleSelectVariation = (messageId: string, variationId: string) => {
if (!activeChat) return;
// Find the message
const messageIndex = activeChat.messages.findIndex(msg => msg.id === messageId);
if (messageIndex < 0) return;
// Set the active variation
const updatedMessages = [...activeChat.messages];
updatedMessages[messageIndex] = {
...updatedMessages[messageIndex],
activeVariation: variationId
};
const updatedChat = {
...activeChat,
messages: updatedMessages
};
setActiveChat(updatedChat);
// Update chats list
setChats(prevChats =>
prevChats.map(chat =>
chat.id === updatedChat.id ? updatedChat : chat
)
);
};
const handleRetryMessage = (messageId: string) => {
if (!activeChat) return;
// Find the failed message
const failedMessageIndex = activeChat.messages.findIndex(
msg => msg.id === messageId && msg.error
);
if (failedMessageIndex < 0) return;
// Get the last user message before this failed message
let userMessageContent = "";
for (let i = failedMessageIndex - 1; i >= 0; i--) {
if (activeChat.messages[i].sender === "user") {
userMessageContent = activeChat.messages[i].content;
break;
}
}
if (!userMessageContent) return;
// Remove the failed message
const updatedMessages = [...activeChat.messages];
updatedMessages[failedMessageIndex] = {
...updatedMessages[failedMessageIndex],
isLoading: true,
error: false,
content: ""
};
const updatedChat = {
...activeChat,
messages: updatedMessages
};
setActiveChat(updatedChat);
setIsLoading(true);
// Prepare chat history for the API
const chatHistory: APIMessage[] = updatedChat.messages
.filter(msg => !msg.isLoading && msg.content && updatedChat.messages.indexOf(msg) < failedMessageIndex - 1)
.map(msg => ({
role: msg.sender === "user" ? "user" : "assistant",
content: msg.content
}));
// Retry the query
apiService.queryRulings({
query: userMessageContent,
chat_history: chatHistory
})
.then(response => {
const finalMessages = [...updatedMessages];
finalMessages[failedMessageIndex] = {
...finalMessages[failedMessageIndex],
content: response.answer,
isLoading: false,
error: false,
result: response.retrieved_sources
};
const finalChat = {
...updatedChat,
messages: finalMessages
};
setActiveChat(finalChat);
setChats(prevChats =>
prevChats.map(chat =>
chat.id === finalChat.id ? finalChat : chat
)
);
})
.catch(error => {
console.error("Error retrying query:", error);
const finalMessages = [...updatedMessages];
finalMessages[failedMessageIndex] = {
...finalMessages[failedMessageIndex],
content: "I'm sorry, I couldn't process your request. Please try again.",
isLoading: false,
error: true
};
setActiveChat({
...updatedChat,
messages: finalMessages
});
toast.error("Failed to process your request");
})
.finally(() => {
setIsLoading(false);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
});
};
const openProfileModal = () => {
setIsProfileModalOpen(true);
};
return (
<>
<div className="flex h-full w-full">
{/* Chat Sidebar */}
<ChatSidebar
chats={chats}
activeChat={activeChat}
isGeneratingTitle={isGeneratingTitle}
createNewChat={createNewChat}
selectChat={selectChat}
onRequestDelete={setChatToDelete}
onOpenSettings={onOpenSettings}
onOpenSources={onOpenSources}
openProfileModal={openProfileModal}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
/>
{/* Chat Main Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile Header */}
<div className="md:hidden p-2 flex items-center border-b">
<Button variant="ghost" size="icon" onClick={() => setIsSidebarOpen(true)}>
<PanelLeft className="h-5 w-5" />
</Button>
<div className="mx-auto font-medium flex items-center">
<Bot className="h-4 w-4 mr-1.5" />
{activeChat?.title || "New Chat"}
</div>
<Button variant="ghost" size="icon" onClick={createNewChat}>
<Plus className="h-5 w-5" />
</Button>
</div>
{/* Chat Area */}
<div className="relative flex-1 overflow-hidden">
{!activeChat ? (
<WelcomeScreen onCreateNewChat={createNewChat} />
) : (
<>
<div className="h-full overflow-y-auto px-4 pb-32 pt-4">
<div className="max-w-3xl mx-auto space-y-4">
{activeChat.messages.map((message) => (
<ChatBubble
key={message.id}
message={message}
onViewSearchResults={onOpenSources}
onRetry={handleRetryMessage}
onRegenerate={handleRegenerateMessage}
onDelete={handleDeleteMessage}
onSelectVariation={handleSelectVariation}
/>
))}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input Area */}
<ChatInputArea
inputRef={inputRef}
inputValue={inputValue}
setInputValue={setInputValue}
handleSendMessage={handleSendMessage}
isLoading={isLoading}
/>
</>
)}
</div>
</div>
</div>
{/* Profile Modal */}
<ProfileModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} />
{/* Delete Chat Confirmation Dialog */}
<DeleteChatDialog
isOpen={chatToDelete !== null}
onOpenChange={() => setChatToDelete(null)}
onDelete={() => chatToDelete && deleteChat(chatToDelete)}
/>
</>
);
};