import { useCallback, useEffect, useRef, useState } from 'react'; import type { WsClient } from '../lib/ws-client'; import type { ChatMessage, ToolActivity, Attachment, StoredAttachment } from './useChat'; import { authFetch } from '../lib/auth'; /** * Chat hook for the standalone Bloby chat app. * Loads/persists messages via the DB (worker API). * Supports cross-device sync via chat:sync WS events. */ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enabled = true) { const [messages, setMessages] = useState([]); const [conversationId, setConversationId] = useState(null); const [streaming, setStreaming] = useState(false); const [streamBuffer, setStreamBuffer] = useState(''); const [tools, setTools] = useState([]); const [hasMore, setHasMore] = useState(false); const loaded = useRef(false); const conversationIdRef = useRef(null); const streamingRef = useRef(false); const loadingOlder = useRef(false); /** Ref to current streamBuffer (avoids stale closures in sendMessage) */ const streamBufferRef = useRef(''); /** Length of text already committed as a partial message — strip from next bot:response */ const committedTextLength = useRef(0); /** ID of the pending audio message waiting for transcription */ const pendingAudioId = useRef(null); /** Page context from Chrome extension (URL, title, etc.) */ const extensionPageContext = useRef(null); /** Whether we're running inside the Chrome extension */ const isExtension = useRef(new URLSearchParams(location.search).has('ext')); // Keep refs in sync with state useEffect(() => { conversationIdRef.current = conversationId; }, [conversationId]); useEffect(() => { streamingRef.current = streaming; }, [streaming]); // Listen for page context from Chrome extension panel useEffect(() => { if (!isExtension.current) return; function handleExtMessage(event: MessageEvent) { if (event.data?.type === 'bloby:page-context') { extensionPageContext.current = event.data.context; } } window.addEventListener('message', handleExtMessage); // Request initial context window.parent?.postMessage({ type: 'bloby:request-context' }, '*'); return () => window.removeEventListener('message', handleExtMessage); }, []); // Parse a raw DB message into a ChatMessage const parseMessage = useCallback((m: any): ChatMessage => { let audioData: string | undefined; if (m.audio_data) { if (m.audio_data.startsWith('data:')) { audioData = m.audio_data; } else if (m.audio_data.includes('/')) { audioData = `/api/files/${m.audio_data}`; } else { audioData = `data:audio/webm;base64,${m.audio_data}`; } } let attachments: StoredAttachment[] | undefined; if (m.attachments) { try { attachments = JSON.parse(m.attachments); } catch { /* ignore */ } } return { id: m.id, role: m.role, content: m.content, timestamp: m.created_at, audioData, hasAttachments: !!(attachments && attachments.length > 0), attachments, }; }, []); // Load current conversation from DB (last 200 messages — pagination kicks in beyond that) const loadFromDb = useCallback(async () => { try { const ctx = await authFetch('/api/context/current').then((r) => r.json()); if (!ctx.conversationId) return; setConversationId(ctx.conversationId); const limit = 200; const res = await authFetch(`/api/conversations/${ctx.conversationId}/messages?limit=${limit}`); if (!res.ok) return; const data = await res.json(); if (!data?.length) return; const filtered = data.filter((m: any) => m.role === 'user' || m.role === 'assistant'); setMessages(filtered.map(parseMessage)); setHasMore(data.length >= limit); } catch { /* worker not ready yet */ } }, [parseMessage]); // Load older messages (cursor-based pagination — cursor is the rowid-equivalent on the server) const loadOlder = useCallback(async () => { if (loadingOlder.current || !conversationIdRef.current) return; loadingOlder.current = true; try { const oldestId = messages[0]?.id; if (!oldestId) return; const limit = 100; const res = await authFetch(`/api/conversations/${conversationIdRef.current}/messages?before=${oldestId}&limit=${limit}`); if (!res.ok) return; const data = await res.json(); if (!data?.length) { setHasMore(false); return; } const filtered = data .filter((m: any) => m.role === 'user' || m.role === 'assistant') .map(parseMessage); setMessages((prev) => [...filtered, ...prev]); if (data.length < limit) setHasMore(false); } catch { /* ignore */ } finally { loadingOlder.current = false; } }, [messages, parseMessage]); // Load on mount (only when enabled/authenticated) useEffect(() => { if (!enabled || loaded.current) return; loaded.current = true; loadFromDb(); }, [enabled, loadFromDb]); // Reload on reconnect (triggerReload changes) useEffect(() => { if (enabled && triggerReload && triggerReload > 0) { loadFromDb(); } }, [enabled, triggerReload, loadFromDb]); // Periodic DB sync after reconnect while streaming — catches the final bot:response useEffect(() => { if (!enabled || !triggerReload || triggerReload === 0 || !streaming) return; const interval = setInterval(() => { if (!streamingRef.current) { clearInterval(interval); return; } loadFromDb(); }, 3000); return () => clearInterval(interval); }, [enabled, triggerReload, streaming, loadFromDb]); 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; status?: string }) => { // Commit current stream as a bubble when agent pauses for tool use // This creates separate message bubbles and shows dots during work const content = streamBufferRef.current; if (content) { committedTextLength.current += content.length; setMessages((msgs) => [ ...msgs, { id: 'tool-' + Date.now(), role: 'assistant' as const, content, timestamp: new Date().toISOString(), }, ]); setStreamBuffer(''); streamBufferRef.current = ''; // streaming stays true → empty buffer = typing dots } 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 }) => { if (conversationIdRef.current && data.conversationId !== conversationIdRef.current) return; setConversationId(data.conversationId); // Strip text that was already committed as a partial message let content = data.content || ''; if (committedTextLength.current > 0) { content = content.slice(committedTextLength.current).replace(/^\n+/, ''); committedTextLength.current = 0; } // Only add a bubble if there's new content if (content.trim()) { setMessages((msgs) => [ ...msgs, { id: data.messageId || Date.now().toString(), role: 'assistant', content, timestamp: new Date().toISOString(), }, ]); } setStreamBuffer(''); streamBufferRef.current = ''; setTools([]); // Don't clear streaming — wait for bot:idle from server }), // Sub-agent spawned — commit current text as bubble, show dots while it works ws.on('bot:task-created', () => { const content = streamBufferRef.current; if (content) { committedTextLength.current += content.length; setMessages((msgs) => [ ...msgs, { id: 'pre-task-' + Date.now(), role: 'assistant', content, timestamp: new Date().toISOString(), }, ]); setStreamBuffer(''); streamBufferRef.current = ''; } // streaming stays true → empty buffer = typing dots }), 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 sync: append message from another client ws.on('chat:sync', (data: { conversationId: string; message: { role: string; content: string; timestamp: string; attachments?: StoredAttachment[] } }) => { if (conversationIdRef.current && data.conversationId !== conversationIdRef.current) return; setMessages((msgs) => [ ...msgs, { id: Date.now().toString(), role: data.message.role as 'user' | 'assistant', content: data.message.content, timestamp: data.message.timestamp, hasAttachments: !!(data.message.attachments?.length), attachments: data.message.attachments, }, ]); }), // Server created a new conversation ws.on('chat:conversation-created', (data: { conversationId: string }) => { setConversationId(data.conversationId); }), // Server sends current streaming state on (re)connect ws.on('chat:state', (data: { streaming: boolean; conversationId?: string; buffer?: string }) => { if (data.streaming) { setStreaming(true); if (data.conversationId) setConversationId(data.conversationId); if (data.buffer) setStreamBuffer(data.buffer); } }), // Context cleared (from any client) ws.on('chat:cleared', () => { setMessages([]); setConversationId(null); setStreamBuffer(''); setStreaming(false); setTools([]); loaded.current = false; }), ]; return () => unsubs.forEach((u) => u()); }, [ws]); /** Add a pending audio bubble immediately (before transcription finishes) */ const addPendingAudio = useCallback((audioData: string) => { const id = 'pending-audio-' + Date.now(); pendingAudioId.current = id; setMessages((msgs) => [ ...msgs, { id, role: 'user', content: '', timestamp: new Date().toISOString(), audioData: audioData.startsWith('data:') ? audioData : `data:audio/webm;base64,${audioData}`, transcribing: true, }, ]); }, []); const sendMessage = useCallback( (content: string, attachments?: Attachment[], audioData?: string) => { if (!ws || (!content.trim() && (!attachments || attachments.length === 0) && !audioData)) return; // If bot is streaming, commit partial response first const partialContent = streamBufferRef.current; if (partialContent) { committedTextLength.current += partialContent.length; setMessages((msgs) => [ ...msgs, { id: 'partial-' + Date.now(), role: 'assistant' as const, content: partialContent, timestamp: new Date().toISOString(), }, ]); setStreamBuffer(''); streamBufferRef.current = ''; } // If updating a pending audio message, update in-place instead of adding new const pendingId = pendingAudioId.current; if (audioData && pendingId) { pendingAudioId.current = null; setMessages((msgs) => msgs.map((m) => m.id === pendingId ? { ...m, content, transcribing: false, hasAttachments: !!(attachments && attachments.length > 0) } : m, ), ); } else { // Normal: add new user message 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, }; setMessages((msgs) => [...msgs, userMsg]); } // Prepend page context if running in Chrome extension let messageContent = content; if (isExtension.current && extensionPageContext.current) { const ctx = extensionPageContext.current; const parts = [`[Page: ${ctx.url}`]; if (ctx.title) parts[0] += ` | ${ctx.title}`; if (ctx.productName) parts[0] += ` | ${ctx.productName}`; if (ctx.price) parts[0] += ` | ${ctx.currency || '$'}${ctx.price}`; parts[0] += ']'; if (ctx.selection) parts.push(`[Selected: ${ctx.selection}]`); if (ctx.hasForms) parts.push(`[Page has ${ctx.formCount} form(s)]`); messageContent = parts.join('\n') + '\n' + content; // Request fresh context for next message window.parent?.postMessage({ type: 'bloby:request-context' }, '*'); } const payload: any = { conversationId, content: messageContent }; if (audioData) { 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) return; ws.send('user:stop', { conversationId }); setStreaming(false); setStreamBuffer(''); streamBufferRef.current = ''; committedTextLength.current = 0; setTools([]); }, [ws, conversationId]); const clearContext = useCallback(() => { // Send clear to server (which broadcasts to all clients + clears Agent SDK session) if (ws) ws.send('user:clear-context', {}); // Optimistic local clear setMessages([]); setConversationId(null); setStreamBuffer(''); streamBufferRef.current = ''; committedTextLength.current = 0; setStreaming(false); setTools([]); loaded.current = false; }, [ws]); return { messages, streaming, streamBuffer, conversationId, tools, hasMore, loadOlder, sendMessage, addPendingAudio, stopStreaming, clearContext }; }