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:
jules
2026-05-02 22:47:36 +10:00
parent cdb96499be
commit 9cbe921db7
8 changed files with 966 additions and 143 deletions

View File

@@ -5,6 +5,7 @@ import {
useRef,
useState,
} from "react"
import { createPortal } from "react-dom"
import {
Archive,
ArrowRight,
@@ -41,6 +42,7 @@ import {
useSettings as useProviderSettings,
} from "@crema/llm-providers-ui"
import { TypingIndicator } from "@crema/chat-ui"
import { useToast } from "@crema/notification-ui"
import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
@@ -67,6 +69,7 @@ import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client"
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
import type { DocHit } from "~/lib/docs-search"
import {
AgentAvatar,
ToolCallCard,
@@ -96,6 +99,176 @@ function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
return <div className="px-1">{rich}</div>
}
// Synthetic assistant message that exercises every typed rich-output block.
// Wired to the "preview rich-output blocks" button in the empty state — used
// to eyeball renderer + theme without driving a live model. Safe to delete
// once Phase 2 has been validated end-to-end.
const BLOCK_SAMPLES_CONTENT = `Here's one example of every rich-output block, in roughly the order a model would emit them.
A **kpi** strip for headline numbers:
\`\`\`kpi
{ "items": [
{ "label": "Tenants", "value": 42 },
{ "label": "Active users", "value": 318, "unit": "/day" },
{ "label": "Suspended", "value": 4 },
{ "label": "Storage", "value": "1.2", "unit": "TB" }
] }
\`\`\`
A **table** for tabular data:
\`\`\`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" },
{ "slug": "initech", "users": 73, "status": "active" }
],
"idKey": "slug" }
\`\`\`
A **chart-bar** for category comparison and a **chart-line** for a trend:
\`\`\`chart-bar
{ "title": "Users by tenant",
"data": [
{ "label": "acme", "value": 42 },
{ "label": "globex", "value": 18 },
{ "label": "initech", "value": 73 },
{ "label": "umbrella", "value": 11 }
] }
\`\`\`
\`\`\`chart-line
{ "title": "Signups over time",
"series": [
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 },
{ "x": 4, "y": 31 }, { "x": 5, "y": 28 }, { "x": 6, "y": 42 }
] }
\`\`\`
A **chart-donut** for part-to-whole and a **chart-spark** inline:
\`\`\`chart-donut
{ "title": "Status breakdown",
"data": [
{ "label": "active", "value": 38 },
{ "label": "suspended", "value": 4 },
{ "label": "deactivated", "value": 2 }
] }
\`\`\`
\`\`\`chart-spark
{ "values": [3, 5, 4, 8, 12, 9, 14, 11, 18, 16, 22] }
\`\`\`
A **code** block and a **diff**:
\`\`\`code
{ "code": "SELECT slug, count(*) AS users\\nFROM tenants t\\nJOIN users u ON u.tenant_id = t.id\\nWHERE t.status = 'active'\\nGROUP BY slug\\nORDER BY users DESC;",
"language": "sql",
"title": "Active tenants by user count",
"lineNumbers": true }
\`\`\`
\`\`\`diff
{ "oldCode": "max_users: 100\\nplan: free\\n",
"newCode": "max_users: 250\\nplan: pro\\n",
"language": "yaml",
"title": "Tenant quota change",
"mode": "unified" }
\`\`\`
A **flowchart** for control flow and an **orgchart** for hierarchy:
\`\`\`flowchart
{ "nodes": [
{ "id": "a", "type": "start", "label": "Receive request", "x": 80, "y": 20 },
{ "id": "b", "type": "process", "label": "Validate token", "x": 80, "y": 110 },
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 80, "y": 200 },
{ "id": "d", "type": "process", "label": "Process", "x": 260, "y": 200 },
{ "id": "e", "type": "end", "label": "Reject (401)", "x": 80, "y": 310 }
],
"edges": [
{ "from": "a", "to": "b" },
{ "from": "b", "to": "c" },
{ "from": "c", "to": "d", "label": "yes" },
{ "from": "c", "to": "e", "label": "no" }
] }
\`\`\`
\`\`\`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",
"children": [
{ "id": "b1", "name": "Invoices", "title": "Module" }
] }
] } }
\`\`\`
A **steps** trail for a multi-step plan:
\`\`\`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" },
{ "id": "4", "title": "Email summary", "status": "queued" }
] }
\`\`\`
A **welcome** hero, a **checklist**, and a **hint**:
\`\`\`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" } }
\`\`\`
\`\`\`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" }
] }
\`\`\`
\`\`\`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" } }
\`\`\`
And the legacy **card** kinds — pill, stat, callout:
\`\`\`card
{ "kind": "pill", "status": "active", "label": "active" }
\`\`\`
\`\`\`card
{ "kind": "stat", "label": "MRR", "value": "$12.4k" }
\`\`\`
\`\`\`card
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "Suspending a tenant blocks all of its users immediately." }
\`\`\`
Clear the conversation to dismiss the preview.`
const SNAPSHOT_KEY = "crema.ai.snapshot"
// Separate key for the live conversation that survives navigation. The
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
@@ -727,12 +900,21 @@ function ChatSurface({
return lines.join("\n")
}, [messages, activeAgent])
const toast = useToast()
const copyMarkdown = useCallback(async () => {
if (messages.length === 0) return
try {
await navigator.clipboard.writeText(buildTranscript())
} catch {}
}, [buildTranscript, messages.length])
toast.success("Copied as Markdown", {
description: `${messages.length} message${messages.length === 1 ? "" : "s"} on the clipboard.`,
})
} catch {
toast.error("Couldn't copy", {
description: "Clipboard access was blocked.",
})
}
}, [buildTranscript, messages.length, toast])
const exportMarkdown = useCallback(() => {
if (messages.length === 0) return
@@ -742,26 +924,30 @@ function ChatSurface({
const a = document.createElement("a")
a.href = url
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
a.download = `ai-${stamp}.md`
const filename = `ai-${stamp}.md`
a.download = filename
a.click()
URL.revokeObjectURL(url)
}, [buildTranscript, messages.length])
toast.success("Exported transcript", { description: filename })
}, [buildTranscript, messages.length, toast])
const saveToLibrary = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
const title =
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
"AI conversation"
addLibraryItem({
kind: "conversation",
title:
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
"AI conversation",
title,
content: md,
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
agentName: activeAgent?.name,
agentRole: activeAgent?.role,
messageCount: messages.length,
})
}, [buildTranscript, messages, activeAgent])
toast.success("Saved to Library", { description: title })
}, [buildTranscript, messages, activeAgent, toast])
const regenerateLast = useCallback(() => {
if (isStreaming) return
@@ -963,18 +1149,17 @@ function ChatSurface({
{/* Empty state — flight-recorder card with staggered reveal */}
<div
aria-hidden={!isEmpty}
className="pointer-events-none absolute inset-x-0 top-[14%] px-8 transition-opacity duration-300"
className="pointer-events-none absolute inset-x-0 top-[10%] px-8 transition-opacity duration-300"
style={{ opacity: isEmpty ? 1 : 0 }}
>
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<div className="console-empty-line console-mono flex items-center justify-between text-[10.5px] tracking-[0.18em] uppercase text-[var(--console-muted)]">
<span>arcadia // operator console</span>
<span>session {sessionLabel}</span>
</div>
<div className="console-empty-line h-px bg-[var(--console-rule-soft)]" />
<h1 className="console-empty-line console-empty-headline">
ATLAS<span className="text-[var(--console-amber)]">.</span>
<br />
ATLAS<span className="text-[var(--console-amber)]">.</span>{" "}
<em>standing&nbsp;by</em>
</h1>
<p className="console-empty-line console-mono max-w-[58ch] text-[13.5px] leading-[1.7] text-[var(--console-text-2)]">
@@ -982,6 +1167,19 @@ function ChatSurface({
Issue an instruction. Read tools run automatically. Writes pause for
confirmation. Tab&nbsp; for command palette.
</p>
<div className="console-empty-line pointer-events-auto">
<button
type="button"
onClick={() =>
setMessages([
{ role: "assistant", content: BLOCK_SAMPLES_CONTENT } as LLMMessage,
])
}
className="console-mono inline-flex items-center gap-1.5 rounded-md border border-[var(--console-rule-soft)] bg-transparent px-2.5 py-1 text-[10.5px] uppercase tracking-[0.18em] text-[var(--console-muted)] transition-colors hover:border-[var(--console-amber)] hover:text-[var(--console-amber)]"
>
preview rich-output blocks
</button>
</div>
</div>
</div>
@@ -1018,6 +1216,11 @@ function ChatSurface({
messageAgents.get(i)?.name ?? activeAgent?.name ?? "Atlas"
}
timestamp={clockLabel}
sources={
m.role === "assistant"
? extractDocSources(messages, i)
: undefined
}
/>
{calls.length > 0 && (
<div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
@@ -1270,6 +1473,61 @@ function truncateModel(m: string): string {
return m.slice(0, 10) + "…" + m.slice(-9)
}
/** Walk forward from an assistant message and collect doc-search hits from
* any matching `tool` result messages. Deduped by sourcePath so a chunk and
* its sibling chunk in the same file collapse to one citation. */
function extractDocSources(
messages: LLMMessage[],
assistantIdx: number,
): DocHit[] {
const msg = messages[assistantIdx]
if (msg?.role !== "assistant" || !msg.toolCalls?.length) return []
const docCallIds = new Set(
msg.toolCalls.filter((tc) => tc.name === "search_docs").map((tc) => tc.id),
)
if (docCallIds.size === 0) return []
const seen = new Set<string>()
const out: DocHit[] = []
for (let i = assistantIdx + 1; i < messages.length; i++) {
const m = messages[i]
if (m.role === "assistant") break // hit the next turn
if (m.role !== "tool" || !m.toolCallId || !docCallIds.has(m.toolCallId)) {
continue
}
try {
const parsed = JSON.parse(m.content) as { hits?: DocHit[] }
for (const h of parsed.hits ?? []) {
if (seen.has(h.sourcePath + "#" + h.id)) continue
seen.add(h.sourcePath + "#" + h.id)
out.push(h)
}
} catch {
// Tool errors come back as { error } — no hits to surface.
}
}
return out
}
function SourcesFooter({ sources }: { sources: DocHit[] }) {
if (sources.length === 0) return null
return (
<div className="console-mono mt-3 flex flex-wrap items-center gap-1.5 text-[10.5px] tracking-[0.08em] text-[var(--console-muted)]">
<span className="uppercase text-[var(--console-muted-2)]">sources</span>
<span className="text-[var(--console-muted-2)]"></span>
{sources.map((s) => (
<span
key={s.id}
title={`${s.sourcePath}\n\n${s.excerpt}`}
className="inline-flex max-w-[28ch] items-center gap-1 truncate rounded border border-[var(--console-rule-soft)] bg-[var(--console-deck)] px-1.5 py-0.5 text-[var(--console-text-2)]"
>
<span className="truncate">{s.title}</span>
</span>
))}
</div>
)
}
function MessageRow({
role,
content,
@@ -1277,6 +1535,7 @@ function MessageRow({
turnNum,
agentName,
timestamp,
sources,
}: {
role: "user" | "assistant"
content: string
@@ -1284,6 +1543,7 @@ function MessageRow({
turnNum?: number
agentName?: string
timestamp?: string
sources?: DocHit[]
}) {
// Operator turn — monospace, sodium-amber prompt, no bubble. The whole
// row hangs from a left gutter showing the turn number.
@@ -1327,6 +1587,7 @@ function MessageRow({
<div className="console-agent-prose">
<MessageBody content={content} toolCalls={toolCalls} />
</div>
{sources && sources.length > 0 && <SourcesFooter sources={sources} />}
<div className="console-sig mt-2 flex items-center gap-2">
<span className="console-sig-name">
{agentName?.toLowerCase() ?? "atlas"}»
@@ -1711,8 +1972,19 @@ function CommandsMenu({
hasCompactSnapshot: boolean
isMock: boolean
}) {
const [open, setOpen] = useState(false)
// Close the popover after a tile is clicked, so the menu acknowledges the
// action visually even when the action itself produces no obvious change
// (Copy MD, Export MD, etc — those also fire a toast at the call site).
const close = useCallback(
(fn: () => void) => () => {
fn()
setOpen(false)
},
[],
)
return (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button
@@ -1731,7 +2003,7 @@ function CommandsMenu({
<div className="grid grid-cols-2 gap-1">
<ToolTile
data-action="ai-cmd-regenerate"
onClick={onRegenerate}
onClick={close(onRegenerate)}
disabled={isStreaming || !hasUserMessage}
icon={<RotateCcw className="size-4" />}
label="Regenerate"
@@ -1739,7 +2011,7 @@ function CommandsMenu({
/>
<ToolTile
data-action="ai-cmd-continue"
onClick={onContinue}
onClick={close(onContinue)}
disabled={isStreaming || !hasMessages}
icon={<ArrowRight className="size-4" />}
label="Continue"
@@ -1747,7 +2019,7 @@ function CommandsMenu({
/>
<ToolTile
data-action="ai-cmd-compact"
onClick={onCompact}
onClick={close(onCompact)}
disabled={isCompacting || isStreaming || !hasMessages}
icon={
isCompacting ? (
@@ -1761,7 +2033,7 @@ function CommandsMenu({
/>
<ToolTile
data-action="ai-cmd-restore-compact"
onClick={onRestoreCompact}
onClick={close(onRestoreCompact)}
disabled={!hasCompactSnapshot}
icon={<Undo2 className="size-4" />}
label="Restore"
@@ -1777,7 +2049,7 @@ function CommandsMenu({
<div className="grid grid-cols-2 gap-1">
<ToolTile
data-action="ai-cmd-copy-md"
onClick={onCopyMarkdown}
onClick={close(onCopyMarkdown)}
disabled={!hasMessages}
icon={<Copy className="size-4" />}
label="Copy MD"
@@ -1785,7 +2057,7 @@ function CommandsMenu({
/>
<ToolTile
data-action="ai-cmd-export-md"
onClick={onExportMarkdown}
onClick={close(onExportMarkdown)}
disabled={!hasMessages}
icon={<Download className="size-4" />}
label="Export MD"
@@ -1793,7 +2065,7 @@ function CommandsMenu({
/>
<ToolTile
data-action="ai-cmd-save-library"
onClick={onSaveToLibrary}
onClick={close(onSaveToLibrary)}
disabled={!hasMessages}
icon={<BookmarkPlus className="size-4" />}
label="Save to Library"
@@ -1801,7 +2073,7 @@ function CommandsMenu({
/>
<ToolTile
data-action="ai-cmd-show-prompt"
onClick={onShowPrompt}
onClick={close(onShowPrompt)}
icon={<FileText className="size-4" />}
label="Show prompt"
title="Preview the system prompt"
@@ -1814,7 +2086,7 @@ function CommandsMenu({
<div className="grid grid-cols-2 gap-1">
<ToolTile
data-action="ai-cmd-retry-probe"
onClick={onRetryProbe}
onClick={close(onRetryProbe)}
icon={<RefreshCw className="size-4" />}
label="Reconnect"
title="Probe the LLM endpoint again"
@@ -1826,7 +2098,7 @@ function CommandsMenu({
<div className="my-2 h-px bg-border" />
<ToolTile
data-action="ai-cmd-clear"
onClick={onClear}
onClick={close(onClear)}
disabled={!hasMessages}
icon={<Trash2 className="size-4" />}
label="Clear conversation"
@@ -1851,7 +2123,8 @@ function SystemPromptDialog({
await navigator.clipboard.writeText(prompt)
} catch {}
}
return (
if (typeof document === "undefined") return null
return createPortal(
<div
role="dialog"
aria-modal="true"
@@ -1886,7 +2159,8 @@ function SystemPromptDialog({
{prompt}
</pre>
</div>
</div>
</div>,
document.body,
)
}

View File

@@ -31,6 +31,15 @@ const PROBE_TIMEOUT_MS = 3000
// "Available actions" in the system prompt only lists what's on screen NOW;
// this catalog tells the model what exists elsewhere so it can plan
// multi-step flows (navigate → wait_for → fill → click) in a single block.
// Rich-output protocol: typed fenced blocks the chat renderer turns into UI
// from @crema/*-ui. The system prompt only carries a thin INDEX (kind →
// one-line purpose) — full schemas live in app/lib/block-schemas.ts and are
// fetched on demand via the get_block_schema tool. Adding a new block kind
// = edit block-schemas.ts + the renderer; no prompt edit required.
import { blockIndexForPrompt } from "~/lib/block-schemas"
const RICH_OUTPUT_PREFACE = blockIndexForPrompt()
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
@@ -104,6 +113,7 @@ function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean):
const ctx = formatAdminContextForPrompt()
const parts = [
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
RICH_OUTPUT_PREFACE,
ARCADIA_KNOWLEDGE,
persona,
ctx,