ai: rich-output blocks via lazy-fetched typed-fence protocol
Assistant replies can now emit typed fenced blocks that render as @crema/*-ui components inline at their position in the reply. - message-body.tsx: segmented rendering — alternating prose chunks and block dispatch (was: all blocks appended at end). Renderers for kpi, table, chart-bar/-line/-donut/-spark, code, diff, flowchart, orgchart, steps, checklist, welcome, hint, plus the legacy card kinds. - block-schemas.ts: single source of truth — BLOCK_INDEX (one-line purpose per kind, always in prompt) + SCHEMAS (full JSON shape + example, fetched on demand). - admin-tools.ts: new get_block_schema(kind) tool the model calls once per kind per thread to fetch the exact schema. Keeps the always-on prompt small (~110 tokens vs ~400 inline). - assistant.tsx: replaces the inline schema dump with the generated thin index. - ai.tsx: empty-state preview button injects a synthetic assistant message exercising every block, for renderer/theme smoke-testing. - console.css + ai.tsx: shrink ATLAS headline so it doesn't slip under the composer with the added preview button. - tsconfig.json + app.css: wire lib-data-ui, lib-code-ui, lib-diagram-ui, lib-onboarding-ui as siblings. Adding a new block kind = add the lib paths, add a renderer case, add a schema entry. No prompt edits required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
247
app/lib/block-schemas.ts
Normal file
247
app/lib/block-schemas.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Lazy-fetched schemas for the typed fenced blocks the assistant can emit.
|
||||
// The system prompt only ships a thin index (kind → one-line purpose). Full
|
||||
// JSON schemas + examples live here and are pulled on demand via the
|
||||
// `get_block_schema` tool. Keeps the always-on prompt small and lets new
|
||||
// blocks be added by editing this file alone — no prompt edits required.
|
||||
//
|
||||
// Renderer is in app/components/assistant/message-body.tsx — keep these in
|
||||
// sync (kinds, field names) when adding or changing blocks.
|
||||
|
||||
export type BlockKind =
|
||||
| "kpi"
|
||||
| "table"
|
||||
| "chart-bar"
|
||||
| "chart-line"
|
||||
| "chart-donut"
|
||||
| "chart-spark"
|
||||
| "code"
|
||||
| "diff"
|
||||
| "card"
|
||||
| "flowchart"
|
||||
| "orgchart"
|
||||
| "steps"
|
||||
| "checklist"
|
||||
| "welcome"
|
||||
| "hint"
|
||||
|
||||
export const BLOCK_INDEX: Record<BlockKind, string> = {
|
||||
kpi: "Headline numbers row (2–6 metrics).",
|
||||
table: "Tabular data (≥3 rows or ≥3 columns).",
|
||||
"chart-bar": "Compare ≤8 categories.",
|
||||
"chart-line": "Ordered series / trend over time.",
|
||||
"chart-donut": "Part-to-whole, ≤5 slices.",
|
||||
"chart-spark": "Inline trend, no axes.",
|
||||
code: "Syntax-highlighted snippet (SQL, JSON, YAML, etc).",
|
||||
diff: "Before/after comparison.",
|
||||
card: "Inline pill, stat chip, or callout banner.",
|
||||
flowchart: "Process / decision flow with shaped nodes (start/end/process/decision/io).",
|
||||
orgchart: "Tree of nested entities (org structure, dependency tree, taxonomy).",
|
||||
steps: "Multi-step plan with statuses (queued/running/done/error/skipped).",
|
||||
checklist: "Onboarding checklist with completable tasks (links/CTAs allowed).",
|
||||
welcome: "Hero welcome card with title, description, primary/secondary CTA.",
|
||||
hint: "Tip / lightbulb card with tone (info/success/warning/neutral/primary).",
|
||||
}
|
||||
|
||||
const SCHEMAS: Record<BlockKind, string> = {
|
||||
kpi: `\`\`\`kpi
|
||||
{ "items": [
|
||||
{ "label": "Tenants", "value": 42 },
|
||||
{ "label": "Active users", "value": 318, "unit": "/day" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: items[]: { label: string, value: string|number, unit?: string }.
|
||||
Use 2–6 items. Don't repeat the numbers in prose.`,
|
||||
|
||||
table: `\`\`\`table
|
||||
{ "columns": [
|
||||
{ "id": "slug", "header": "Tenant" },
|
||||
{ "id": "users", "header": "Users", "align": "right" },
|
||||
{ "id": "status", "header": "Status" }
|
||||
],
|
||||
"rows": [
|
||||
{ "slug": "acme", "users": 42, "status": "active" },
|
||||
{ "slug": "globex", "users": 18, "status": "suspended" }
|
||||
],
|
||||
"idKey": "slug" }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- columns[]: { id: string, header?: string, align?: "left"|"center"|"right", sortable?: boolean }
|
||||
- rows[]: object keyed by column id.
|
||||
- idKey?: string — column whose value is the row id (defaults to first column).
|
||||
Use for ≥3 rows OR ≥3 columns. Smaller lists → markdown table.`,
|
||||
|
||||
"chart-bar": `\`\`\`chart-bar
|
||||
{ "title": "Users by tenant",
|
||||
"data": [
|
||||
{ "label": "acme", "value": 42 },
|
||||
{ "label": "globex", "value": 18 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||
≤8 categories. For more, use a table.`,
|
||||
|
||||
"chart-line": `\`\`\`chart-line
|
||||
{ "title": "Signups over time",
|
||||
"series": [
|
||||
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, series[]: { x: number, y: number }.
|
||||
Use for ordered numeric series ≥3 points. x is treated as a numeric axis.`,
|
||||
|
||||
"chart-donut": `\`\`\`chart-donut
|
||||
{ "title": "Status breakdown",
|
||||
"data": [
|
||||
{ "label": "active", "value": 38 },
|
||||
{ "label": "suspended", "value": 4 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||
≤5 slices. Skip if one slice would be >90%.`,
|
||||
|
||||
"chart-spark": `\`\`\`chart-spark
|
||||
{ "values": [3, 5, 4, 8, 12, 9, 14] }
|
||||
\`\`\`
|
||||
Fields: values: number[], width?, height?, stroke?, fill?.
|
||||
Use inline next to a single number to show its recent trend.`,
|
||||
|
||||
code: `\`\`\`code
|
||||
{ "code": "SELECT count(*) FROM tenants WHERE status='active';",
|
||||
"language": "sql",
|
||||
"title": "Active tenant count",
|
||||
"lineNumbers": false,
|
||||
"highlightLines": [] }
|
||||
\`\`\`
|
||||
Fields: code: string, language?: string, title?: string, lineNumbers?: boolean, highlightLines?: number[].
|
||||
Languages with syntax: js/ts/tsx, python, rust, go, html, css, sql, json, yaml.
|
||||
Prefer this over plain markdown fences when the snippet matters (queries the user might copy, configs, etc.).`,
|
||||
|
||||
diff: `\`\`\`diff
|
||||
{ "oldCode": "max_users: 100\\n",
|
||||
"newCode": "max_users: 250\\n",
|
||||
"language": "yaml",
|
||||
"title": "Tenant quota change",
|
||||
"mode": "unified" }
|
||||
\`\`\`
|
||||
Fields: oldCode: string, newCode: string, language?: string, title?: string, mode?: "unified"|"split".
|
||||
Use for showing exactly what changed in a config, query, or file.`,
|
||||
|
||||
card: `\`\`\`card
|
||||
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "This action is destructive." }
|
||||
\`\`\`
|
||||
Three sub-kinds:
|
||||
- pill: { "kind": "pill", "status": "active"|"suspended"|"deactivated"|other, "label"?: string } — small status badge.
|
||||
- stat: { "kind": "stat", "label": string, "value": string|number } — inline metric chip.
|
||||
- callout: { "kind": "callout", "tone": "info"|"warning"|"danger"|"success", "title"?: string, "body"?: string } — banner.
|
||||
Use sparingly. For multiple metrics use \`kpi\` instead of multiple \`stat\` cards.`,
|
||||
|
||||
flowchart: `\`\`\`flowchart
|
||||
{ "nodes": [
|
||||
{ "id": "a", "type": "start", "label": "Receive request", "x": 60, "y": 20 },
|
||||
{ "id": "b", "type": "process", "label": "Validate token", "x": 60, "y": 100 },
|
||||
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 60, "y": 180 },
|
||||
{ "id": "d", "type": "process", "label": "Process", "x": 220, "y": 180 },
|
||||
{ "id": "e", "type": "end", "label": "Reject", "x": 60, "y": 280 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "a", "to": "b" },
|
||||
{ "from": "b", "to": "c" },
|
||||
{ "from": "c", "to": "d", "label": "yes" },
|
||||
{ "from": "c", "to": "e", "label": "no" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- nodes[]: { id: string, type: "start"|"end"|"process"|"decision"|"io", label: string, x: number, y: number } — coordinates in pixels (canvas auto-sizes).
|
||||
- edges[]: { from: nodeId, to: nodeId, label?: string }.
|
||||
Use for control flow, workflows, request lifecycles. Keep ≤12 nodes; lay out top-to-bottom or left-to-right with ~80–120px spacing.`,
|
||||
|
||||
orgchart: `\`\`\`orgchart
|
||||
{ "data": {
|
||||
"id": "root", "name": "Platform", "title": "Tenant",
|
||||
"children": [
|
||||
{ "id": "a", "name": "Auth", "title": "Service",
|
||||
"children": [
|
||||
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||||
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||||
] },
|
||||
{ "id": "b", "name": "Billing", "title": "Service" }
|
||||
] },
|
||||
"horizontal": false }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- data: OrgNode = { id: string, name: string, title?: string, avatar?: string (url), children?: OrgNode[] }
|
||||
- horizontal?: boolean — left-to-right vs top-to-bottom (default).
|
||||
Use for nested hierarchies (org charts, dependency trees, taxonomies). Skip for flat lists.`,
|
||||
|
||||
steps: `\`\`\`steps
|
||||
{ "steps": [
|
||||
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||||
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||||
{ "id": "3", "title": "Build report", "status": "queued" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- steps[]: { id: string, title: string, status: "queued"|"planning"|"running"|"waiting"|"done"|"error"|"skipped", detail?: string, substeps?: same-shape[] }
|
||||
Use for: showing a multi-step plan you're about to execute, or a post-hoc trail of what you did. Skip for single-step actions.`,
|
||||
|
||||
checklist: `\`\`\`checklist
|
||||
{ "title": "Get started",
|
||||
"description": "Finish setting up your tenant.",
|
||||
"tasks": [
|
||||
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||||
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||||
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title?: string, description?: string
|
||||
- tasks[]: { id: string, title: string, description?: string, completed?: boolean, optional?: boolean, estimate?: string, href?: string }
|
||||
Use for: actionable setup lists with progress. Each task with an href becomes a click-through link. Toggling is read-only in chat (can't persist completion across turns).`,
|
||||
|
||||
welcome: `\`\`\`welcome
|
||||
{ "title": "Welcome to Arcadia Admin",
|
||||
"description": "Manage tenants, users, and platform settings from one place.",
|
||||
"badge": "v2",
|
||||
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||||
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title: string (required), description?: string, badge?: string
|
||||
- primaryAction?, secondaryAction?: { label: string, href?: string }
|
||||
Use sparingly — once at the top of a thread that's introducing a feature/product, never as a recurring response.`,
|
||||
|
||||
hint: `\`\`\`hint
|
||||
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title?: string, body: string
|
||||
- tone?: "info"|"success"|"warning"|"neutral"|"primary" (default "info")
|
||||
- action?: { label: string, href?: string }
|
||||
Use for: discoverability tips, gotchas, "did you know". One per reply.`,
|
||||
}
|
||||
|
||||
const ALL_KINDS = Object.keys(SCHEMAS) as BlockKind[]
|
||||
|
||||
export function isBlockKind(kind: string): kind is BlockKind {
|
||||
return (ALL_KINDS as string[]).includes(kind)
|
||||
}
|
||||
|
||||
export function getBlockSchema(kind: string): string | null {
|
||||
if (!isBlockKind(kind)) return null
|
||||
return SCHEMAS[kind]
|
||||
}
|
||||
|
||||
/** Thin index suitable for the always-on system prompt. */
|
||||
export function blockIndexForPrompt(): string {
|
||||
const lines = ALL_KINDS.map((k) => ` ${k} — ${BLOCK_INDEX[k]}`)
|
||||
return [
|
||||
"Rich output: when a UI primitive will communicate better than prose, emit a typed fenced ```<kind>\\n<json>\\n``` block. The chat renderer turns it into a @crema/*-ui component inline at that position.",
|
||||
"",
|
||||
"Available kinds:",
|
||||
...lines,
|
||||
"",
|
||||
"Before emitting a block for the FIRST time in a thread, call get_block_schema(kind) to fetch the exact JSON shape and field rules. Once you've seen a schema in this conversation, reuse it from memory.",
|
||||
"Always lead with one short sentence of prose, then the block. Don't repeat block data in prose.",
|
||||
"JSON must be valid (double quotes, no trailing commas). If unsure of the schema, fetch it.",
|
||||
].join("\n")
|
||||
}
|
||||
Reference in New Issue
Block a user