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"
/>
setEditingId(null)}>
) : (
{currentTitle}
{t('ai.selectConversation', 'Switch Chat')}
{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}
startEditing(conv, e)}
>
handleDelete(conv.id, e)}
>
>
)}
))}
{(!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' && (
{
const conv = conversations?.find(c => c.id === selectedConversationId);
if (conv) startEditing(conv, e);
}}
>
)}
)
)}
{/* Right: Actions */}
{aiStatus?.configured && (
<>
{/* Export Button */}
{
if (!conversations || conversations.length === 0) return;
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify({
exportDate: new Date().toISOString(),
conversations: conversations,
activeMessages: messages
}, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", `ai_chat_export_${new Date().toISOString()}.json`);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}}
title={t('ai.export', 'Export Chat')}
>
{t('ai.newChat', 'New Chat')}
>
)}
{/* 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.')}
{t('ai.startChat', 'Start a New Chat')}
) : (
<>
{/* Messages: Render if not 'new', or if 'new' show empty */}
{selectedConversationId !== 'new' && messages?.map((msg) => (
{msg.role === 'user' ? : }
{editingMessageId === msg.id ? (
) : (
<>
{/* Edit Button for User Messages */}
{msg.role === 'user' && !editingMessageId && (
handleStartEditMessage(msg)}
className="absolute -left-8 top-1/2 -translate-y-1/2 p-1.5 rounded-full bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted hover:text-foreground"
title={t('common.edit', 'Edit')}
>
)}
{msg.role === 'assistant' ? (
msg.id === latestMessageId ? (
scrollToBottom()} />
) : (
{preprocessMarkdown(msg.content)}
{/* {msg.content} */}
)
) : (
{msg.content}
)}
>
)}
{msg.createdAt && (
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true, locale: i18n.language?.startsWith('de') ? de : undefined })}
)}
))}
{(sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending) && (
{editMessageMutation.isPending ? t('ai.regenerating', 'Regenerating...') : t('ai.thinking', 'Thinking...')}
)}
{/* Input Area */}
{t('ai.disclaimer', 'AI can make mistakes. Verify important information.')}
>
)}
{/* Delete Confirmation Dialog */}
!open && setDeleteId(null)}>
{t('ai.deleteConfirmTitle', 'Delete Chat')}
{t('ai.deleteConfirmDesc', 'Are you sure you want to delete this conversation? This cannot be undone.')}
{t('common.cancel', 'Cancel')}
{t('common.delete', 'Delete')}
);
}