Answer markers (idk / handoff / chips)
Source: platform/apps/app/src/lib/chat/answer-markers.ts · rendered from main on every deploy — edit in the repo, not here
/** * End-of-answer UI marker parser (ADR-0022 rich messages). * * Two markers share the same `A|B|C` shape: * * SUGGESTIONS: — ambient follow-up questions (3 max, up to 100 chars) * BUTTONS: — discrete choices the assistant is asking the user to * pick from (6 max, up to 80 chars — chip-sized) * STICKER: — ADR-0202: one sticker intent (a single token, e.g. * `thanks`); the channel core maps it to a sticker part. * ATTACH: — ADR-0208: response-asset ids (`ast_…`, `|`-separated, ≤ 4); * the server keeps only ids it retrieved for this turn. * * Markers are stripped from the visible answer; the widget receives them * as separate SSE events and renders chip UIs. Keeping the extraction * out of the chat handler makes it unit-testable and shareable with any * future channel (e.g. a LINE flex-message renderer). */
const SUG_MARKER = 'SUGGESTIONS:';const BTN_MARKER = 'BUTTONS:';const STK_MARKER = 'STICKER:';const ATT_MARKER = 'ATTACH:';const MAX_ATTACH = 4;const MAX_SUGGESTIONS = 3;const MAX_BUTTONS = 6;const MAX_SUGGESTION_LEN = 100;const MAX_BUTTON_LEN = 80;
export interface AnswerMarkers { /** Text with both markers stripped. */ clean: string; suggestions: string[]; buttons: string[]; /** Lower-cased sticker intent, or null when the marker is absent/empty. */ sticker: string | null; /** Response-asset ids named after ATTACH: (unvalidated — the caller checks them). */ attach: string[];}
export function parseAnswerMarkers(text: string): AnswerMarkers { // ATTACH: — a line of ids; lifted first like STICKER:. Brackets the model // may copy from the context list ("[ast_x]") are tolerated. let attach: string[] = []; const attIdx = text.lastIndexOf(ATT_MARKER); if (attIdx !== -1) { attach = text .slice(attIdx + ATT_MARKER.length) .split('\n')[0] .split('|') .map((s) => stripDecor(s) .replace(/^\[|\]$/g, '') .trim(), ) .filter((s) => /^ast_[A-Za-z0-9_-]{6,32}$/.test(s)) .slice(0, MAX_ATTACH); text = `${text.slice(0, attIdx)}${text.slice(attIdx).split('\n').slice(1).join('\n')}`.replace( /[\s*]+$/, '', ); } // The sticker marker is a single token on the last line; lift it out first // so it can't be mistaken for part of a SUGGESTIONS/BUTTONS list. let sticker: string | null = null; const stkIdx = text.lastIndexOf(STK_MARKER); if (stkIdx !== -1) { const raw = stripDecor(text.slice(stkIdx + STK_MARKER.length).split('\n')[0]).toLowerCase(); sticker = /^[a-z_]{1,24}$/.test(raw) ? raw : null; text = `${text.slice(0, stkIdx)}${text.slice(stkIdx).split('\n').slice(1).join('\n')}`.replace( /[\s*]+$/, '', ); }
const sugIdx = text.indexOf(SUG_MARKER); const btnIdx = text.indexOf(BTN_MARKER);
const extract = ( startAfter: number, nextMarkerIdx: number, cap: number, maxLen: number, ): string[] => { const end = nextMarkerIdx === -1 ? text.length : nextMarkerIdx; return text .slice(startAfter, end) .split('|') .map((s) => stripDecor(s)) .filter((s) => s.length > 0 && s.length < maxLen) .slice(0, cap); };
const suggestions: string[] = sugIdx === -1 ? [] : extract( sugIdx + SUG_MARKER.length, btnIdx > sugIdx ? btnIdx : -1, MAX_SUGGESTIONS, MAX_SUGGESTION_LEN, );
const buttons: string[] = btnIdx === -1 ? [] : extract( btnIdx + BTN_MARKER.length, sugIdx > btnIdx ? sugIdx : -1, MAX_BUTTONS, MAX_BUTTON_LEN, );
// Visible text ends at whichever marker appears first. Strip any trailing // markdown noise the model may have placed before the marker (e.g. // `**SUGGESTIONS:**` leaves a dangling `**` in the visible text). const present = [sugIdx, btnIdx].filter((i) => i !== -1); const cut = present.length > 0 ? Math.min(...present) : -1; const clean = cut === -1 ? text : text .slice(0, cut) .replace(/[\s*]+$/, '') .trim();
return { clean, suggestions, buttons, sticker, attach };}
/** Trim whitespace, surrounding markdown emphasis (`*` / `**`), and surrounding * quote chars (straight or curly). Models occasionally wrap the marker as * `**SUGGESTIONS:**` or quote each item; both leak garbage characters into the * extracted strings if not stripped. */function stripDecor(s: string): string { return s .trim() .replace(/^\*+|\*+$/g, '') .trim() .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '') .trim();}