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:
303
app/components/assistant/tool-result-renderers.tsx
Normal file
303
app/components/assistant/tool-result-renderers.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user