/** * What a turn in the conversation earned: a scan card, a status card, a * retry. The panel and HQ render the same thread, so the reading of a turn * belongs here rather than in either of them. */ import type { ICopilotAction, ICopilotMessage, ICopilotStep, } from '../../service/copilot/copilot.interface'; /** The tool that starts a visibility scan. It gets a card of its own: it is * the one job measured in minutes rather than seconds. */ export const SCAN_TOOL_ID = 'start_visibility_scan'; /** The tool that reads catalog quality; a turn using it can show the catalog. */ export const CATALOG_TOOL_ID = 'get_catalog_quality'; /** Tool ids whose result is an async generation worth a status card. */ export const GENERATION_TOOL_IDS: ReadonlySet = new Set([ 'generate_article', // A social post is an article row on the same endpoint, so it polls to // "ready" and opens through this card exactly like a page does. 'write_social_post', 'generate_product_content', 'bulk_generate_product_content', 'run_company_id_read', 'push_company_id_to_visibility', ]); /** * Whether a step stands for work that actually happened. A tool held for a * yes, or one that answered with an error, has a step in the tree too, and a * card keyed on the step alone opened "Scan starting" over a question the * merchant had not answered yet. */ export const ranThrough = (step: ICopilotStep): boolean => step.state !== 'held' && step.state !== 'failed'; /** Whether a turn ran a tool through. */ const ranTool = ( message: ICopilotMessage, matches: (id: string) => boolean ): boolean => (message.steps ?? []).some(step => matches(step.id) && ranThrough(step)); /** Whether this turn kicked off a visibility scan. */ export const startedScan = (message: ICopilotMessage): boolean => ranTool(message, id => id === SCAN_TOOL_ID); /** Whether this turn read the merchant's catalog quality. */ export const readCatalog = (message: ICopilotMessage): boolean => ranTool(message, id => id === CATALOG_TOOL_ID); /** * Whether this turn started something long-running worth a status card. A * directive that reported a started job is enough on its own: the merchant * must see the loader even when the step tree came back under a name the * tool list does not know. */ export const startedGeneration = (message: ICopilotMessage): boolean => message.generationStatus !== undefined || ranTool(message, id => GENERATION_TOOL_IDS.has(id)); /** What the status card under a turn says. */ export type GenerationCardState = | { kind: 'content'; status: 'working' | 'ready' } | { kind: 'company_id'; status: 'working' | 'ready' | 'stalled'; watch: ICopilotMessage['generationWatch']; } | { kind: 'article'; status: 'working' | 'ready'; articleId: string | undefined; }; /** * The status card a turn earns, or none. An emailed product-content job is * working or done; a Company ID read is working, done, or gave up; an * article is working or ready. */ export function generationCard( message: ICopilotMessage ): GenerationCardState | null { if (!startedGeneration(message)) return null; const status = message.generationStatus; if (message.generationKind === 'content') { return { kind: 'content', status: status === 'ready' ? 'ready' : 'working', }; } if (message.generationKind === 'company_id_engines') { return { kind: 'company_id', status: status === 'stalled' ? 'stalled' : status === 'ready' ? 'ready' : 'working', watch: message.generationWatch, }; } return { kind: 'article', status: status === 'ready' ? 'ready' : 'working', articleId: message.generationArticleId, }; } /** * Whether a quick-action chip would do nothing if pressed. A chip pressed * mid-stream used to eat the whole row and do nothing, because the send it * asks for is refused while a turn is running. A chip the agent sent without * the route or prompt it needs is dead for good. */ export const isActionDisabled = ( action: ICopilotAction, isStreaming: boolean ): boolean => (isStreaming && action.action === 'prompt') || (action.action === 'navigate' && !action.route) || (action.action === 'prompt' && !action.prompt); /** The most characters a conversation's title carries; the agent stores the * same bound. */ export const THREAD_TITLE_MAX_CHARS = 60; /** * The title of a conversation a page opened (Ask Copilot, a quest): the * agent titles one by the merchant's first question, which such a * conversation does not have, so every one of them was listed as "New * conversation". The first line of what the page asked, cut at a word. * * @param {string} text - What the page sent. * @returns {string} The title. */ export const pageTurnTitle = (text: string): string => { const line: string = text.trim().split('\n')[0].trim().replace(/:$/, ''); if (line.length <= THREAD_TITLE_MAX_CHARS) return line; const cut: string = line.slice(0, THREAD_TITLE_MAX_CHARS - 1); const space: number = cut.lastIndexOf(' '); return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[\s,;:.(]+$/, '')}…`; };