Skip to content

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

platform/apps/app/src/lib/chat/answer-markers.ts
/**
* 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();
}