import { useState, useEffect, useRef, useLayoutEffect } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuLabel } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Send, Bot, User as UserIcon, Sparkles, Trash2, MessageSquare, Pencil, X, ChevronDown, Plus, Calendar, CheckCircle2, Clock, AlertCircle, Download, Loader2 } from "lucide-react"; import { apiRequest } from "@/lib/queryClient"; import { cn } from "@/lib/utils"; import { formatDistanceToNow } from "date-fns"; import { de } from "date-fns/locale"; import { useToast } from "@/hooks/use-toast"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; // Types type Conversation = { id: string; title: string; updatedAt: string; }; type Message = { id: string; role: 'user' | 'assistant'; content: string; createdAt: string; }; const preprocessMarkdown = (text: string) => { if (!text) return ""; // 1. Ensure newlines before headers (###), horizontal rules (---), and list items let processed = text .replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2') // Header .replace(/([^\n])\n(\*{3,}|-{3,}|_{3,})/g, '$1\n\n$2') // HR .replace(/([^\n])\n(- |\* |\d+\. )/g, '$1\n\n$2'); // Lists // 2. Ensure code blocks have newlines before/after processed = processed.replace(/([^\n])```/g, '$1\n```'); processed = processed.replace(/```([^\n])/g, '```\n$1'); return processed; }; const TypewriterMessage = ({ content, onComplete }: { content: string, onComplete?: () => void }) => { const [displayedContent, setDisplayedContent] = useState(""); const indexRef = useRef(0); // Optimization: Render faster const SPEED_MS = 1; const CHARS_PER_TICK = 5; useEffect(() => { indexRef.current = 0; setDisplayedContent(""); const interval = setInterval(() => { setDisplayedContent((prev) => { if (indexRef.current >= content.length) { clearInterval(interval); onComplete?.(); return content; } const nextSlice = content.slice(indexRef.current, indexRef.current + CHARS_PER_TICK); indexRef.current += CHARS_PER_TICK; return prev + nextSlice; }); }, SPEED_MS); return () => clearInterval(interval); }, [content]); return (
{preprocessMarkdown(displayedContent)} {/* {displayedContent} */}
); }; export default function AiChatPage() { const { t, i18n } = useTranslation(); const { toast } = useToast(); const queryClient = useQueryClient(); // 'new' indicates a temporary draft state const [selectedConversationId, setSelectedConversationId] = useState(null); const [input, setInput] = useState(""); const scrollRef = useRef(null); const [latestMessageId, setLatestMessageId] = useState(null); // Rename state const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [deleteId, setDeleteId] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [editMessageContent, setEditMessageContent] = useState(""); // Check AI Configuration Status const { data: aiStatus, isLoading: isLoadingStatus } = useQuery<{ configured: boolean }>({ queryKey: ['/api/ai/status'], }); // Fetch Conversations const { data: conversations, isLoading: isLoadingConvs } = useQuery({ queryKey: ["/api/ai/conversations"], enabled: aiStatus?.configured }); // Select latest conversation on load if none selected useEffect(() => { if (conversations && conversations.length > 0 && !selectedConversationId) { setSelectedConversationId(conversations[0].id); } }, [conversations, selectedConversationId]); // Fetch Messages for selected conversation const { data: messages, isLoading: isLoadingMessages } = useQuery({ queryKey: ["/api/ai/conversations", selectedConversationId, "messages"], enabled: !!selectedConversationId && selectedConversationId !== 'new', refetchInterval: (query) => { // Poll if the last message is from the user (waiting for AI response) const lastMsg = query.state.data?.slice(-1)[0]; return lastMsg?.role === 'user' ? 2000 : false; } }); const scrollToBottom = () => { if (scrollRef.current) { scrollRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); } } useLayoutEffect(() => { scrollToBottom(); }, [messages, selectedConversationId, latestMessageId]); // Mutations const createConversationMutation = useMutation({ mutationFn: async () => { const res = await apiRequest("POST", "/api/ai/conversations", { title: t('ai.newChatDefault', 'New Chat') }); if (!res.ok) throw new Error("Failed to create conversation"); return res.json(); }, onSuccess: (newConv) => { queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] }); }, }); // Delete Conversation const deleteConversationMutation = useMutation({ mutationFn: async (id: string) => { const res = await apiRequest("DELETE", `/api/ai/conversations/${id}`); if (!res.ok) throw new Error("Failed to delete conversation"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] }); if (selectedConversationId === deleteId) { setSelectedConversationId(null); } toast({ title: t('common.deleted', 'Deleted') }); }, }); // Rename Conversation const renameConversationMutation = useMutation({ mutationFn: async ({ id, title }: { id: string, title: string }) => { const res = await apiRequest("PATCH", `/api/ai/conversations/${id}`, { title }); if (!res.ok) throw new Error("Failed to rename conversation"); return res.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] }); }, }); const sendMessageMutation = useMutation({ mutationFn: async ({ conversationId, content }: { conversationId: string, content: string }) => { // ALWAYS use the main chat endpoint which is now async-friendly const res = await apiRequest("POST", "/api/ai/chat", { conversationId, content, clientTime: new Date().toLocaleString() }); if (!res.ok) { try { const errData = await res.json(); throw new Error(errData.error || t('ai.sendFailed', 'Failed to send message')); } catch (e) { throw new Error(t('ai.sendFailed', 'Failed to send message')); } } return res.json(); }, onMutate: async ({ conversationId, content }) => { await queryClient.cancelQueries({ queryKey: ["/api/ai/conversations", conversationId, "messages"] }); const previousMessages = queryClient.getQueryData(["/api/ai/conversations", conversationId, "messages"]); // Optimistic update queryClient.setQueryData(["/api/ai/conversations", conversationId, "messages"], (old: Message[] = []) => [ ...old, { id: 'temp-' + Date.now(), role: 'user', content: content, createdAt: new Date().toISOString() } ]); const previousInput = input; setInput(""); return { previousMessages, newContent: previousInput }; }, onError: (err: any, vars, context: any) => { if (context?.previousMessages) { queryClient.setQueryData(["/api/ai/conversations", vars.conversationId, "messages"], context.previousMessages); } if (context?.newContent) { setInput(context.newContent); } toast({ title: t('ai.errorSending', 'Error sending message'), description: err.message, variant: "destructive" }); }, onSuccess: (userMessage, vars, context) => { // Returns USER message now queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", vars.conversationId, "messages"] }); queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] }); // Refresh tasks and labels in case AI created/modified them queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); queryClient.invalidateQueries({ queryKey: ['/api/labels'] }); queryClient.invalidateQueries({ queryKey: ['/api/user'] }); }, }); const editMessageMutation = useMutation({ mutationFn: async ({ id, content }: { id: string, content: string }) => { const res = await apiRequest("PUT", `/api/ai/chat/${id}`, { content, clientTime: new Date().toLocaleString() }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || t('ai.editFailed', 'Failed to edit message')); } return res.json(); }, onSuccess: (botMessage, vars) => { queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", selectedConversationId, "messages"] }); queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); // In case tasks were updated during regen setEditingMessageId(null); setLatestMessageId(botMessage.id); toast({ title: t('ai.messageEdited', 'Message edited & answer regenerated') }); }, onError: (err: any) => { toast({ title: t('ai.errorEditing', 'Error editing message'), description: err.message, variant: "destructive" }); } }); const handleSend = async (e?: React.FormEvent) => { e?.preventDefault(); if (!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending) return; let conversationId = selectedConversationId; // If drafting a new chat, create it now if (conversationId === 'new') { try { const newConv = await createConversationMutation.mutateAsync(); conversationId = newConv.id; setSelectedConversationId(conversationId); } catch (error) { toast({ title: t('ai.error', 'Error'), description: t('ai.createFailed', 'Failed to create new conversation'), variant: "destructive" }); return; } } if (conversationId && conversationId !== 'new') { sendMessageMutation.mutate({ conversationId, content: input }); } }; const handleCreateNew = () => { setSelectedConversationId('new'); setInput(""); // No mutation call here. }; const handleDelete = (id: string, e: React.MouseEvent) => { e.stopPropagation(); setDeleteId(id); }; const confirmDelete = () => { if (deleteId) { deleteConversationMutation.mutate(deleteId); setDeleteId(null); } }; const startEditing = (conv: Conversation, e: React.MouseEvent) => { e.stopPropagation(); setEditingId(conv.id); setEditName(conv.title); }; const saveName = () => { if (!editingId || !editName.trim()) { setEditingId(null); return; } renameConversationMutation.mutate({ id: editingId, title: editName }); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingId(null); e.stopPropagation(); // prevent closing dropdown }; const handleStartEditMessage = (msg: Message) => { setEditingMessageId(msg.id); setEditMessageContent(msg.content); }; const handleSaveEditMessage = () => { if (!editingMessageId || !editMessageContent.trim()) return; editMessageMutation.mutate({ id: editingMessageId, content: editMessageContent }); }; const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp' && !input && !editingMessageId && messages) { e.preventDefault(); // Find last user message const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); if (lastUserMsg) { handleStartEditMessage(lastUserMsg); } } }; const currentTitle = selectedConversationId === 'new' ? t('ai.newChat', 'New Chat') : conversations?.find(c => c.id === selectedConversationId)?.title || t('ai.assistant', 'AI Assistant'); return (
{/* Header / Top Navigation Bar */}
{/* Left: Branding, History & Current Title */} {/* Left: Branding, History & Current Title */}
{/* Logic: If editing current title, show input. Else show Dropdown. */} {aiStatus?.configured && ( editingId === selectedConversationId ? (
setEditName(e.target.value)} onKeyDown={handleKeyDown} onBlur={saveName} autoFocus className="h-9" />
) : (
{t('ai.recentChats', 'Recent Chats')} {conversations?.map(conv => ( setSelectedConversationId(conv.id)} className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")} > {editingId === conv.id ? (
setEditName(e.target.value)} onKeyDown={handleKeyDown} onClick={(e) => e.stopPropagation()} onBlur={saveName} autoFocus className="h-8 text-xs" />
) : ( <>
{conv.title}
)}
))} {(!conversations || conversations.length === 0) && (
{t('ai.noChats', 'No recent chats')}
)}
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */} {selectedConversationId && selectedConversationId !== 'new' && ( )}
) )}
{/* Right: Actions */}
{aiStatus?.configured && ( <> {/* Export Button */} )}
{/* Main Chat Area */}
{!aiStatus?.configured ? (

{t('ai.notConfiguredTitle', 'AI Not Configured')}

{t('ai.notConfiguredDesc', 'Please configure an AI provider in the admin settings to start using the assistant.')}

) : !selectedConversationId ? (

{t('ai.welcomeTitle', 'How can I help you?')}

{t('ai.welcomeDesc', 'I can assist you with your tasks, planning, and more.')}

) : ( <> {/* Messages: Render if not 'new', or if 'new' show empty */}
{selectedConversationId !== 'new' && messages?.map((msg) => (
{msg.role === 'user' ? : }
{editingMessageId === msg.id ? (