143 lines
6.0 KiB
JavaScript
143 lines
6.0 KiB
JavaScript
/**
|
|
* Response blocks.
|
|
*
|
|
* Answers are documents, not strings. A capability decides how its information
|
|
* is best communicated — a comparison is a table, a health check is a status
|
|
* list, a funnel is a funnel — and the renderer draws it with design-system
|
|
* components. That is what turns a reply into a small interactive report rather
|
|
* than a wall of chat text.
|
|
*
|
|
* Blocks are plain data, so they serialize: a future remote service can return
|
|
* exactly this shape and nothing in the UI changes.
|
|
*/
|
|
|
|
/** Assembles a document, flattening nested arrays and dropping empty slots. */
|
|
export const doc = (...blocks) => ({
|
|
blocks: blocks.flat(Infinity).filter(Boolean),
|
|
});
|
|
|
|
/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
|
|
export const text = (value) => value && { type: 'text', text: value };
|
|
|
|
/** A section label inside a response. */
|
|
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
|
|
|
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
|
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
|
|
|
/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
|
|
export const status = (items) => items?.length && { type: 'status', items };
|
|
|
|
/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
|
|
export const meters = (items) => items?.length && { type: 'meters', items };
|
|
|
|
/**
|
|
* A comparison or record table.
|
|
* `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
|
|
* A cell may be a string, a number, or `{ value, tone, badge }`.
|
|
*/
|
|
export const table = (columns, rows, options = {}) =>
|
|
rows?.length && { type: 'table', columns, rows, ...options };
|
|
|
|
/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
|
|
export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
|
|
|
|
/** Bulleted or numbered points. */
|
|
export const list = (items, options = {}) =>
|
|
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
|
|
|
|
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
|
|
/**
|
|
* Findings. `items: [{ title, body, tone?, to?, hint? }]`
|
|
*
|
|
* `to` makes the card a link into the app — a recommended person opening their
|
|
* own record, rather than a name the reader has to go and look up. It is a route
|
|
* the app already has; nothing here invents an address, and an item without one
|
|
* renders exactly as it always did.
|
|
*/
|
|
export const insights = (items) =>
|
|
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
|
|
|
|
/** Recommended next steps. `items: [{ title, body }]` */
|
|
export const actions = (items) =>
|
|
items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
|
|
|
|
/** Inline tags. `items: [{ label, tone }]` or plain strings. */
|
|
export const badges = (items) =>
|
|
items?.filter(Boolean).length && {
|
|
type: 'badges',
|
|
items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
|
|
};
|
|
|
|
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
|
|
export const timeline = (items) => items?.length && { type: 'timeline', items };
|
|
|
|
/**
|
|
* A section a skill definition declared, drawn in the panel.
|
|
*
|
|
* The one block whose shape is not decided here: `section` is the normalized
|
|
* record the registry produced from a `.md` file, and `data` is what the shared
|
|
* resolver read for it. The renderer hands both to the same component the page
|
|
* mounts, so a flow asked for in conversation *is* the page's flow rather than
|
|
* a chat-shaped imitation of it.
|
|
*
|
|
* Both halves are plain data, so a reply containing one still serializes into
|
|
* the stored thread like every other block.
|
|
*/
|
|
export const skillSection = (section, data) =>
|
|
section && data && { type: 'skillSection', section, data };
|
|
|
|
/** A caveat. Always the last word on a claim, never the headline. */
|
|
export const note = (value) => value && { type: 'note', text: value };
|
|
|
|
/**
|
|
* A write the agent has proposed and NOT performed.
|
|
*
|
|
* The only block in this file that carries a decision rather than information,
|
|
* and the only one whose renderer has a button. Everything else here describes
|
|
* something that already happened; this describes something that will happen if
|
|
* a person says so.
|
|
*
|
|
* The payload comes from the server verbatim — title, summary, details,
|
|
* warnings, token — and is rendered rather than reformatted. The wording was
|
|
* composed beside the code that will do the writing, so a browser paraphrasing
|
|
* it would be describing a different act than the one the token authorises.
|
|
*
|
|
* `token` is what the approval sends back. It authorises exactly one call, with
|
|
* exactly those arguments, and it expires.
|
|
*/
|
|
export const confirmation = (payload) =>
|
|
payload?.token && { type: 'confirmation', ...payload };
|
|
|
|
/** A single sentence answer — used by short free-text replies. */
|
|
export const answer = (value) => doc(text(value));
|
|
|
|
/* ── Streaming ──────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Splits a document into the progressive snapshots the provider streams.
|
|
*
|
|
* Text and heading blocks reveal word by word; structured blocks appear whole,
|
|
* because a half-drawn table reads as broken rather than as arriving. The result
|
|
* is a response that composes itself in a readable order.
|
|
*/
|
|
export function toSnapshots({ blocks }) {
|
|
const snapshots = [];
|
|
const settled = [];
|
|
|
|
for (const block of blocks) {
|
|
if (block.type === 'text' || block.type === 'heading') {
|
|
const tokens = String(block.text).split(/(\s+)/);
|
|
// Three words per snapshot: fast enough to feel live, coarse enough to
|
|
// avoid a reflow per character.
|
|
for (let i = 6; i < tokens.length; i += 6) {
|
|
snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
|
|
}
|
|
}
|
|
settled.push(block);
|
|
snapshots.push([...settled]);
|
|
}
|
|
|
|
return snapshots.length ? snapshots : [[]];
|
|
}
|