Files
krow_talent_app/src/components/ai-assistant/blocks.js
2026-08-28 11:02:02 +05:30

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 : [[]];
}