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:
@@ -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"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,12 @@ Things to keep in mind when assisting:
|
||||
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
|
||||
- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`).
|
||||
|
||||
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. Write tools (suspend_tenant, activate_tenant) prompt the operator with an inline confirm card before they actually run — you do not need to ask in prose first; just call the tool and the user will see the confirmation UI. If the user denies a write, do not retry it; ask what they'd like to do differently.`
|
||||
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. Write tools (suspend_tenant, activate_tenant) prompt the operator with an inline confirm card before they actually run — you do not need to ask in prose first; just call the tool and the user will see the confirmation UI. If the user denies a write, do not retry it; ask what they'd like to do differently.
|
||||
|
||||
Rich output (optional, use when it helps):
|
||||
- You may use full GitHub-Flavoured Markdown — tables, task lists, code fences, etc.
|
||||
- For inline UI accents, you may emit fenced \`\`\`card\`\`\` blocks containing one JSON object. The client renders them as small components and strips the block from your prose. Schemas:
|
||||
- Status pill: \`{"kind":"pill","status":"active"|"suspended"|"deactivated","label"?:"text"}\`
|
||||
- Stat tile: \`{"kind":"stat","label":"Tenants","value":42}\`
|
||||
- Callout: \`{"kind":"callout","tone":"info"|"warning"|"danger"|"success","title"?:"…","body"?:"…"}\`
|
||||
Tool results already render as rich tables/cards/timelines automatically — DO NOT re-render that data with markdown tables or cards. Refer to it as "above" and add only commentary.`
|
||||
|
||||
@@ -78,6 +78,13 @@ import {
|
||||
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
||||
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||||
import { ConfirmCard } from "~/components/assistant/confirm-card"
|
||||
import { renderToolResult } from "~/components/assistant/tool-result-renderers"
|
||||
|
||||
function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
|
||||
const rich = renderToolResult(name, result)
|
||||
if (!rich) return null
|
||||
return <div className="px-1">{rich}</div>
|
||||
}
|
||||
|
||||
const SNAPSHOT_KEY = "crema.ai.snapshot"
|
||||
type StoredMessage = { role: "user" | "assistant"; content: string }
|
||||
@@ -549,7 +556,12 @@ function ChatSurface({
|
||||
{calls.length > 0 && (
|
||||
<div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
|
||||
{calls.map((c) => (
|
||||
<ToolCallCard key={c.id} call={c} defaultExpanded={c.status !== "success"} />
|
||||
<div key={c.id} className="flex flex-col gap-2">
|
||||
<ToolCallCard call={c} defaultExpanded={false} />
|
||||
{c.status === "success" && (
|
||||
<ToolResultBlock name={c.name} result={c.result} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
294
package-lock.json
generated
294
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "7.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
@@ -4575,6 +4576,18 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
@@ -6228,6 +6241,16 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -6237,6 +6260,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||
@@ -6261,6 +6300,107 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
||||
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
||||
"mdast-util-gfm-footnote": "^2.0.0",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mdast-util-gfm-task-list-item": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-autolink-literal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
||||
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"ccount": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.0",
|
||||
"micromark-util-character": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.1.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-strikethrough": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
||||
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-table": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
||||
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"markdown-table": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-task-list-item": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
||||
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-mdx-expression": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||
@@ -6508,6 +6648,127 @@
|
||||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
||||
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
||||
"micromark-extension-gfm-footnote": "^2.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
||||
"micromark-extension-gfm-table": "^2.0.0",
|
||||
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
||||
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
||||
"micromark-util-combine-extensions": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-core-commonmark": "^2.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-strikethrough": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
||||
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-util-chunked": "^2.0.0",
|
||||
"micromark-util-classify-character": "^2.0.0",
|
||||
"micromark-util-resolve-all": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-tagfilter": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
||||
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-task-list-item": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
||||
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
@@ -8041,6 +8302,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
@@ -8074,6 +8353,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-stringify": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
||||
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "7.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
|
||||
Reference in New Issue
Block a user