From 9cbe921db7ac8fe15c1816c3db3dd2024d9f597d Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 22:47:36 +1000 Subject: [PATCH] ai: rich-output blocks via lazy-fetched typed-fence protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/app.css | 5 + app/components/assistant/message-body.tsx | 432 ++++++++++++++++------ app/lib/admin-tools.ts | 67 ++++ app/lib/block-schemas.ts | 247 +++++++++++++ app/routes/ai.tsx | 324 ++++++++++++++-- app/routes/assistant.tsx | 10 + app/themes/console.css | 16 +- tsconfig.json | 8 + 8 files changed, 966 insertions(+), 143 deletions(-) create mode 100644 app/lib/block-schemas.ts diff --git a/app/app.css b/app/app.css index 57259bc..12f2064 100644 --- a/app/app.css +++ b/app/app.css @@ -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 *)); diff --git a/app/components/assistant/message-body.tsx b/app/components/assistant/message-body.tsx index 03aa7c0..913a58d 100644 --- a/app/components/assistant/message-body.tsx +++ b/app/components/assistant/message-body.tsx @@ -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 ```\n\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 + case "chart-spark": + return ( +
+ +
+ ) + case "chart-bar": + return ( + +
+ +
+ +
+ ) + case "chart-line": + return ( + +
+ +
+
+ ) + case "chart-donut": + return ( + +
+
+ +
+ +
+
+ ) + case "table": + return + case "kpi": + return ( +
+ +
+ ) + case "code": + return ( +
+ +
+ ) + case "diff": + return ( +
+ +
+ ) + case "flowchart": + return ( +
+ +
+ ) + case "orgchart": + return ( +
+ +
+ ) + case "steps": + return ( +
+ +
+ ) + case "checklist": + return ( +
+ +
+ ) + case "welcome": + return ( +
+ +
+ ) + case "hint": + return ( +
+ + {spec.body ?? ""} + +
+ ) + default: + return ( +
+          {JSON.stringify(spec, null, 2)}
+        
+ ) + } +} + +function ChartFrame({ title, children }: { title?: string; children: ReactNode }) { + return ( +
+ {title &&
{title}
} + {children} +
+ ) +} + +function ChartLegend({ data }: { data?: ChartDatum[] }) { + if (!data || data.length === 0) return null + return ( +
    + {data.map((d) => ( +
  • + + {d.label} + {d.value} +
  • + ))} +
+ ) +} + +function TableBlock({ spec }: { spec: any }) { + const rows: Record[] = Array.isArray(spec.rows) ? spec.rows : [] + const columns: Column>[] = (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 ( +
+ String(r[idKey] ?? Math.random())} + density="compact" + /> +
+ ) +} + 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 ( - + {s.label ?? s.status} ) @@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode { case "stat": { const s = spec as { kind: "stat"; label: string; value: string | number } return ( - + {s.label} {s.value} ) } 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 = { 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 ( -
+
{s.title &&
{s.title}
} {s.body &&
{s.body}
}
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode { } default: return ( -
+        
           {JSON.stringify(spec, null, 2)}
         
) } } +const PROSE_COMPONENTS = { + p: ({ children }: any) =>

{children}

, + code: ({ children, className }: any) => { + const isBlock = className?.startsWith("language-") + if (isBlock) { + return ( +
+          {children}
+        
+ ) + } + return {children} + }, + ul: ({ children }: any) =>
    {children}
, + ol: ({ children }: any) =>
    {children}
, + li: ({ children }: any) =>
  • {children}
  • , + a: ({ children, href }: any) => ( + + {children} + + ), + table: ({ children }: any) => ( +
    + {children}
    +
    + ), + thead: ({ children }: any) => {children}, + th: ({ children }: any) => {children}, + td: ({ children }: any) => {children}, + input: ({ checked, type, ...rest }: any) => + type === "checkbox" ? ( + + ) : ( + + ), +} + +function ProseChunk({ text }: { text: string }) { + const trimmed = text.trim() + if (!trimmed) return null + return ( + + {trimmed} + + ) +} + 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 (
    - {prose && ( -

    {children}

    , - code: ({ children, className }) => { - const isBlock = className?.startsWith("language-") - if (isBlock) { - return ( -
    -                    {children}
    -                  
    - ) - } - return ( - - {children} - - ) - }, - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - a: ({ children, href }) => ( - - {children} - - ), - table: ({ children }) => ( -
    - {children}
    -
    - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - {children} - ), - td: ({ children }) => {children}, - input: ({ checked, type, ...rest }) => - type === "checkbox" ? ( - - ) : ( - - ), - }} - > - {prose} -
    - )} - {cardBlocks.length > 0 && ( -
    - {cardBlocks.map((spec, i) => ( - - {renderCard(spec)} - - ))} -
    + {segments.map((seg, i) => + seg.type === "prose" ? : renderBlock(seg.kind, seg.spec, i), )} {actionCount > 0 && ( { + 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 { diff --git a/app/lib/block-schemas.ts b/app/lib/block-schemas.ts new file mode 100644 index 0000000..4fe5633 --- /dev/null +++ b/app/lib/block-schemas.ts @@ -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 = { + 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 = { + 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 ```\\n\\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") +} diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index a44b348..5b43392 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -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
    {rich}
    } +// 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 */}
    -
    +
    arcadia // operator console session {sessionLabel}

    - ATLAS. -
    + ATLAS.{" "} standing by

    @@ -982,6 +1167,19 @@ function ChatSurface({ Issue an instruction. Read tools run automatically. Writes pause for confirmation. Tab ⇥ for command palette.

    +
    + +
    @@ -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 && (
    @@ -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() + 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 ( +
    + sources + + {sources.map((s) => ( + + {s.title} + + ))} +
    + ) +} + 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({
    + {sources && sources.length > 0 && }
    {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 ( - + } label="Regenerate" @@ -1739,7 +2011,7 @@ function CommandsMenu({ /> } label="Continue" @@ -1747,7 +2019,7 @@ function CommandsMenu({ /> } label="Restore" @@ -1777,7 +2049,7 @@ function CommandsMenu({
    } label="Copy MD" @@ -1785,7 +2057,7 @@ function CommandsMenu({ /> } label="Export MD" @@ -1793,7 +2065,7 @@ function CommandsMenu({ /> } label="Save to Library" @@ -1801,7 +2073,7 @@ function CommandsMenu({ /> } label="Show prompt" title="Preview the system prompt" @@ -1814,7 +2086,7 @@ function CommandsMenu({
    } label="Reconnect" title="Probe the LLM endpoint again" @@ -1826,7 +2098,7 @@ function CommandsMenu({
    } label="Clear conversation" @@ -1851,7 +2123,8 @@ function SystemPromptDialog({ await navigator.clipboard.writeText(prompt) } catch {} } - return ( + if (typeof document === "undefined") return null + return createPortal(
    -
    +
    , + document.body, ) } diff --git a/app/routes/assistant.tsx b/app/routes/assistant.tsx index c4fe8f6..3e59e52 100644 --- a/app/routes/assistant.tsx +++ b/app/routes/assistant.tsx @@ -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, diff --git a/app/themes/console.css b/app/themes/console.css index dc47e68..95f8063 100644 --- a/app/themes/console.css +++ b/app/themes/console.css @@ -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); diff --git a/tsconfig.json b/tsconfig.json index 8d47a0a..0d09a40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/*"],