Rich output rendering: GFM markdown, tool-result blocks, card blocks

Three layers:

1. GFM markdown — add remark-gfm so tables, task lists, strikethrough,
   autolinks render properly. Style table elements (overflow-aware
   container, muted header, divider rows). Render `[ ]` task list items
   as visible checkboxes.

2. Structured tool-result rendering — new `tool-result-renderers.tsx`
   dispatches by tool name to render a small UI block beneath each
   ToolCallCard:
   - list_tenants → table with status pills + plan column
   - get_tenant → tenant detail card
   - get_platform_stats → KPI tiles (total + per-status)
   - list_audit_log → timeline rows with actor_type + action
   - list_users → user list with role chips
   - suspend_tenant / activate_tenant → tenant card with action confirm
   ToolCallCard collapses by default — operators expand for raw JSON.

3. Custom ```card``` blocks the LLM can emit inline:
   - {"kind":"pill","status":"…"} — status pill
   - {"kind":"stat","label":"…","value":…} — stat tile
   - {"kind":"callout","tone":"info|warning|danger|success",…} — callout
   Malformed blocks fall through to the prose unchanged. Client strips
   well-formed blocks from prose and renders them as components.

Domain primer updated to teach the model the card schemas and remind it
NOT to re-render tool-result data as markdown tables (that's done
automatically — it should add commentary only).

Layers are independent: 1 + 2 always work; 3 is purely additive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-01 20:39:06 +10:00
parent b9a163c7cc
commit 45fa130951
6 changed files with 758 additions and 12 deletions

View File

@@ -1,31 +1,120 @@
// Renders an assistant message: markdown for prose, plus pills for any
// command-bus action blocks or native tool calls attached to the message.
// Tool-role messages (results from a function call) render as a JSON card.
// 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").
import { useMemo } from "react"
import { type ReactNode, useMemo } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { Sparkles, Wrench } from "lucide-react"
import { extractActionBlocks } from "@crema/action-bus"
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
const CARD_BLOCK_RE = /```card\s*\n([\s\S]*?)```/g
export type MessageBodyProps = {
content: string
/** When set, render as a tool-result card. */
isToolResult?: boolean
/** Native tool calls attached to this assistant message, if any. */
toolCalls?: ToolCall[]
}
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 {
switch (spec.kind) {
case "pill": {
const s = spec as { kind: "pill"; status: string; label?: string }
const tone =
s.status === "active"
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: s.status === "suspended"
? "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300"
: s.status === "deactivated"
? "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}`}
>
{s.label ?? s.status}
</span>
)
}
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="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 tone = s.tone ?? "info"
const palette: Record<string, string> = {
info: "border-sky-500/40 bg-sky-500/10",
warning: "border-amber-500/40 bg-amber-500/10",
danger: "border-rose-500/40 bg-rose-500/10",
success: "border-emerald-500/40 bg-emerald-500/10",
}
return (
<div className={`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>
)
}
default:
return (
<pre className="rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
{JSON.stringify(spec, null, 2)}
</pre>
)
}
}
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
const { prose, actionCount } = useMemo(() => {
const { prose, actionCount, cardBlocks } = useMemo(() => {
const blocks = extractActionBlocks(content)
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "").trim()
const cleaned = stripToolCallTags(content)
.replace(ACTION_BLOCK_RE, "")
.trim()
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
return {
prose: cleaned,
prose: stripped.trim(),
actionCount: blocks.length,
cardBlocks,
}
}, [content])
@@ -47,6 +136,7 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
<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 }) => {
@@ -68,15 +158,53 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
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">
<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>
)}
{actionCount > 0 && (
<span
className="mt-1 inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary dark:border-sky-400/60 dark:bg-sky-400/15 dark:text-sky-200"

View File

@@ -0,0 +1,303 @@
// Per-tool rich renderers for assistant tool results. Dispatched by tool
// name in `MessageBody`. Each renderer receives the parsed result object
// and produces a small UI block. When the tool isn't recognised here we
// fall back to a JSON dump (handled by the caller).
import type { ReactNode } from "react"
import {
ArrowUpRight,
CheckCircle2,
CircleSlash,
PauseCircle,
Sparkles,
User2,
Building2,
} from "lucide-react"
type Status = "active" | "suspended" | "deactivated" | string
const statusStyle: Record<string, { bg: string; fg: string; icon: ReactNode }> = {
active: {
bg: "bg-emerald-500/15 border-emerald-500/40",
fg: "text-emerald-700 dark:text-emerald-300",
icon: <CheckCircle2 className="size-3" />,
},
suspended: {
bg: "bg-amber-500/15 border-amber-500/40",
fg: "text-amber-700 dark:text-amber-300",
icon: <PauseCircle className="size-3" />,
},
deactivated: {
bg: "bg-rose-500/15 border-rose-500/40",
fg: "text-rose-700 dark:text-rose-300",
icon: <CircleSlash className="size-3" />,
},
}
function StatusPill({ status }: { status: Status }) {
const s = statusStyle[status] ?? {
bg: "bg-muted border-border",
fg: "text-muted-foreground",
icon: null,
}
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${s.bg} ${s.fg}`}
>
{s.icon}
{status}
</span>
)
}
interface TenantRow {
id?: string
slug: string
name: string
status: string
plan?: string | null
inserted_at?: string
}
function TenantsTable({ rows }: { rows: TenantRow[] }) {
if (rows.length === 0) {
return <div className="text-sm text-muted-foreground">No tenants.</div>
}
return (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Tenant</th>
<th className="px-3 py-2 text-left font-medium">Slug</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Plan</th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((t) => (
<tr key={t.id ?? t.slug} className="hover:bg-muted/30">
<td className="px-3 py-2 font-medium">{t.name}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{t.slug}
</td>
<td className="px-3 py-2">
<StatusPill status={t.status} />
</td>
<td className="px-3 py-2 text-muted-foreground">{t.plan ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function TenantCard({ t }: { t: TenantRow }) {
return (
<div className="rounded-md border bg-card p-3 text-sm">
<div className="mb-2 flex items-center gap-2">
<Building2 className="size-4 text-muted-foreground" />
<span className="font-medium">{t.name}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
{t.slug}
</code>
<span className="ml-auto">
<StatusPill status={t.status} />
</span>
</div>
<dl className="grid grid-cols-2 gap-y-1 text-xs">
<dt className="text-muted-foreground">Plan</dt>
<dd>{t.plan ?? "—"}</dd>
{t.inserted_at && (
<>
<dt className="text-muted-foreground">Created</dt>
<dd>{new Date(t.inserted_at).toLocaleDateString()}</dd>
</>
)}
</dl>
</div>
)
}
interface PlatformStats {
tenants_total: number
tenants_by_status: Record<string, number>
tenants_by_plan: Record<string, number>
}
function PlatformStatsBlock({ stats }: { stats: PlatformStats }) {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<KpiTile label="Tenants" value={stats.tenants_total} icon={<Building2 className="size-3.5" />} />
{Object.entries(stats.tenants_by_status).map(([status, n]) => (
<KpiTile key={status} label={status} value={n} icon={<StatusPill status={status} />} compact />
))}
</div>
)
}
function KpiTile({
label,
value,
icon,
compact,
}: {
label: string
value: number | string
icon?: ReactNode
compact?: boolean
}) {
return (
<div className="rounded-md border bg-card px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{!compact && icon}
<span>{label}</span>
</div>
<div className="mt-1 flex items-center gap-1.5 text-2xl font-semibold tabular-nums">
{compact && icon}
{value}
</div>
</div>
)
}
interface AuditEntry {
actor_type: string
actor_id?: string | null
action: string
target?: string | null
inserted_at: string
}
function AuditLogList({ entries }: { entries: AuditEntry[] }) {
if (entries.length === 0) {
return <div className="text-sm text-muted-foreground">No audit entries.</div>
}
return (
<ul className="space-y-1.5">
{entries.map((e, i) => (
<li
key={i}
className="flex items-start gap-2 rounded-md border bg-card px-3 py-2 text-xs"
>
<ArrowUpRight className="mt-0.5 size-3.5 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
{e.actor_type}
</span>
<span className="font-medium">{e.action}</span>
{e.target && (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
{e.target}
</code>
)}
</div>
<div className="text-[11px] text-muted-foreground">
{new Date(e.inserted_at).toLocaleString()}
</div>
</div>
</li>
))}
</ul>
)
}
interface UserRow {
id: string
email: string
name?: string | null
roles?: string[]
verified?: boolean | null
inserted_at?: string
}
function UsersList({ users }: { users: UserRow[] }) {
if (users.length === 0) {
return <div className="text-sm text-muted-foreground">No users.</div>
}
return (
<ul className="space-y-1.5">
{users.map((u) => (
<li
key={u.id}
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2 text-sm"
>
<User2 className="size-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{u.name || u.email}</span>
{u.verified === false && (
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">
unverified
</span>
)}
</div>
{u.name && (
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
{(u.roles ?? []).map((r) => (
<span
key={r}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{r}
</span>
))}
</div>
</li>
))}
</ul>
)
}
/**
* Dispatch by tool name. Returns null when no rich renderer applies — the
* caller falls back to a JSON dump (already part of ToolCallCard).
*/
export function renderToolResult(
toolName: string,
result: unknown,
): ReactNode | null {
if (result == null) return null
switch (toolName) {
case "list_tenants":
if (Array.isArray(result)) return <TenantsTable rows={result as TenantRow[]} />
return null
case "get_tenant":
if (typeof result === "object" && (result as TenantRow).slug) {
return <TenantCard t={result as TenantRow} />
}
return null
case "get_platform_stats":
if (typeof result === "object" && result && "tenants_total" in result) {
return <PlatformStatsBlock stats={result as PlatformStats} />
}
return null
case "list_audit_log":
if (Array.isArray(result)) return <AuditLogList entries={result as AuditEntry[]} />
return null
case "list_users":
if (Array.isArray(result)) return <UsersList users={result as UserRow[]} />
return null
case "suspend_tenant":
case "activate_tenant":
if (typeof result === "object" && (result as TenantRow).slug) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-300">
<Sparkles className="size-3" />
{toolName === "suspend_tenant" ? "Suspended" : "Activated"}
</div>
<TenantCard t={result as TenantRow} />
</div>
)
}
return null
default:
return null
}
}