import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { ThreadPrimitive, MessagePrimitive, ComposerPrimitive, useLocalRuntime, AssistantRuntimeProvider, useMessage, type ChatModelAdapter, } from "@assistant-ui/react"; import { Sparkles, X, Send, User, Bot, Loader2 } from "lucide-react"; import { Button } from "@/components/ds/ui/button"; import { useLocation } from "react-router"; import { useSDK } from "../root/SDKProvider"; import axios from "axios"; import { useAISettings } from "../root/AISettingsProvider"; import { supabase } from "../providers/supabase/supabase"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; /** * Markdown Renderer for AI messages */ const MarkdownText = () => { return ( ( {text} ), }} /> ); }; /** * Custom Thread Component * Since @assistant-ui/react is headless, we build a simple, clean UI here. */ const CustomThread = ({ ready }: { ready: boolean }) => { return (

AI Assistant

I can help you analyze contacts, summarize notes, and manage your CRM data.

{ const { role } = useMessage(); return (
{role === "user" ? ( ) : ( )}
{role === "assistant" ? ( ) : ( )}
); }, }} />
); }; export const AIAssistant = () => { const { isAvailable } = useSDK(); const location = useLocation(); const { settings } = useAISettings(); const [isOpen, setIsOpen] = useState(false); // Manage persistent thread ID const [threadId, setThreadId] = useState(null); const [threadInitializing, setThreadInitializing] = useState(false); const threadPromiseRef = useRef | null>(null); const ensureThreadId = useCallback(async (): Promise => { if (threadId) return threadId; if (threadPromiseRef.current) return threadPromiseRef.current; threadPromiseRef.current = (async () => { setThreadInitializing(true); try { const { data: { user }, } = await supabase.auth.getUser(); if (!user) return null; const storageKey = `realtimex_crm_thread_id_${user.id}`; const storedThreadId = localStorage.getItem(storageKey); if (storedThreadId) { const { data, error } = await supabase .from("chat_threads") .select("id") .eq("id", storedThreadId) .maybeSingle(); if (data && !error) { setThreadId(data.id); return data.id; } localStorage.removeItem(storageKey); } const { data, error } = await supabase .from("chat_threads") .insert({ user_id: user.id, title: "New Conversation", }) .select() .single(); if (error || !data) { console.error("[AIAssistant] Failed to create thread:", error); return null; } setThreadId(data.id); localStorage.setItem(storageKey, data.id); return data.id as string; } finally { setThreadInitializing(false); threadPromiseRef.current = null; } })(); return threadPromiseRef.current; }, [threadId]); // Initialize or load thread useEffect(() => { if (isOpen && isAvailable) { ensureThreadId().catch((error) => { console.error("[AIAssistant] Failed to initialize thread:", error); }); } }, [isOpen, isAvailable, ensureThreadId]); const adapter: ChatModelAdapter = useMemo( () => ({ run: async function* ({ messages, abortSignal }) { const activeThreadId = (await ensureThreadId()) ?? threadId; if (!activeThreadId) { throw new Error("Could not initialize 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 const { error: userMessageError } = await supabase .from("chat_messages") .insert({ thread_id: activeThreadId, role: "user", content: userContent, }); if (userMessageError) { console.error( "[AIAssistant] Failed to save user message:", userMessageError, ); } // Update thread metadata if (messages.length === 1) { const title = userContent.substring(0, 40) + (userContent.length > 40 ? "..." : ""); await supabase .from("chat_threads") .update({ title, updated_at: new Date().toISOString() }) .eq("id", activeThreadId); } else { await supabase .from("chat_threads") .update({ updated_at: new Date().toISOString() }) .eq("id", activeThreadId); } try { const response = await axios.post( "/api/sdk/chat", { threadId: activeThreadId, messages: messages.map((m) => ({ role: m.role, content: m.content .map((part) => { if (part.type === "text") return part.text || ""; if (part.type === "ui") return "[UI Content]"; return ""; }) .join("\n"), })), settings: { llm_provider: settings.llm_provider, llm_model: settings.llm_model, }, }, { signal: abortSignal }, ); if (response.data.success) { let content = response.data.content; if (content === null || content === undefined) { content = ""; } else if (typeof content !== "string") { content = JSON.stringify(content); } // 2. Save assistant response to DB if (content) { const { error: assistantMessageError } = await supabase .from("chat_messages") .insert({ thread_id: activeThreadId, role: "assistant", content: content, }); if (assistantMessageError) { console.error( "[AIAssistant] Failed to save assistant message:", assistantMessageError, ); } } // Handle TTS Auto-play (only if provider is configured) if ( settings.tts_auto_play && settings.tts_provider && settings.tts_voice && content ) { axios .post( "/api/sdk/tts", { text: content, settings: { provider: settings.tts_provider, voice: settings.tts_voice, speed: settings.tts_speed, }, }, { responseType: "arraybuffer" }, ) .then((res) => { const blob = new Blob([res.data], { type: "audio/mpeg" }); const url = URL.createObjectURL(blob); new Audio(url).play(); }) .catch((err) => { if (err.response?.status !== 500) { console.error( "TTS Auto-play failed:", err.response?.data?.message || err.message, ); } }); } yield { content: [{ type: "text", text: content }], }; } else { throw new Error(response.data.message || "AI Error"); } } catch (error: any) { console.error("[AIAssistant] Chat Error:", error); yield { content: [{ type: "text", text: `Error: ${error.message}` }], }; } }, }), [settings, threadId, ensureThreadId], ); const runtime = useLocalRuntime(adapter); const isAIPage = location.pathname.startsWith("/ai"); if (!isAvailable || isAIPage) return null; return (
{!isOpen ? ( ) : (
RealTimeX CRM AI {threadInitializing && ( )}
)}
); };