Files
arcadia-admin/app/components/assistant/message-body.tsx
jules fe93f2766c Wire AI assistant to arcadia: domain primer, tool calling, admin context
Make /ai and /assistant operate as the platform admin's assistant
against arcadia-app's API:

- Add `arcadia-knowledge.ts` — domain primer (multi-tenant Phoenix
  backend, tenant lifecycle, platform_admins identity, etc.) baked into
  every system prompt.
- Add `admin-tools.ts` — curated tool registry exposing `list_tenants`
  and `get_tenant`, callable via OpenAI-native function calling. Tools
  hit arcadia through `useArcadiaClient()` and inherit the operator's
  JWT + tenant header. `runLLMToolCalls()` returns `tool` role messages
  ready to push back into history.
- Add `admin-context.ts` — runtime registry pages publish to so the
  assistant can answer factual questions about live UI state without
  scraping the DOM. Tenants page registers its summary on mount.
- Replace generic Vibespace personas (Atlas/Forge/Inkwell/Pilot/Cursor)
  with arcadia-flavoured ones: Operator, Auditor, Triage, Analyst,
  UI Operator. Auto-migrate stored agents from the legacy set.
- /assistant: build admin preface (role + primer + persona + ctx) and
  pass it as the `useChat` system at construction. Pass `tools` on every
  `send()`. Auto-loop reads `toolCalls` off the streaming assistant
  message and uses `continueChat()` to push tool results.
- /ai: same wiring (this is the canonical admin chat surface; the user
  prefers its look).
- MessageBody renders tool-result cards (role: "tool") and a "Called X"
  pill on assistant messages with toolCalls. Strips Qwen-style
  `<tool_call>` XML from prose when the tags were converted to
  structured calls.
- Extend ThreadMessage with the `tool` role + tool-call metadata so
  conversations round-trip through localStorage.
- Tenants page: row actions get `data-action="tenant-<slug>-{suspend,
  activate,deactivate}"` (via lib-table-ui's new dataAction prop);
  registers tenant summary into admin-context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:08:47 +10:00

101 lines
3.8 KiB
TypeScript

// 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.
import { useMemo } from "react"
import ReactMarkdown from "react-markdown"
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
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[]
}
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
const { prose, actionCount } = useMemo(() => {
const blocks = extractActionBlocks(content)
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "").trim()
return {
prose: cleaned,
actionCount: blocks.length,
}
}, [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
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>
),
}}
>
{prose}
</ReactMarkdown>
)}
{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>
)
}