import { useCallback, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useCopilot } from '../../context/copilot.context'; import type { ICopilotMessage, ICopilotThreadSummary, } from '../../service/copilot/copilot.interface'; import { deleteCopilotThread, fetchCopilotHistory, fetchCopilotThread, fetchCopilotThreads, renameCopilotThread, saveCopilotThread, streamCopilot, } from '../../service/copilot/copilot.service'; import { pageTurnTitle } from './thread-turns'; import { turnRequest } from './turn-request'; /** Tool ids that change server data: after these, affected pages re-fetch. */ const WRITE_TOOL_IDS: ReadonlySet = new Set([ 'call_api', 'bulk_delete', 'generate_article', 'generate_product_content', 'start_visibility_scan', 'update_brand_keywords', 'update_visibility_settings', 'generate_kb_document', 'push_kb_document', 'add_knowledge_text', ]); /** A turn, and the saved conversations it is written into. */ export interface ICopilotTurn { /** The merchant's conversations, newest first. */ threads: ICopilotThreadSummary[]; /** The conversation being written to, or null for one not saved yet. */ threadId: string | null; /** A picture queued to ride along the next turn. */ pendingLogo: { name: string; dataUri: string } | null; /** Ask, and stream the answer into the open conversation. A hidden turn * is one a page sent for the merchant: a quiet line, not their bubble. * The history sent along is the conversation, unless one is given. */ send: ( text: string, hidden?: boolean, past?: ICopilotMessage[] ) => Promise; /** Stop the answer being streamed. */ stop: () => void; /** Ask the last question again, after its answer failed. */ retry: () => Promise; /** Read the conversations again, after one was written to. */ refreshThreads: () => Promise; /** Put the conversation the merchant left open back on screen, and say * whether there was one. */ restoreLatest: () => Promise; /** Leave the open conversation for an empty one; nothing is deleted. */ startNewThread: () => void; /** Put a saved conversation back on screen, cards and buttons included. */ openThread: (threadId: string) => Promise; removeThread: (threadId: string) => Promise; renameThread: (threadId: string, title: string) => Promise; /** Queue a picture for the next turn, for the brand-colour read. */ attachLogo: (logo: { name: string; dataUri: string } | null) => void; } /** * One turn of the conversation, wherever it is shown: the docked panel and * the HQ page hold the same thread, so the streaming, the tools, the saved * conversations and the picture queued for the next turn all live here * rather than in whichever surface happens to be on screen. * * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @param onTurnStart - Told when a turn starts, so a surface showing the * landing screen can switch to the conversation. A hidden turn is one the * page fired, not the merchant. * @return The turn and the conversations around it. */ export function useCopilotTurn({ token, clientId, onTurnStart, }: { token: string; clientId: string; onTurnStart?: (hidden: boolean) => void; }): ICopilotTurn { const location = useLocation(); const navigate = useNavigate(); const copilot = useCopilot(); const [threads, setThreads] = useState([]); const [threadId, setThreadId] = useState(null); const [pendingLogo, setPendingLogo] = useState<{ name: string; dataUri: string; } | null>(null); // The stamp the last save came back with: the agent uses it to tell a // concurrent save from a stale one. const stampRef = useRef(null); const abortRef = useRef(null); const { isStreaming, addUserMessage, startAssistantMessage, setStreaming, upsertStep, setAssistantActions, setAssistantGeneration, appendToAssistant, failLastAssistant, setAssistantKbDocument, setPendingPopupCustomization, setPendingQuickActionIcons, setMessages, reset, getState, } = copilot; const refreshThreads = useCallback(async (): Promise => { setThreads(await fetchCopilotThreads(token, clientId)); }, [token, clientId]); /** * The newest conversation is the one a returning merchant left open. A * merchant from before conversations were kept apart still has their * single history, which stands in until their first save makes a thread. */ const restoreLatest = useCallback(async (): Promise => { const saved: ICopilotThreadSummary[] = await fetchCopilotThreads( token, clientId ); setThreads(saved); let restored: ICopilotMessage[] = []; if (saved.length > 0) { const thread = await fetchCopilotThread( saved[0].thread_id, token, clientId ); if (thread) { restored = thread.messages; setThreadId(thread.thread_id); stampRef.current = thread.updated_at ?? null; } } else { restored = await fetchCopilotHistory(token, clientId); } if (restored.length === 0 || getState().messages.length > 0) return false; setMessages(restored); return true; }, [token, clientId, getState, setMessages]); const send = useCallback( async ( text: string, hidden: boolean = false, past?: ICopilotMessage[] ): Promise => { const trimmed: string = text.trim(); if (!trimmed || isStreaming) return; onTurnStart?.(hidden); const history: ICopilotMessage[] = past ?? getState().messages; // A hidden turn is one a page sent (Ask Copilot, a quest): it stays in // the thread, marked, so the reply keeps its question in the history // the next turn sends and in the saved conversation. addUserMessage(trimmed, hidden); startAssistantMessage(); setStreaming(true); // Take any queued picture, so it rides along this one turn only. const logoImage: string | undefined = pendingLogo?.dataUri; if (pendingLogo) setPendingLogo(null); const controller: AbortController = new AbortController(); abortRef.current = controller; let didWrite: boolean = false; try { await streamCopilot( turnRequest(trimmed, { history, route: location.pathname, thinking: copilot.thinking, hidden, logoImage, }), { onStep: step => { if (WRITE_TOOL_IDS.has(step.id)) didWrite = true; upsertStep(step); }, onToken: appendToAssistant, onDirective: directive => { if (directive.directive === 'navigate') { // Beside the conversation when the page can host it, so a // directive arriving mid-answer does not pull the merchant // off the answer they are reading. if (!getState().openInPage?.(directive.path)) { navigate(directive.path); } } else if (directive.directive === 'actions') { setAssistantActions(directive.actions); } else if (directive.directive === 'generation') { setAssistantGeneration({ kind: directive.kind, articleId: directive.articleId, jobId: directive.jobId, dispatchedAt: directive.dispatchedAt, watch: directive.watch, }); } else if (directive.directive === 'kb_document') { // The document is the answer to something asked for in the // conversation, so it opens there: navigating to the // Knowledge Base page left the merchant to find it. setAssistantKbDocument(directive.fileName); } else if (directive.directive === 'customize') { setPendingPopupCustomization(directive.suggestion); // The /customization page reads this and pre-fills the form // for the merchant to review and save. if (location.pathname !== '/customization') { navigate('/customization'); } } else if (directive.directive === 'quick_action_icons') { setPendingQuickActionIcons(directive.icons); // The /customization page reads this and sets the generated // icons on the quick actions for review. if (location.pathname !== '/customization') { navigate('/customization'); } } }, onDone: () => setStreaming(false), onError: failLastAssistant, }, token, clientId, controller.signal ); } catch { // Aborted by the merchant (stop / new chat) or a network drop; // partial content stays in the thread. onError already covered the // HTTP failures. An answer that got nothing at all said "Thinking..." // for ever: it says what happened, and the thread offers Try again. const last: ICopilotMessage | undefined = getState().messages.at(-1); if ( last?.role === 'assistant' && !last.content && (last.steps?.length ?? 0) === 0 ) { failLastAssistant( controller.signal.aborted ? 'Stopped before an answer came.' : 'No answer came back: the connection dropped on the way.' ); } } finally { setStreaming(false); void (async () => { const result = await saveCopilotThread( getState().messages, threadId, stampRef.current, token, clientId ); if (!result.threadId) return; setThreadId(result.threadId); stampRef.current = result.updatedAt; // A conversation a page opened has no question of the merchant's // for the agent to title it by. if (hidden && !threadId) { await renameCopilotThread( result.threadId, pageTurnTitle(trimmed), token, clientId ); } // Another tab wrote to the same conversation while this one was // streaming: what is stored is both tabs' turns. if (result.mergedMessages) setMessages(result.mergedMessages); await refreshThreads(); })(); // A write changed server data: tell the affected pages to fetch it // again (WordPress has no loaders, so this event stands in for a // revalidate). if (didWrite) { window.dispatchEvent(new CustomEvent('copilot:data-changed')); } } }, [ isStreaming, copilot.thinking, onTurnStart, pendingLogo, threadId, addUserMessage, startAssistantMessage, setStreaming, upsertStep, setAssistantActions, setAssistantGeneration, appendToAssistant, failLastAssistant, setAssistantKbDocument, setPendingPopupCustomization, setPendingQuickActionIcons, setMessages, getState, refreshThreads, navigate, location.pathname, token, clientId, ] ); const stop = useCallback((): void => { abortRef.current?.abort(); setStreaming(false); }, [setStreaming]); /** * Ask the last question again: the answer that failed goes, with the * question, so the thread and the history the agent reads hold it once. */ const retry = useCallback(async (): Promise => { const messages: ICopilotMessage[] = getState().messages; let at: number = messages.length - 1; while (at >= 0 && messages[at].role !== 'user') at -= 1; if (at < 0) return; const question: ICopilotMessage = messages[at]; const before: ICopilotMessage[] = messages.slice(0, at); setMessages(before); await send(question.content, Boolean(question.hidden), before); }, [getState, setMessages, send]); const startNewThread = useCallback((): void => { abortRef.current?.abort(); reset(); setThreadId(null); stampRef.current = null; }, [reset]); const openThread = useCallback( async (id: string): Promise => { abortRef.current?.abort(); const thread = await fetchCopilotThread(id, token, clientId); if (!thread) return; setMessages(thread.messages); setThreadId(thread.thread_id); stampRef.current = thread.updated_at ?? null; }, [token, clientId, setMessages] ); const removeThread = useCallback( async (id: string): Promise => { if (!(await deleteCopilotThread(id, token, clientId))) return; setThreads(current => current.filter(thread => thread.thread_id !== id)); if (id === threadId) { reset(); setThreadId(null); stampRef.current = null; } }, [token, clientId, threadId, reset] ); const renameThread = useCallback( async (id: string, title: string): Promise => { if (!(await renameCopilotThread(id, title, token, clientId))) return; setThreads(current => current.map(thread => thread.thread_id === id ? { ...thread, title } : thread ) ); }, [token, clientId] ); return { threads, threadId, pendingLogo, send, stop, retry, refreshThreads, restoreLatest, startNewThread, openThread, removeThread, renameThread, attachLogo: setPendingLogo, }; } /** The conversations, for a surface that only lists them. */ export type CopilotThreads = ICopilotThreadSummary[];