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,
)
}