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>
229 lines
8.3 KiB
TypeScript
229 lines
8.3 KiB
TypeScript
// 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 { 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
|
|
isToolResult?: boolean
|
|
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, cardBlocks } = useMemo(() => {
|
|
const blocks = extractActionBlocks(content)
|
|
const cleaned = stripToolCallTags(content)
|
|
.replace(ACTION_BLOCK_RE, "")
|
|
.trim()
|
|
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
|
|
return {
|
|
prose: stripped.trim(),
|
|
actionCount: blocks.length,
|
|
cardBlocks,
|
|
}
|
|
}, [content])
|
|
|
|
if (isToolResult) {
|
|
return (
|
|
<div className="rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs">
|
|
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
|
|
<Wrench className="size-3" />
|
|
Tool result
|
|
</div>
|
|
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[11px] leading-snug text-foreground/80">
|
|
{content}
|
|
</pre>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)}
|
|
{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"
|
|
title="Action block executed by the command bus"
|
|
>
|
|
<Sparkles className="size-3" />
|
|
Ran {actionCount} action{actionCount > 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
{toolCalls && toolCalls.length > 0 && (
|
|
<span
|
|
className="mt-1 ml-1 inline-flex items-center gap-1.5 rounded-full border border-amber-400/40 bg-amber-400/10 px-2.5 py-0.5 text-xs font-medium text-amber-700 dark:border-amber-300/60 dark:bg-amber-300/10 dark:text-amber-200"
|
|
title="Tool call dispatched"
|
|
>
|
|
<Wrench className="size-3" />
|
|
Called {toolCalls.map((c) => c.name).join(", ")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|