import { useCallback, useEffect, useState, useRef } from 'react'; import type { WsClient } from '../lib/ws-client'; import { authFetch } from '../lib/auth'; export interface StoredAttachment { type: string; name: string; mediaType: string; filePath: string; } export interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; timestamp: string; // ISO string hasAttachments?: boolean; audioData?: string; // data URL or HTTP URL for voice messages attachments?: StoredAttachment[]; transcribing?: boolean; // true while audio is being transcribed } export interface ToolActivity { name: string; status: 'running' | 'done'; } export interface Attachment { id: string; type: 'image' | 'file'; name: string; preview: string; // base64 data URL } export function useChat(ws: WsClient | null) { const [messages, setMessages] = useState([]); const [conversationId, setConversationId] = useState(null); const [streaming, setStreaming] = useState(false); const [streamBuffer, setStreamBuffer] = useState(''); const [tools, setTools] = useState([]); const loaded = useRef(false); /** Ref to current conversationId (avoids stale closures in the once-registered WS callbacks) */ const conversationIdRef = useRef(null); /** Ref to current streamBuffer (avoids stale closures in callbacks) */ const streamBufferRef = useRef(''); // Keep conversationIdRef in sync with state useEffect(() => { conversationIdRef.current = conversationId; }, [conversationId]); // Load current conversation from DB on mount useEffect(() => { if (loaded.current) return; loaded.current = true; authFetch('/api/context/current') .then((r) => r.json()) .then((data) => { if (data.conversationId) { setConversationId(data.conversationId); } }) .catch(() => {}); }, []); // Load messages when conversationId is set useEffect(() => { if (!conversationId) return; authFetch(`/api/conversations/${conversationId}`) .then((r) => { if (!r.ok) throw new Error('not found'); return r.json(); }) .then((data) => { if (data.messages?.length) { setMessages( data.messages .filter((m: any) => m.role === 'user' || m.role === 'assistant') .map((m: any) => { // Backward compat for audio_data: file path → URL, data: prefix → legacy, else → prepend data URL let audioData: string | undefined; if (m.audio_data) { if (m.audio_data.startsWith('data:')) { audioData = m.audio_data; // legacy data URL } else if (m.audio_data.includes('/')) { audioData = `/api/files/${m.audio_data}`; // file path → HTTP URL } else { audioData = `data:audio/webm;base64,${m.audio_data}`; // raw base64 } } // Parse stored attachments let attachments: StoredAttachment[] | undefined; if (m.attachments) { try { attachments = JSON.parse(m.attachments); } catch { /* ignore malformed */ } } return { id: m.id, role: m.role, content: m.content, timestamp: m.created_at, audioData, hasAttachments: !!(attachments && attachments.length > 0), attachments, }; }), ); } }) .catch(() => { // Conversation gone — clear setConversationId(null); authFetch('/api/context/clear', { method: 'POST' }).catch(() => {}); }); }, [conversationId]); // Persist conversationId to DB when it changes const prevConvId = useRef(null); useEffect(() => { if (conversationId && conversationId !== prevConvId.current) { prevConvId.current = conversationId; authFetch('/api/context/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId }), }).catch(() => {}); } }, [conversationId]); useEffect(() => { if (!ws) return; const unsubs = [ ws.on('bot:typing', () => { setStreaming(true); setTools([]); }), ws.on('bot:token', (data: { token: string }) => { // Auto-detect new turn — tokens arriving means agent is working setStreaming(true); setStreamBuffer((buf) => { const next = buf + data.token; streamBufferRef.current = next; return next; }); }), ws.on('bot:tool', (data: { name: string; input?: any; status?: string }) => { setTools((prev) => { const existing = prev.find((t) => t.name === data.name && t.status === 'running'); if (existing) return prev; return [...prev, { name: data.name, status: 'running' }]; }); }), ws.on('bot:response', (data: { conversationId: string; messageId?: string; content: string }) => { setConversationId(data.conversationId); // Always add as a new bubble setMessages((msgs) => [ ...msgs, { id: data.messageId || Date.now().toString(), role: 'assistant', content: data.content, timestamp: new Date().toISOString(), }, ]); setStreamBuffer(''); streamBufferRef.current = ''; setTools([]); // Don't clear streaming here — wait for bot:idle from the server }), ws.on('bot:idle', () => { // Server confirmed agent is idle — safe to stop streaming setStreaming(false); }), ws.on('bot:error', (data: { error: string }) => { setStreamBuffer(''); streamBufferRef.current = ''; setStreaming(false); setTools([]); setMessages((msgs) => [ ...msgs, { id: Date.now().toString(), role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date().toISOString(), }, ]); }), // Cross-device / channel sync: append a message broadcast by the server. // Covers channel-inbound (WhatsApp/Telegram), peer-client, scheduler, and // workspace messages — none of which produce an optimistic bubble here, and // the server skips the sender, so a plain append is correct (no de-dup needed). ws.on('chat:sync', (data: { conversationId: string; message: { role: string; content: string; timestamp?: string; attachments?: StoredAttachment[]; audio_data?: string } }) => { if (data.conversationId !== conversationIdRef.current) return; // Resolve audioData the same way the DB loader does (usually absent on sync) let audioData: string | undefined; const raw = data.message.audio_data; if (raw) { if (raw.startsWith('data:')) { audioData = raw; } else if (raw.includes('/')) { audioData = `/api/files/${raw}`; } else { audioData = `data:audio/webm;base64,${raw}`; } } setMessages((msgs) => [ ...msgs, { id: Date.now().toString(), role: data.message.role as 'user' | 'assistant', content: data.message.content, timestamp: data.message.timestamp || new Date().toISOString(), attachments: data.message.attachments, hasAttachments: !!(data.message.attachments?.length), audioData, }, ]); }), // Server created a new conversation (first message of a fresh context) ws.on('chat:conversation-created', (data: { conversationId: string }) => { setConversationId(data.conversationId); }), // Context cleared from any client ws.on('chat:cleared', () => { setMessages([]); setConversationId(null); setStreamBuffer(''); streamBufferRef.current = ''; setStreaming(false); setTools([]); loaded.current = false; }), ]; return () => unsubs.forEach((u) => u()); }, [ws]); const sendMessage = useCallback( (content: string, attachments?: Attachment[], audioData?: string) => { if (!ws || (!content.trim() && (!attachments || attachments.length === 0))) return; // Build optimistic stored attachments from client previews const optimisticAttachments: StoredAttachment[] | undefined = attachments?.map((att) => { const match = att.preview.match(/^data:([^;]+);/); return { type: att.type, name: att.name, mediaType: match?.[1] || 'application/octet-stream', filePath: att.preview }; }); const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', content, timestamp: new Date().toISOString(), hasAttachments: !!(attachments && attachments.length > 0), audioData: audioData ? (audioData.startsWith('data:') ? audioData : `data:audio/webm;base64,${audioData}`) : undefined, attachments: optimisticAttachments, }; // If bot is currently streaming, commit the partial response as a completed message // so the user's new message appears BELOW the bot's in-progress text (chronological order) const partialContent = streamBufferRef.current; if (partialContent) { setMessages((msgs) => [ ...msgs, { id: 'partial-' + Date.now(), role: 'assistant' as const, content: partialContent, timestamp: new Date().toISOString(), }, userMsg, ]); setStreamBuffer(''); streamBufferRef.current = ''; } else { setMessages((msgs) => [...msgs, userMsg]); } // Build WS payload const payload: any = { conversationId, content }; if (audioData) { // Send raw base64 (strip data URL prefix) payload.audioData = audioData.includes(',') ? audioData.split(',')[1] : audioData; } if (attachments?.length) { payload.attachments = attachments.map((att) => { const match = att.preview.match(/^data:([^;]+);base64,(.+)$/); return { type: att.type, name: att.name, mediaType: match?.[1] || 'application/octet-stream', data: match?.[2] || '', }; }); } ws.send('user:message', payload); }, [ws, conversationId], ); const stopStreaming = useCallback(() => { if (!ws || !conversationId) return; ws.send('user:stop', { conversationId }); setStreaming(false); setStreamBuffer(''); streamBufferRef.current = ''; setTools([]); }, [ws, conversationId]); const clearContext = useCallback(() => { setMessages([]); setConversationId(null); setStreamBuffer(''); streamBufferRef.current = ''; setStreaming(false); setTools([]); prevConvId.current = null; loaded.current = false; authFetch('/api/context/clear', { method: 'POST' }).catch(() => {}); }, []); return { messages, streaming, streamBuffer, conversationId, tools, sendMessage, stopStreaming, clearContext }; }