5ac64b437b
continuous-integration/drone/push Build is passing
- Add animated typing indicator (bouncing dots) when AI is processing - Show "Thinking..." status in chat header while processing - Add translations for AI chat (en/de): title, welcome, placeholder, thinking, error - Auto-refresh tasks, labels, and user data after AI response - Disable input field while AI is processing - Fix translation keys to use ai.chat.* namespace
742 lines
40 KiB
TypeScript
742 lines
40 KiB
TypeScript
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 (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{preprocessMarkdown(displayedContent)}</ReactMarkdown>
|
|
{/* {displayedContent} */}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function AiChatPage() {
|
|
const { t, i18n } = useTranslation();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
// 'new' indicates a temporary draft state
|
|
const [selectedConversationId, setSelectedConversationId] = useState<string | 'new' | null>(null);
|
|
const [input, setInput] = useState("");
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [latestMessageId, setLatestMessageId] = useState<string | null>(null);
|
|
|
|
// Rename state
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editName, setEditName] = useState("");
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
|
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(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<Conversation[]>({
|
|
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<Message[]>({
|
|
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<Message[]>(["/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<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
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 (
|
|
<div className="flex flex-col h-[calc(100vh-4rem)] -m-4 md:-m-6 bg-background relative">
|
|
{/* Header / Top Navigation Bar */}
|
|
<div className="h-16 border-b flex items-center px-4 md:px-6 justify-between shrink-0 bg-background/80 backdrop-blur-sm z-30 sticky top-0 shadow-sm">
|
|
|
|
{/* Left: Branding, History & Current Title */}
|
|
{/* Left: Branding, History & Current Title */}
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
|
<Sparkles className="w-4 h-4 text-violet-500" />
|
|
</div>
|
|
|
|
{/* Logic: If editing current title, show input. Else show Dropdown. */}
|
|
{aiStatus?.configured && (
|
|
editingId === selectedConversationId ? (
|
|
<div className="flex items-center gap-2 flex-1 max-w-[300px]">
|
|
<Input
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={saveName}
|
|
autoFocus
|
|
className="h-9"
|
|
/>
|
|
<Button size="icon" variant="ghost" onClick={() => setEditingId(null)}>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="h-auto py-2 px-3 font-semibold text-lg flex gap-2 items-center hover:bg-muted/50 transition-colors rounded-lg max-w-full">
|
|
<div className="flex flex-col items-start leading-none min-w-0">
|
|
<span className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent text-lg truncate max-w-[200px] md:max-w-[400px]">
|
|
{currentTitle}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground font-normal flex items-center gap-1">
|
|
{t('ai.selectConversation', 'Switch Chat')} <ChevronDown className="w-3 h-3" />
|
|
</span>
|
|
</div>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-[300px] max-h-[500px] overflow-y-auto">
|
|
<DropdownMenuLabel>{t('ai.recentChats', 'Recent Chats')}</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{conversations?.map(conv => (
|
|
<DropdownMenuItem
|
|
key={conv.id}
|
|
onClick={() => setSelectedConversationId(conv.id)}
|
|
className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")}
|
|
>
|
|
{editingId === conv.id ? (
|
|
<div className="flex items-center gap-2 flex-1 onClick-stop">
|
|
<Input
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onBlur={saveName}
|
|
autoFocus
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 overflow-hidden flex-1">
|
|
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
<span className="truncate font-medium">{conv.title}</span>
|
|
</div>
|
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground"
|
|
onClick={(e) => startEditing(conv, e)}
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
|
onClick={(e) => handleDelete(conv.id, e)}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
{(!conversations || conversations.length === 0) && (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
{t('ai.noChats', 'No recent chats')}
|
|
</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */}
|
|
{selectedConversationId && selectedConversationId !== 'new' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:bg-muted"
|
|
onClick={(e) => {
|
|
const conv = conversations?.find(c => c.id === selectedConversationId);
|
|
if (conv) startEditing(conv, e);
|
|
}}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{aiStatus?.configured && (
|
|
<>
|
|
{/* Export Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:bg-muted"
|
|
onClick={() => {
|
|
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')}
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
|
|
<Button onClick={handleCreateNew} size="sm" className="gap-2 shadow-sm rounded-full shrink-0">
|
|
<Plus className="w-4 h-4" />
|
|
<span className="hidden sm:inline">{t('ai.newChat', 'New Chat')}</span>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Chat Area */}
|
|
<div className="flex-1 flex flex-col min-w-0 relative overflow-hidden">
|
|
{!aiStatus?.configured ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
|
|
<div className="w-24 h-24 bg-destructive/10 rounded-full flex items-center justify-center mb-6">
|
|
<Bot className="w-12 h-12 text-destructive" />
|
|
</div>
|
|
<h3 className="text-3xl font-bold mb-3 tracking-tight text-foreground">
|
|
{t('ai.notConfiguredTitle', 'AI Not Configured')}
|
|
</h3>
|
|
<p className="max-w-md mb-8 text-lg opacity-80 leading-relaxed">
|
|
{t('ai.notConfiguredDesc', 'Please configure an AI provider in the admin settings to start using the assistant.')}
|
|
</p>
|
|
</div>
|
|
) : !selectedConversationId ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
|
|
<div className="w-24 h-24 bg-gradient-to-br from-violet-500/10 to-indigo-500/10 rounded-full flex items-center justify-center mb-6 animate-pulse">
|
|
<Sparkles className="w-12 h-12 text-violet-500" />
|
|
</div>
|
|
<h3 className="text-3xl font-bold mb-3 tracking-tight text-foreground">
|
|
{t('ai.welcomeTitle', 'How can I help you?')}
|
|
</h3>
|
|
<p className="max-w-md mb-8 text-lg opacity-80 leading-relaxed">
|
|
{t('ai.welcomeDesc', 'I can assist you with your tasks, planning, and more.')}
|
|
</p>
|
|
<Button size="lg" onClick={handleCreateNew} className="rounded-full px-8 h-12 text-base shadow-lg hover:shadow-xl transition-all">
|
|
{t('ai.startChat', 'Start a New Chat')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Messages: Render if not 'new', or if 'new' show empty */}
|
|
<ScrollArea className="flex-1 px-4 md:px-8 pt-4 md:pt-8 pb-2">
|
|
<div className="space-y-8 max-w-3xl mx-auto pb-4">
|
|
{selectedConversationId !== 'new' && messages?.map((msg) => (
|
|
<div key={msg.id} className={cn("flex gap-4 animate-in slide-in-from-bottom-2 duration-300 group", msg.role === 'user' ? "flex-row-reverse" : "flex-row")}>
|
|
<div className={cn(
|
|
"w-9 h-9 rounded-full flex items-center justify-center shrink-0 shadow-sm transition-transform group-hover:scale-105",
|
|
msg.role === 'user' ? "bg-primary text-primary-foreground" : "bg-gradient-to-br from-violet-500 to-indigo-600 text-white"
|
|
)}>
|
|
{msg.role === 'user' ? <UserIcon className="w-5 h-5" /> : <Sparkles className="w-4 h-4" />}
|
|
</div>
|
|
<div className={cn(
|
|
"flex flex-col gap-1 min-w-0 max-w-[85%]",
|
|
msg.role === 'user' ? "items-end" : "items-start"
|
|
)}>
|
|
<div className={cn(
|
|
"rounded-2xl px-6 py-4 text-sm shadow-sm leading-relaxed relative",
|
|
editingMessageId === msg.id ? "w-full min-w-[300px] border-primary ring-2 ring-primary/20" : "",
|
|
msg.role === 'user'
|
|
? "bg-primary text-primary-foreground rounded-tr-none"
|
|
: "bg-background border shadow-md text-foreground rounded-tl-none"
|
|
)}>
|
|
{editingMessageId === msg.id ? (
|
|
<div className="flex flex-col gap-2 w-full">
|
|
<textarea
|
|
value={editMessageContent}
|
|
onChange={(e) => setEditMessageContent(e.target.value)}
|
|
className="w-full bg-transparent border-0 focus:ring-0 p-0 text-inherit resize-none min-h-[60px]"
|
|
autoFocus
|
|
/>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button size="sm" variant="secondary" onClick={() => setEditingMessageId(null)} className="h-7 text-xs bg-white/20 hover:bg-white/30 text-inherit border-0">
|
|
{t('common.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button size="sm" onClick={handleSaveEditMessage} disabled={editMessageMutation.isPending} className="h-7 text-xs bg-white text-primary hover:bg-white/90">
|
|
{editMessageMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.save', 'Regenerate')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Edit Button for User Messages */}
|
|
{msg.role === 'user' && !editingMessageId && (
|
|
<button
|
|
onClick={() => 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')}
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
|
|
{msg.role === 'assistant' ? (
|
|
msg.id === latestMessageId ? (
|
|
<TypewriterMessage content={msg.content} onComplete={() => scrollToBottom()} />
|
|
) : (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{preprocessMarkdown(msg.content)}</ReactMarkdown>
|
|
{/* {msg.content} */}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="whitespace-pre-wrap">{msg.content}</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{msg.createdAt && (
|
|
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity px-2">
|
|
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true, locale: i18n.language?.startsWith('de') ? de : undefined })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{(sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending) && (
|
|
<div className="flex gap-4 animate-pulse">
|
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shrink-0 opacity-80">
|
|
<Sparkles className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div className="bg-background border rounded-2xl rounded-tl-none px-6 py-4 flex items-center gap-2 shadow-sm">
|
|
<span className="flex gap-1.5">
|
|
<span className="w-2 h-2 rounded-full bg-violet-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.32s]"></span>
|
|
<span className="w-2 h-2 rounded-full bg-indigo-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.16s]"></span>
|
|
<span className="w-2 h-2 rounded-full bg-blue-500 animate-[bounce_1.4s_infinite]"></span>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground font-medium ml-2">{editMessageMutation.isPending ? t('ai.regenerating', 'Regenerating...') : t('ai.thinking', 'Thinking...')}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={scrollRef} className="h-px" />
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Input Area */}
|
|
<div className="pt-2 px-4 md:px-6 md:pt-2 pb-6 bg-background/50 backdrop-blur-sm shrink-0">
|
|
<div className="max-w-3xl mx-auto space-y-3">
|
|
<form
|
|
className="relative flex items-start gap-2 bg-muted/40 border rounded-2xl px-4 py-1.5 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all hover:bg-muted/60"
|
|
onSubmit={handleSend}
|
|
>
|
|
<Sparkles className="w-5 h-5 text-muted-foreground/70 self-start mt-2" />
|
|
<Textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
// Handle Shift+Enter for newline
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
// Let Shift+Enter pass through
|
|
handleInputKeyDown(e);
|
|
}}
|
|
placeholder={t('ai.placeholder', 'Ask me anything about your tasks...')}
|
|
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-3 min-h-[44px] max-h-[200px] text-base placeholder:text-muted-foreground/60 resize-none py-2.5"
|
|
autoFocus
|
|
disabled={sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
disabled={!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
|
|
size="icon"
|
|
className={cn(
|
|
"h-9 w-9 rounded-xl shrink-0 transition-all duration-300 self-end mb-1",
|
|
input.trim()
|
|
? "opacity-100 scale-100 bg-primary text-primary-foreground shadow-md hover:scale-105"
|
|
: "opacity-0 scale-75"
|
|
)}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</Button>
|
|
</form>
|
|
<div className="text-center">
|
|
<span className="text-[11px] text-muted-foreground/60 flex items-center justify-center gap-1.5">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{t('ai.disclaimer', 'AI can make mistakes. Verify important information.')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('ai.deleteConfirmTitle', 'Delete Chat')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('ai.deleteConfirmDesc', 'Are you sure you want to delete this conversation? This cannot be undone.')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
{t('common.delete', 'Delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|