import React from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { AIMessageChunk, BaseMessage, HumanMessage } from '@langchain/core/messages'; import { mapStoredMessageToChatMessage } from '@langchain/core/messages'; import { ChatManager } from '@/lib/chat/manager'; import { ChatHistoryDB, DexieChatMemory } from '@/lib/chat/memory'; import { ConfigManager } from '@/lib/config/manager'; import { DocumentManager } from '@/lib/document/manager'; import { IDocument } from '@/lib/document/types'; import { CHAT_MODELS } from '@/lib/config/types'; import { toast } from 'sonner'; import { cn } from "@/lib/utils"; // Components import { AutosizeTextarea } from '@/components/ui/autosize-textarea'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; // Icons import { Send, X, Check, Info, Paperclip, Upload, Link2, Loader2, Download, ClipboardCopy, RefreshCcw, Pencil } from 'lucide-react'; // Markdown import ReactMarkdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import remarkGfm from 'remark-gfm'; import 'katex/dist/katex.min.css'; // Types interface ChatState { input: string; attachments: IDocument[]; isUrlInputOpen: boolean; urlInput: string; previewDocument: IDocument | null; isGenerating: boolean; streamingHumanMessage: HumanMessage | null; streamingAIMessageChunks: AIMessageChunk[]; editingMessageIndex: number | null; } // Query Keys const chatKeys = { all: ['chat'] as const, session: (id: string) => [...chatKeys.all, 'session', id] as const, config: ['config'] as const, }; // Custom Hooks const useChatSession = (id: string | undefined) => { const chatHistoryDB = React.useMemo(() => new ChatHistoryDB(), []); return useQuery({ queryKey: chatKeys.session(id || 'new'), queryFn: async () => { if (!id || id === 'new') return null; return await chatHistoryDB.sessions.get(id); }, staleTime: 1000 * 60, // 1 minute }); }; const useConfig = () => { const configManager = React.useMemo(() => ConfigManager.getInstance(), []); return useQuery({ queryKey: chatKeys.config, queryFn: async () => await configManager.getConfig(), staleTime: 1000 * 60 * 5, // 5 minutes }); }; // Optimized Components const MessageContent = React.memo(({ content }: { content: string }) => ( { navigator.clipboard.writeText(code); toast.success('Code copied to clipboard'); }; return match ? (
{language && ( {language} )}
{code}
) : ( {children} ); }, }} > {content}
)); MessageContent.displayName = 'MessageContent'; const VirtualizedMessages = React.memo(({ messages, streamingMessages, parentRef, onEditMessage, onRegenerateMessage, editingMessageIndex, onSaveEdit, onCancelEdit, }: { messages: BaseMessage[]; streamingMessages: { human: HumanMessage | null; ai: AIMessageChunk[]; }; parentRef: React.MutableRefObject; onEditMessage: (index: number) => void; onRegenerateMessage: (index: number) => void; editingMessageIndex: number | null; onSaveEdit: (content: string) => void; onCancelEdit: () => void; }) => { const rowVirtualizer = useVirtualizer({ count: messages.length + (streamingMessages.human ? 2 : 0), getScrollElement: () => parentRef.current, estimateSize: () => 100, overscan: 5, }); if (!parentRef.current) return null; return (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const index = virtualRow.index; const message = index < messages.length ? messages[index] : index === messages.length && streamingMessages.human ? streamingMessages.human : null; if (!message) return null; const isHuman = message instanceof HumanMessage; const isEditing = editingMessageIndex === index; return (
{isHuman ? ( onEditMessage(index)} onRegenerate={() => onRegenerateMessage(index)} onSave={onSaveEdit} onCancelEdit={onCancelEdit} /> ) : ( )}
); })}
); }); VirtualizedMessages.displayName = 'VirtualizedMessages'; const HumanMessageComponent = React.memo(({ message, isEditing, onEdit, onRegenerate, onSave, onCancelEdit, }: { message: HumanMessage; isEditing?: boolean; onEdit?: () => void; onRegenerate?: () => void; onSave?: (content: string) => void; onCancelEdit?: () => void; }) => { const [editedContent, setEditedContent] = React.useState(String(message.content)); if (message.response_metadata?.documents?.length) { return (
{}} onRemove={() => {}} removeable={false} maxHeight="200px" />
); } return (
{isEditing ? (
setEditedContent(e.target.value)} className="bg-muted p-5 rounded-md" maxHeight={300} />
) : ( <>
{String(message.content)}
)}
); }); HumanMessageComponent.displayName = 'HumanMessageComponent'; const AIMessageComponent = React.memo(({ message }: { message: BaseMessage }) => { const handleCopy = React.useCallback(() => { const content = String(message.content); navigator.clipboard.writeText(content) .then(() => toast.success("Response copied to clipboard")) .catch(() => toast.error("Failed to copy response")); }, [message.content]); return (
); }); AIMessageComponent.displayName = 'AIMessageComponent'; const DocumentBadgesScrollArea = React.memo(({ documents, onPreview, onRemove, removeable = true, maxHeight = "100px", className = "" }: { documents: IDocument[]; onPreview: (doc: IDocument) => void; onRemove: (docId: string) => void; removeable?: boolean; maxHeight?: string; className?: string; }) => (
{documents.map((document) => ( onPreview(document)} onRemove={() => onRemove(document.id)} removeable={removeable} /> ))}
)); DocumentBadgesScrollArea.displayName = 'DocumentBadgesScrollArea'; const DocumentBadge = React.memo(({ document, onPreview, onRemove, removeable = true }: { document: IDocument; onPreview: () => void; onRemove: () => void; removeable?: boolean; }) => ( {document.name} {removeable && ( )} )); DocumentBadge.displayName = 'DocumentBadge'; const FilePreviewDialog = React.memo(({ document: fileDoc, onClose }: { document: IDocument | null; onClose: () => void; }) => { const [content, setContent] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [error, setError] = React.useState(null); const documentManager = React.useMemo(() => DocumentManager.getInstance(), []); React.useEffect(() => { if (!fileDoc) return; const loadContent = async () => { try { setIsLoading(true); setError(null); const file = await documentManager.getDocument(fileDoc.id); if (fileDoc.type === "image") { const reader = new FileReader(); reader.onload = () => setContent(reader.result as string); reader.readAsDataURL(file); } else if (fileDoc.type === "pdf") { const url = URL.createObjectURL(file); setContent(url); return () => URL.revokeObjectURL(url); } else { const text = await file.text(); setContent(text); } } catch (err) { console.error(err); setError("Failed to load file content"); } finally { setIsLoading(false); } }; loadContent(); }, [fileDoc, documentManager]); const handleDownload = React.useCallback(async () => { if (!fileDoc) return; try { const file = await documentManager.getDocument(fileDoc.id); const url = URL.createObjectURL(file); const a = document.createElement('a'); a.href = url; a.download = fileDoc.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error(err); toast.error("Failed to download file"); } }, [fileDoc, documentManager]); if (!fileDoc) return null; return (
{fileDoc.name} Type: {fileDoc.type} • Created: {new Date(fileDoc.createdAt).toLocaleString()}
{isLoading ? (
) : error ? (
{error}
) : content ? ( fileDoc.type === "image" ? (
{fileDoc.name}
) : fileDoc.type === "pdf" ? (