import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useNotify } from "ra-core"; import { MessageSquare, Plus, Search, Trash2, ChevronRight, Sparkles, User, Bot, Send, } from "lucide-react"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarInset, SidebarInput, } from "@/components/ds/ui/sidebar"; import { Button } from "@/components/ds/ui/button"; import { useSDK } from "../root/SDKProvider"; import { supabase } from "../providers/supabase/supabase"; import { ThreadPrimitive, MessagePrimitive, ComposerPrimitive, useLocalRuntime, AssistantRuntimeProvider, useMessage, type ChatModelAdapter, } from "@assistant-ui/react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import axios from "axios"; import { useAISettings } from "../root/AISettingsProvider"; import { cn } from "@/lib/utils"; import { TTSProvider } from "./TTSContext"; import { TTSButton } from "./TTSButton"; // Context to track new messages for auto-play const NewMessageContext = React.createContext | null>(null); interface Thread { id: string; title: string; created_at: string; updated_at?: string; last_message?: string; } interface ChatMessageDB { id: string; thread_id: string; role: "user" | "assistant"; content: string; created_at: string; } interface ThreadWithMessages { id: string; title: string | null; created_at: string; updated_at: string | null; chat_messages?: ChatMessageDB[]; } /** * Message Component for Assistant UI */ const AssistantMessage = () => { const message = useMessage(); const { role, content } = message; const newMessageContentRef = React.useContext(NewMessageContext); // Extract text content for TTS const textContent = content .filter((part) => part.type === "text") .map((part) => ("text" in part ? part.text : "")) .join("\n"); // Check if this is a new message (for auto-play) const isNewMessage = newMessageContentRef ? textContent === newMessageContentRef.current : false; // Clear the ref after the message is rendered React.useEffect(() => { if (isNewMessage && newMessageContentRef) { newMessageContentRef.current = null; } }, [isNewMessage, newMessageContentRef]); return (
{role === "user" ? ( ) : ( )}
( {text} ), }} />
{role === "user" ? "You" : "Assistant"} {role === "assistant" && textContent && ( )}
); }; const AIPageContent = () => { const { isAvailable } = useSDK(); const { settings } = useAISettings(); const [threads, setThreads] = useState([]); const [activeThreadId, setActiveThreadId] = useState(null); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const notify = useNotify(); const newMessageContentRef = useRef(null); const fetchThreads = useCallback(async () => { try { const { data, error } = await supabase .from("chat_threads") .select( ` id, title, created_at, updated_at, chat_messages ( content, created_at ) `, ) .order("updated_at", { ascending: false }); if (error) throw error; const formattedThreads = (data as ThreadWithMessages[]).map((t) => ({ id: t.id, title: t.title || "Untitled Conversation", created_at: t.created_at, updated_at: t.updated_at, last_message: t.chat_messages?.sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), )[0]?.content, })); setThreads(formattedThreads); setActiveThreadId((prev) => { if (prev && formattedThreads.some((thread) => thread.id === prev)) return prev; return formattedThreads[0]?.id ?? null; }); } catch (error) { console.error("Failed to fetch threads:", error); notify("Failed to fetch AI conversations", { type: "error" }); } finally { setLoading(false); } }, [notify]); const createThread = useCallback(async () => { const { data: { user }, } = await supabase.auth.getUser(); if (!user) return null; const { data, error } = await supabase .from("chat_threads") .insert({ user_id: user.id, title: "New Conversation", }) .select() .single(); if (error || !data) { throw error ?? new Error("Failed to create conversation"); } const newThread: Thread = { id: data.id, title: data.title || "New Conversation", created_at: data.created_at, updated_at: data.updated_at, }; setThreads((prev) => [newThread, ...prev]); setActiveThreadId(data.id); return data.id as string; }, []); useEffect(() => { fetchThreads(); // Listen for refresh events (from AIAssistant) const handleRefresh = () => fetchThreads(); window.addEventListener("realtimex-refresh-threads", handleRefresh); return () => window.removeEventListener("realtimex-refresh-threads", handleRefresh); }, [fetchThreads]); const handleCreateThread = async () => { try { // Clear messages immediately for better UX setInitialMessages([]); setMessagesLoading(true); await createThread(); // Messages will be loaded by the useEffect when activeThreadId changes } catch (error) { console.error("Failed to create thread:", error); notify("Failed to create new conversation", { type: "error" }); setMessagesLoading(false); } }; const handleDeleteThread = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); // Confirmation dialog const thread = threads.find((t) => t.id === id); const confirmMessage = `Delete "${thread?.title || "this conversation"}"? This cannot be undone.`; if (!window.confirm(confirmMessage)) { return; } try { const { error } = await supabase .from("chat_threads") .delete() .eq("id", id); if (error) throw error; setThreads((prev) => { const filtered = prev.filter((thread) => thread.id !== id); setActiveThreadId((currentActive) => { if (currentActive !== id) return currentActive; return filtered[0]?.id ?? null; }); return filtered; }); notify("Conversation deleted", { type: "success" }); } catch (error) { console.error("Failed to delete thread:", error); notify("Failed to delete conversation", { type: "error" }); } }; const filteredThreads = useMemo(() => { return threads.filter( (t) => t.title.toLowerCase().includes(searchQuery.toLowerCase()) || t.last_message?.toLowerCase().includes(searchQuery.toLowerCase()), ); }, [threads, searchQuery]); const [initialMessages, setInitialMessages] = useState< Array<{ id: string; role: "user" | "assistant"; content: Array<{ type: "text"; text: string }>; }> >([]); const [messagesLoading, setMessagesLoading] = useState(false); const activeThreadIdRef = useRef(null); const threadsRef = useRef([]); // Keep refs in sync with state useEffect(() => { activeThreadIdRef.current = activeThreadId; }, [activeThreadId]); useEffect(() => { threadsRef.current = threads; }, [threads]); useEffect(() => { let isCancelled = false; const fetchMessages = async () => { if (!activeThreadId) { setInitialMessages([]); setMessagesLoading(false); return; } setInitialMessages([]); setMessagesLoading(true); try { const { data, error } = await supabase .from("chat_messages") .select("*") .eq("thread_id", activeThreadId) .order("created_at", { ascending: true }); if (error) throw error; if (isCancelled) return; setInitialMessages( (data as ChatMessageDB[]).map((m) => ({ id: m.id, role: m.role, content: [{ type: "text", text: m.content }], })), ); } catch (error) { console.error("Failed to fetch messages:", error); } finally { if (!isCancelled) { setMessagesLoading(false); } } }; fetchMessages(); return () => { isCancelled = true; }; }, [activeThreadId]); const adapter: ChatModelAdapter = useMemo( () => ({ run: async function* ({ messages, abortSignal }) { let effectiveThreadId = activeThreadIdRef.current; if (!effectiveThreadId) { effectiveThreadId = await createThread(); // Ensure threads state is fresh after creation await fetchThreads(); } if (!effectiveThreadId) { throw new Error("Unable to create conversation thread"); } const lastMessage = messages[messages.length - 1]; const userContent = lastMessage.content .map((part) => { if (part.type === "text") return part.text || ""; return ""; }) .join("\n"); // 1. Save user message to DB await supabase.from("chat_messages").insert({ thread_id: effectiveThreadId, role: "user", content: userContent, }); // 2. Get current thread from DB to check title const { data: currentThread } = await supabase .from("chat_threads") .select("title") .eq("id", effectiveThreadId) .single(); let updatedTitle: string | undefined; if ( currentThread && (currentThread.title === "New Conversation" || currentThread.title === "Untitled Conversation") ) { updatedTitle = userContent.substring(0, 40) + (userContent.length > 40 ? "..." : ""); await supabase .from("chat_threads") .update({ title: updatedTitle, updated_at: new Date().toISOString(), }) .eq("id", effectiveThreadId); } else { await supabase .from("chat_threads") .update({ updated_at: new Date().toISOString() }) .eq("id", effectiveThreadId); } try { const response = await axios.post( "/api/sdk/chat", { threadId: effectiveThreadId, messages: messages.map((m) => ({ role: m.role, content: m.content .map((part) => { if (part.type === "text") return part.text || ""; return ""; }) .join("\n"), })), settings: { llm_provider: settings.llm_provider, llm_model: settings.llm_model, }, }, { signal: abortSignal }, ); if (response.data.success) { const content = response.data.content || ""; // 3. Save assistant response to DB if (content) { await supabase.from("chat_messages").insert({ thread_id: effectiveThreadId, role: "assistant", content: content, }); } // 4. Update only the current thread in state (performance optimization) const now = new Date().toISOString(); setThreads((prev) => { const updated = prev.map((t) => t.id === effectiveThreadId ? { ...t, title: updatedTitle || t.title, updated_at: now, last_message: content, } : t, ); // Re-sort by updated_at return updated.sort( (a, b) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime(), ); }); // Mark this as a new message for auto-play newMessageContentRef.current = content; yield { content: [{ type: "text", text: content }], }; } else { throw new Error(response.data.message || "AI Error"); } } catch (error) { console.error("[AIPage] Chat Error:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; yield { content: [{ type: "text", text: `Error: ${errorMessage}` }], }; } }, }), [settings.llm_provider, settings.llm_model, createThread, fetchThreads], ); const runtime = useLocalRuntime(adapter, { initialMessages }); // Reset runtime when switching threads or when messages finish loading const prevThreadIdRef = useRef(null); useEffect(() => { // Only reset if we're not loading and messages have changed if (messagesLoading) return; // If thread changed or messages loaded, reset the runtime if (prevThreadIdRef.current !== activeThreadId) { prevThreadIdRef.current = activeThreadId; runtime.thread.reset(initialMessages); } else if (initialMessages.length > 0) { // Messages loaded for current thread runtime.thread.reset(initialMessages); } }, [activeThreadId, initialMessages, messagesLoading, runtime]); if (!isAvailable) { return (

AI Assistant Offline

To use AI features, please ensure the RealTimeX Desktop app is running and connected.

); } return (
AI History
setSearchQuery(e.target.value)} />
{loading ? ( [1, 2, 3, 4, 5].map((i) => (
)) ) : filteredThreads.length === 0 ? (

No conversations found

) : ( filteredThreads.map((thread) => ( setActiveThreadId(thread.id)} >
{thread.title}
{new Date(thread.created_at).toLocaleDateString( undefined, { month: "short", day: "numeric", }, )} {thread.last_message || "No messages"}
)) )}

{threads.find((t) => t.id === activeThreadId)?.title || "AI Assistant"}

Powered by {settings.llm_provider || "Local LLM"} •{" "} {settings.llm_model}

{messagesLoading ? (

Loading history...

) : ( <>

How can I help you today?

Your private, secure CRM assistant. I can help you analyze data, draft emails, or summarize your day.

{[ "Summarize my recent deals", "Who are my most active contacts?", "Draft a follow-up email for John Doe", ].map((prompt) => ( ))}
)}

AI can make mistakes. Always verify critical information.

); }; export const AIPage = () => { return ( ); };