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

@@ -29,6 +29,11 @@
@source "../../lib-chart-ui/src";
@source "../../lib-map-ui/src";
@source "../../lib-status-ui/src";
@source "../../lib-data-ui/src";
@source "../../lib-code-ui/src";
@source "../../lib-diagram-ui/src";
@source "../../lib-onboarding-ui/src";
@source "../../lib-notification-ui/src";
/* CREMA:SOURCES */
@custom-variant dark (&:is(.dark *));

View File

@@ -1,6 +1,20 @@
// Renders an assistant message: GFM markdown for prose, custom ```card```
// blocks rendered as rich UI (status pills, tenant cards, KPIs), pills for
// command-bus action blocks, and tool-result cards (role: "tool").
// Renders an assistant message: GFM markdown for prose, plus typed fenced
// code blocks rendered as rich UI from `@crema/*-ui` libs (charts, tables,
// KPIs, code, diffs, status pills, callouts), pills for command-bus action
// blocks, and tool-result cards (role: "tool").
//
// Typed blocks recognized (each is a fenced ```<kind>\n<json>\n``` block):
// action — command-bus DSL (handled by extractActionBlocks; replaced
// with a "Ran N actions" pill)
// card — { kind: "pill" | "stat" | "callout", ... } (legacy)
// chart-spark — { values: number[], stroke?, fill? }
// chart-bar — { data: [{ label, value, color? }] }
// chart-line — { series: [{ x, y }] }
// chart-donut — { data: [{ label, value, color? }] }
// table — { columns: [{ id, header, accessor? }], rows: [...] }
// kpi — { items: [{ label, value, unit? }] }
// code — { code, language?, title?, lineNumbers? }
// diff — { oldCode, newCode, language?, title? }
import { type ReactNode, useMemo } from "react"
import ReactMarkdown from "react-markdown"
@@ -9,9 +23,33 @@ import { Sparkles, Wrench } from "lucide-react"
import { extractActionBlocks } from "@crema/action-bus"
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
import {
Sparkline,
BarChart,
LineChart,
Donut,
type ChartDatum,
type SeriesPoint,
} from "@crema/chart-ui"
import { DataTable, type Column } from "@crema/table-ui"
import { KPIRow } from "@crema/data-ui"
import { CodeBlock, DiffViewer } from "@crema/code-ui"
import { FlowChart, OrgChart } from "@crema/diagram-ui"
import { StepTrail, type AgentStep } from "@crema/agent-ui"
import {
OnboardingChecklist,
WelcomeCard,
HintCard,
type ChecklistTask,
type OnboardingTone,
} from "@crema/onboarding-ui"
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
const CARD_BLOCK_RE = /```card\s*\n([\s\S]*?)```/g
// Captures the kind tag and the body of every fenced block we render as UI.
// Kept alongside the markdown ones — react-markdown ignores anything we strip.
const TYPED_BLOCK_RE =
/```(card|chart-spark|chart-bar|chart-line|chart-donut|table|kpi|code|diff|flowchart|orgchart|steps|checklist|welcome|hint)\s*\n([\s\S]*?)```/g
export type MessageBodyProps = {
content: string
@@ -19,33 +57,231 @@ export type MessageBodyProps = {
toolCalls?: ToolCall[]
}
type Segment =
| { type: "prose"; text: string }
| { type: "block"; kind: string; spec: unknown; raw: string }
function parseSegments(content: string): Segment[] {
const segments: Segment[] = []
TYPED_BLOCK_RE.lastIndex = 0
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = TYPED_BLOCK_RE.exec(content)) !== null) {
const [raw, kind, body] = match
if (match.index > lastIndex) {
segments.push({ type: "prose", text: content.slice(lastIndex, match.index) })
}
let spec: unknown = null
try {
spec = JSON.parse(body.trim())
} catch {
// malformed → emit the raw fence as prose so the user sees the model output
segments.push({ type: "prose", text: raw })
lastIndex = match.index + raw.length
continue
}
segments.push({ type: "block", kind, spec, raw })
lastIndex = match.index + raw.length
}
if (lastIndex < content.length) {
segments.push({ type: "prose", text: content.slice(lastIndex) })
}
return segments
}
function renderBlock(kind: string, spec: any, key: number): ReactNode {
switch (kind) {
case "card":
return <CardBlock key={key} spec={spec} />
case "chart-spark":
return (
<div key={key} className="my-2 inline-block text-primary">
<Sparkline
values={spec.values ?? []}
width={spec.width ?? 240}
height={spec.height ?? 48}
stroke={spec.stroke ?? "currentColor"}
fill={spec.fill}
/>
</div>
)
case "chart-bar":
return (
<ChartFrame key={key} title={spec.title}>
<div className="text-primary">
<BarChart data={(spec.data ?? []) as ChartDatum[]} width={spec.width ?? 360} height={spec.height ?? 180} />
</div>
<ChartLegend data={spec.data} />
</ChartFrame>
)
case "chart-line":
return (
<ChartFrame key={key} title={spec.title}>
<div className="text-primary">
<LineChart series={(spec.series ?? []) as SeriesPoint[]} width={spec.width ?? 360} height={spec.height ?? 180} />
</div>
</ChartFrame>
)
case "chart-donut":
return (
<ChartFrame key={key} title={spec.title}>
<div className="flex items-center gap-4">
<div className="text-primary">
<Donut data={(spec.data ?? []) as ChartDatum[]} size={spec.size ?? 140} thickness={spec.thickness ?? 20} />
</div>
<ChartLegend data={spec.data} />
</div>
</ChartFrame>
)
case "table":
return <TableBlock key={key} spec={spec} />
case "kpi":
return (
<div key={key} className="my-3">
<KPIRow items={spec.items ?? []} />
</div>
)
case "code":
return (
<div key={key} className="my-3">
<CodeBlock
code={spec.code ?? ""}
language={spec.language}
title={spec.title}
showLineNumbers={spec.lineNumbers ?? false}
highlightLines={spec.highlightLines}
/>
</div>
)
case "diff":
return (
<div key={key} className="my-3">
<DiffViewer
oldCode={spec.oldCode ?? ""}
newCode={spec.newCode ?? ""}
language={spec.language}
title={spec.title}
mode={spec.mode ?? "unified"}
/>
</div>
)
case "flowchart":
return (
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
<FlowChart nodes={spec.nodes ?? []} edges={spec.edges ?? []} />
</div>
)
case "orgchart":
return (
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
<OrgChart data={spec.data} horizontal={spec.horizontal} />
</div>
)
case "steps":
return (
<div key={key} className="my-3 rounded-lg border bg-card/50 p-3">
<StepTrail steps={(spec.steps ?? []) as AgentStep[]} />
</div>
)
case "checklist":
return (
<div key={key} className="my-3">
<OnboardingChecklist
title={spec.title}
description={spec.description}
tasks={(spec.tasks ?? []) as ChecklistTask[]}
/>
</div>
)
case "welcome":
return (
<div key={key} className="my-3">
<WelcomeCard
title={spec.title}
description={spec.description}
badge={spec.badge}
primaryAction={spec.primaryAction}
secondaryAction={spec.secondaryAction}
/>
</div>
)
case "hint":
return (
<div key={key} className="my-3">
<HintCard
title={spec.title}
tone={(spec.tone ?? "info") as OnboardingTone}
action={spec.action}
>
{spec.body ?? ""}
</HintCard>
</div>
)
default:
return (
<pre key={key} className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
{JSON.stringify(spec, null, 2)}
</pre>
)
}
}
function ChartFrame({ title, children }: { title?: string; children: ReactNode }) {
return (
<div className="my-3 rounded-lg border bg-card/50 p-3">
{title && <div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>}
{children}
</div>
)
}
function ChartLegend({ data }: { data?: ChartDatum[] }) {
if (!data || data.length === 0) return null
return (
<ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{data.map((d) => (
<li key={d.label} className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-sm"
style={{ background: d.color ?? "currentColor" }}
/>
<span>{d.label}</span>
<span className="tabular-nums text-foreground/70">{d.value}</span>
</li>
))}
</ul>
)
}
function TableBlock({ spec }: { spec: any }) {
const rows: Record<string, unknown>[] = Array.isArray(spec.rows) ? spec.rows : []
const columns: Column<Record<string, unknown>>[] = (spec.columns ?? []).map((c: any) => ({
id: c.id,
header: c.header ?? c.id,
accessor: c.accessor ?? c.id,
sortable: c.sortable ?? true,
align: c.align,
}))
const idKey = spec.idKey ?? columns[0]?.id ?? "id"
return (
<div className="my-3 overflow-x-auto rounded-lg border bg-card/50">
<DataTable
columns={columns}
rows={rows}
getRowId={(r) => String(r[idKey] ?? Math.random())}
density="compact"
/>
</div>
)
}
type CardSpec =
| { kind: "pill"; status: string; label?: string }
| { kind: "stat"; label: string; value: string | number; tone?: string }
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
| { kind: string; [k: string]: unknown }
function parseCardBlocks(content: string): { blocks: CardSpec[]; stripped: string } {
const blocks: CardSpec[] = []
CARD_BLOCK_RE.lastIndex = 0
const stripped = content.replace(CARD_BLOCK_RE, (_, body: string) => {
try {
const parsed = JSON.parse(body.trim()) as CardSpec
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
blocks.push(parsed)
return "" // strip from prose
}
} catch {
// malformed — leave the original block in the prose so the user can see
// what the model tried to emit.
return _
}
return _
})
return { blocks, stripped }
}
function renderCard(spec: CardSpec): ReactNode {
function CardBlock({ spec }: { spec: CardSpec }) {
switch (spec.kind) {
case "pill": {
const s = spec as { kind: "pill"; status: string; label?: string }
@@ -58,9 +294,7 @@ function renderCard(spec: CardSpec): ReactNode {
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
: "border-border bg-muted text-muted-foreground"
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}
>
<span className={`my-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
{s.label ?? s.status}
</span>
)
@@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode {
case "stat": {
const s = spec as { kind: "stat"; label: string; value: string | number }
return (
<span className="inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
<span className="my-1 inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
<span className="text-xs text-muted-foreground">{s.label}</span>
<span className="font-semibold tabular-nums">{s.value}</span>
</span>
)
}
case "callout": {
const s = spec as {
kind: "callout"
title?: string
tone?: "info" | "warning" | "danger" | "success"
body?: string
}
const s = spec as { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
const tone = s.tone ?? "info"
const palette: Record<string, string> = {
info: "border-sky-500/40 bg-sky-500/10",
@@ -89,7 +318,7 @@ function renderCard(spec: CardSpec): ReactNode {
success: "border-emerald-500/40 bg-emerald-500/10",
}
return (
<div className={`rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
<div className={`my-2 rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
{s.body && <div className="text-muted-foreground">{s.body}</div>}
</div>
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode {
}
default:
return (
<pre className="rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
<pre className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
{JSON.stringify(spec, null, 2)}
</pre>
)
}
}
const PROSE_COMPONENTS = {
p: ({ children }: any) => <p className="my-1.5 leading-relaxed">{children}</p>,
code: ({ children, className }: any) => {
const isBlock = className?.startsWith("language-")
if (isBlock) {
return (
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
<code className="font-mono">{children}</code>
</pre>
)
}
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
},
ul: ({ children }: any) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
ol: ({ children }: any) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
li: ({ children }: any) => <li className="my-0.5">{children}</li>,
a: ({ children, href }: any) => (
<a href={href} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">
{children}
</a>
),
table: ({ children }: any) => (
<div className="my-2 overflow-x-auto rounded-md border">
<table className="w-full text-sm">{children}</table>
</div>
),
thead: ({ children }: any) => <thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>,
th: ({ children }: any) => <th className="px-3 py-2 text-left font-medium">{children}</th>,
td: ({ children }: any) => <td className="border-t px-3 py-2">{children}</td>,
input: ({ checked, type, ...rest }: any) =>
type === "checkbox" ? (
<input type="checkbox" checked={!!checked} readOnly {...rest} className="mr-1.5 align-middle" />
) : (
<input type={type} {...rest} />
),
}
function ProseChunk({ text }: { text: string }) {
const trimmed = text.trim()
if (!trimmed) return null
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={PROSE_COMPONENTS}>
{trimmed}
</ReactMarkdown>
)
}
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
const { prose, actionCount, cardBlocks } = useMemo(() => {
const { segments, actionCount } = useMemo(() => {
const blocks = extractActionBlocks(content)
const cleaned = stripToolCallTags(content)
.replace(ACTION_BLOCK_RE, "")
.trim()
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "")
return {
prose: stripped.trim(),
segments: parseSegments(cleaned),
actionCount: blocks.length,
cardBlocks,
}
}, [content])
@@ -134,76 +406,8 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
return (
<div className="prose prose-sm max-w-none dark:prose-invert">
{prose && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
code: ({ children, className }) => {
const isBlock = className?.startsWith("language-")
if (isBlock) {
return (
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
<code className="font-mono">{children}</code>
</pre>
)
}
return (
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
)
},
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
ol: ({ children }) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
a: ({ children, href }) => (
<a
href={href}
className="text-primary underline underline-offset-2"
target="_blank"
rel="noreferrer"
>
{children}
</a>
),
table: ({ children }) => (
<div className="my-2 overflow-x-auto rounded-md border">
<table className="w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>
),
th: ({ children }) => (
<th className="px-3 py-2 text-left font-medium">{children}</th>
),
td: ({ children }) => <td className="border-t px-3 py-2">{children}</td>,
input: ({ checked, type, ...rest }) =>
type === "checkbox" ? (
<input
type="checkbox"
checked={!!checked}
readOnly
{...rest}
className="mr-1.5 align-middle"
/>
) : (
<input type={type} {...rest} />
),
}}
>
{prose}
</ReactMarkdown>
)}
{cardBlocks.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
{cardBlocks.map((spec, i) => (
<span key={i} className={spec.kind === "callout" ? "block w-full" : ""}>
{renderCard(spec)}
</span>
))}
</div>
{segments.map((seg, i) =>
seg.type === "prose" ? <ProseChunk key={i} text={seg.text} /> : renderBlock(seg.kind, seg.spec, i),
)}
{actionCount > 0 && (
<span

View File

@@ -15,6 +15,8 @@ import {
suspendTenant,
type Tenant,
} from "~/lib/arcadia/tenants"
import { searchDocs } from "~/lib/docs-search"
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
export type ToolCall = {
name: string
@@ -221,6 +223,71 @@ const TOOLS: ToolDef[] = [
return summarize(updated)
},
},
{
name: "search_docs",
description:
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.",
},
limit: {
type: "integer",
description: "Max passages to return. Default 5, cap 10.",
minimum: 1,
maximum: 10,
},
},
required: ["query"],
additionalProperties: false,
},
isWrite: false,
run: async (args) => {
const query = typeof args.query === "string" ? args.query.trim() : ""
if (!query) throw new Error("search_docs requires a non-empty { query }")
const limit = Math.min(
10,
Math.max(1, typeof args.limit === "number" ? args.limit : 5),
)
const hits = await searchDocs(query, limit)
return { query, count: hits.length, hits }
},
},
{
name: "get_block_schema",
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
BLOCK_INDEX,
)
.map(([k, v]) => `${k} (${v})`)
.join(", ")}.`,
parameters: {
type: "object",
properties: {
kind: {
type: "string",
description: "The block kind to fetch the schema for.",
enum: Object.keys(BLOCK_INDEX),
},
},
required: ["kind"],
additionalProperties: false,
},
isWrite: false,
run: async (args) => {
const kind = typeof args.kind === "string" ? args.kind : ""
const schema = getBlockSchema(kind)
if (!schema) {
return {
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
}
}
return { kind, schema }
},
},
]
interface AuditEntry {

247
app/lib/block-schemas.ts Normal file
View 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 (26 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 26 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 ~80120px 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")
}

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,

View File

@@ -187,10 +187,10 @@
/* Empty-state oversize text — letter-spacing tracking is the whole point */
[data-theme="console"] .console-empty-headline {
font-family: var(--console-font-mono);
font-size: clamp(2.25rem, 5.5vw, 4.5rem);
font-size: clamp(1.5rem, 3.2vw, 2.5rem);
font-weight: 500;
letter-spacing: 0.02em;
line-height: 0.95;
line-height: 1;
color: var(--console-text);
}
@@ -325,10 +325,18 @@
color: var(--console-muted-2);
}
/* Header strip — session card */
/* Header strip — session card. Solid background so messages scrolling past
* don't bleed through the sticky bar. */
[data-theme="console"] .console-header {
border-bottom: 1px solid var(--console-rule-soft);
background: linear-gradient(to bottom, oklch(0.16 0.02 240 / 0.6), transparent);
background: var(--console-ink);
-webkit-backdrop-filter: blur(12px) saturate(140%);
backdrop-filter: blur(12px) saturate(140%);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
[data-theme="console"] .console-header {
background: oklch(0.13 0.02 240 / 0.82);
}
}
[data-theme="console"] .console-session-id {
font-family: var(--console-font-mono);

View File

@@ -54,6 +54,14 @@
"@crema/map-ui/*": ["../lib-map-ui/src/*"],
"@crema/status-ui": ["../lib-status-ui/src/index.tsx"],
"@crema/status-ui/*": ["../lib-status-ui/src/*"],
"@crema/data-ui": ["../lib-data-ui/src/index.tsx"],
"@crema/data-ui/*": ["../lib-data-ui/src/*"],
"@crema/code-ui": ["../lib-code-ui/src/index.tsx"],
"@crema/code-ui/*": ["../lib-code-ui/src/*"],
"@crema/diagram-ui": ["../lib-diagram-ui/src/index.tsx"],
"@crema/diagram-ui/*": ["../lib-diagram-ui/src/*"],
"@crema/onboarding-ui": ["../lib-onboarding-ui/src/index.tsx"],
"@crema/onboarding-ui/*": ["../lib-onboarding-ui/src/*"],
"// CREMA:PATHS": [""],
"react": ["./node_modules/@types/react"],
"react/*": ["./node_modules/@types/react/*"],