import BaseAgentService from '../BaseAgentService'; import { COPILOT_HISTORY_URL, COPILOT_PUBLISH_ARTICLE_URL, COPILOT_QUESTS_REFRESH_URL, COPILOT_QUESTS_URL, COPILOT_STREAM_URL, buildCopilotArticleStatusUrl, buildCopilotContentStatusUrl, buildCopilotQuestDismissUrl, } from './copilot.routes'; import type { ICopilotAction, ICopilotMessage, ICopilotPopupCustomization, ICopilotQuestOverview, ICopilotQuickActionIcon, ICopilotStreamHandlers, ICopilotStreamRequest, } from './copilot.interface'; /** * Builds the auth headers the agent expects. Reuses * {@link BaseAgentService.clientIdHeaders} (Bearer + client-id + X-Client-Id + * Accept) and layers on ``Content-Type`` so fetch-based calls (axios can't * stream) authenticate exactly like the typed service wrappers. * * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return A headers map carrying the bearer token and the client id. */ function buildHeaders(token: string, clientId: string): Record { return { 'Content-Type': 'application/json', ...BaseAgentService.clientIdHeaders(clientId, token), }; } /** * Refreshes and loads the merchant's daily quests (resolves finished ones and * generates today's if none are open). * * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return The overview, or null on failure. */ export async function refreshCopilotQuests( token: string, clientId: string ): Promise { try { const response: Response = await fetch(COPILOT_QUESTS_REFRESH_URL, { method: 'POST', headers: buildHeaders(token, clientId), }); if (!response.ok) return null; return (await response.json()) as ICopilotQuestOverview; } catch { return null; } } /** * Dismisses a proactive-nudge quest so it stops surfacing, returning the * refreshed overview (with the next-best nudge). * * @param questId - The quest to dismiss. * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return The overview, or null on failure. */ export async function dismissCopilotQuest( questId: string, token: string, clientId: string ): Promise { try { const response: Response = await fetch( buildCopilotQuestDismissUrl(questId), { method: 'POST', headers: buildHeaders(token, clientId), } ); if (!response.ok) return null; return (await response.json()) as ICopilotQuestOverview; } catch { return null; } } /** * Reads the current quest overview + setup state without generating new quests. * Used to re-sync the home surface after the merchant completes a step. * * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return The overview, or null on failure. */ export async function fetchCopilotQuests( token: string, clientId: string ): Promise { try { const response: Response = await fetch(COPILOT_QUESTS_URL, { headers: buildHeaders(token, clientId), }); if (!response.ok) return null; return (await response.json()) as ICopilotQuestOverview; } catch { return null; } } /** * Loads the persisted conversation thread (within the agent's retention window). * * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return Stored messages, or an empty list. */ export async function fetchCopilotHistory( token: string, clientId: string ): Promise { try { const response: Response = await fetch(COPILOT_HISTORY_URL, { headers: buildHeaders(token, clientId), }); if (!response.ok) return []; const data: { messages?: ICopilotMessage[] } = await response.json(); return Array.isArray(data.messages) ? data.messages : []; } catch { return []; } } /** * Persists the conversation thread (role + content only; steps are transient). * * @param messages - The thread to save. * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return Resolves once the save request settles. */ export async function saveCopilotConversation( messages: ICopilotMessage[], token: string, clientId: string ): Promise { try { await fetch(COPILOT_HISTORY_URL, { method: 'POST', headers: buildHeaders(token, clientId), body: JSON.stringify({ messages: messages.map((message: ICopilotMessage) => ({ role: message.role, content: message.content, })), }), }); } catch { // Best-effort persistence; a failed save should never break the chat. } } /** * Parses one SSE ``data:`` line and dispatches it to the stream handlers. * * @param line - A single line from the SSE body. * @param handlers - Stream callbacks. * @return Nothing. */ function dispatchSseLine(line: string, handlers: ICopilotStreamHandlers): void { if (!line.startsWith('data:')) return; const raw: string = line.slice('data:'.length).trim(); if (!raw) return; let event: { type: string; text?: string; message?: string; id?: string; label?: string; detail?: string; state?: 'running' | 'done'; directive?: | 'navigate' | 'actions' | 'generation' | 'kb_document' | 'customize' | 'quick_action_icons'; path?: string; actions?: ICopilotAction[]; kind?: 'article' | 'content'; article_id?: string; job_id?: string; file_name?: string; suggestion?: ICopilotPopupCustomization; icons?: ICopilotQuickActionIcon[]; }; try { event = JSON.parse(raw); } catch { return; } if ( event.type === 'ui_directive' && event.directive === 'navigate' && event.path ) { handlers.onDirective({ directive: 'navigate', path: event.path }); } else if ( event.type === 'ui_directive' && event.directive === 'actions' && Array.isArray(event.actions) && event.actions.length > 0 ) { handlers.onDirective({ directive: 'actions', actions: event.actions }); } else if ( event.type === 'ui_directive' && event.directive === 'generation' && event.kind === 'content' && event.job_id ) { handlers.onDirective({ directive: 'generation', kind: 'content', jobId: event.job_id, }); } else if ( event.type === 'ui_directive' && event.directive === 'generation' && event.article_id ) { handlers.onDirective({ directive: 'generation', kind: 'article', articleId: event.article_id, }); } else if ( event.type === 'ui_directive' && event.directive === 'kb_document' && event.file_name ) { handlers.onDirective({ directive: 'kb_document', fileName: event.file_name, }); } else if ( event.type === 'ui_directive' && event.directive === 'customize' && event.suggestion ) { handlers.onDirective({ directive: 'customize', suggestion: event.suggestion, }); } else if ( event.type === 'ui_directive' && event.directive === 'quick_action_icons' && Array.isArray(event.icons) && event.icons.length > 0 ) { handlers.onDirective({ directive: 'quick_action_icons', icons: event.icons, }); } else if (event.type === 'step' && event.id && event.label && event.state) { handlers.onStep({ id: event.id, label: event.label, detail: event.detail, state: event.state, }); } else if (event.type === 'token' && event.text) { handlers.onToken(event.text); } else if (event.type === 'done') { handlers.onDone(); } else if (event.type === 'error') { handlers.onError(event.message ?? 'The copilot hit an error.'); } } /** * Streams a copilot answer directly from the agent, invoking handlers as tokens * arrive. * * @param request - Message, history, and context. * @param handlers - Token / done / error callbacks. * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @param signal - Optional signal to cancel the stream. * @return Resolves when the stream ends or is aborted. */ export async function streamCopilot( request: ICopilotStreamRequest, handlers: ICopilotStreamHandlers, token: string, clientId: string, signal?: AbortSignal ): Promise { const response: Response = await fetch(COPILOT_STREAM_URL, { method: 'POST', headers: buildHeaders(token, clientId), body: JSON.stringify(request), signal, }); if (!response.ok || !response.body) { handlers.onError( response.status === 403 ? 'The copilot is not enabled for this account.' : 'The copilot is unavailable right now.' ); return; } const reader: ReadableStreamDefaultReader = response.body.getReader(); const decoder: TextDecoder = new TextDecoder(); let buffer: string = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let separatorIndex: number = buffer.indexOf('\n\n'); while (separatorIndex !== -1) { const frame: string = buffer.slice(0, separatorIndex); buffer = buffer.slice(separatorIndex + 2); for (const frameLine of frame.split('\n')) { dispatchSseLine(frameLine, handlers); } separatorIndex = buffer.indexOf('\n\n'); } } } /** Lean status of a copilot-started article, polled by the in-chat card. */ export interface ICopilotArticleStatus { /** Lifecycle status: "generating" | "pending" | "ready" | "published" | ... */ status: string; /** The polled article id. */ article_id: string; /** Current title (topic while generating, final title once ready). */ title: string; } /** * Polls a copilot-started article's status so the in-chat card can flip from * "Generating" to "Ready". * * @param articleId - The article id to check. * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return The status, or null on failure. */ export async function fetchCopilotArticleStatus( articleId: string, token: string, clientId: string ): Promise { try { const response: Response = await fetch( buildCopilotArticleStatusUrl(articleId), { headers: buildHeaders(token, clientId) } ); if (!response.ok) return null; const payload: { data?: { summary?: { status?: string; title?: string } }; } = await response.json(); return { status: payload.data?.summary?.status ?? 'unknown', article_id: articleId, title: payload.data?.summary?.title ?? '', }; } catch { return null; } } /** * Polls a copilot-started product-content job's state so the in-chat card can * flip from "Generating" to "Sent to your email". * * @param jobId - The content-generator job id to check. * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @return The job state, or null on failure. */ export async function fetchCopilotContentState( jobId: string, token: string, clientId: string ): Promise { try { const response: Response = await fetch( buildCopilotContentStatusUrl(jobId), { headers: buildHeaders(token, clientId), } ); if (!response.ok) return null; const payload: { data?: { state?: string } } = await response.json(); return payload.data?.state ?? null; } catch { return null; } } /** * Publish a generated article live to the merchant's WordPress blog. The agent * reads the store's connector and creates the post via the WordPress API. * * @param articleId - The ready article to publish. * @param token - The merchant's recomaze JWT. * @param clientId - The merchant's recomaze client id. * @param override - Optional per-article publishing target that overrides the * merchant's global default for this one article: a post type, a parent path * (publishes as a child Page under it), and/or a taxonomy-to-term-IDs map. * @return ``{ ok }`` true when published, with an optional error message. */ export async function publishCopilotArticleToBlog( articleId: string, token: string, clientId: string, override?: { post_type?: string; parent_path?: string; terms?: Record; } ): Promise<{ ok: boolean; error?: string }> { try { const response: Response = await fetch(COPILOT_PUBLISH_ARTICLE_URL, { method: 'POST', headers: buildHeaders(token, clientId), body: JSON.stringify({ article_id: articleId, post_type: override?.post_type, parent_path: override?.parent_path, terms: override?.terms, }), }); const data: { published?: boolean; error?: string } = await response .json() .catch(() => ({})); if (response.ok && data.published) return { ok: true }; return { ok: false, error: data.error ?? 'Could not publish the article.' }; } catch { return { ok: false, error: 'Could not reach the publisher. Try again.' }; } }