Files
arcadia-admin/app/lib/admin-tools.ts
jules 9cbe921db7 ai: rich-output blocks via lazy-fetched typed-fence protocol
Assistant replies can now emit typed fenced blocks that render as
@crema/*-ui components inline at their position in the reply.

- message-body.tsx: segmented rendering — alternating prose chunks and
  block dispatch (was: all blocks appended at end). Renderers for kpi,
  table, chart-bar/-line/-donut/-spark, code, diff, flowchart, orgchart,
  steps, checklist, welcome, hint, plus the legacy card kinds.
- block-schemas.ts: single source of truth — BLOCK_INDEX (one-line
  purpose per kind, always in prompt) + SCHEMAS (full JSON shape +
  example, fetched on demand).
- admin-tools.ts: new get_block_schema(kind) tool the model calls once
  per kind per thread to fetch the exact schema. Keeps the always-on
  prompt small (~110 tokens vs ~400 inline).
- assistant.tsx: replaces the inline schema dump with the generated
  thin index.
- ai.tsx: empty-state preview button injects a synthetic assistant
  message exercising every block, for renderer/theme smoke-testing.
- console.css + ai.tsx: shrink ATLAS headline so it doesn't slip under
  the composer with the added preview button.
- tsconfig.json + app.css: wire lib-data-ui, lib-code-ui, lib-diagram-ui,
  lib-onboarding-ui as siblings.

Adding a new block kind = add the lib paths, add a renderer case, add
a schema entry. No prompt edits required.

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

434 lines
14 KiB
TypeScript

// Curated tool surface the assistant can call. The LLM emits a fenced
// ```tool block with one JSON object per line; we parse, execute via
// arcadia-client, and feed results back as the next user turn.
//
// Each tool is a named function with documented args. The LLM never sees
// raw HTTP — only the menu below.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui"
import {
activateTenant,
getTenant,
listTenants,
suspendTenant,
type Tenant,
} from "~/lib/arcadia/tenants"
import { searchDocs } from "~/lib/docs-search"
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
export type ToolCall = {
name: string
args: Record<string, unknown>
}
export type ToolResult = {
name: string
args: Record<string, unknown>
ok: boolean
data?: unknown
error?: string
}
type ToolDef = {
name: string
description: string
parameters: Record<string, unknown> // JSON Schema for OpenAI tool calling
isWrite: boolean
run: (args: Record<string, unknown>, ctx: ToolCtx) => Promise<unknown>
}
type ToolCtx = { arcadia: ArcadiaClient }
const TOOLS: ToolDef[] = [
{
name: "list_tenants",
description:
"List every tenant on this arcadia deployment. Returns id, slug, name, status, plan, inserted_at. Call this for any question about tenant counts, statuses, or which tenants exist.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
isWrite: false,
run: async (_args, { arcadia }) => {
const tenants = await listTenants(arcadia)
return tenants.map(summarize)
},
},
{
name: "get_tenant",
description:
"Fetch a single tenant by slug (preferred) or id. Returns the tenant summary or null if not found.",
parameters: {
type: "object",
properties: {
slug: { type: "string", description: "The tenant's slug (e.g. 'acme', 'platform-admin')." },
id: { type: "string", description: "The tenant's UUID. Use only when the slug is unknown." },
},
additionalProperties: false,
},
isWrite: false,
run: async (args, { arcadia }) => {
const slug = typeof args.slug === "string" ? args.slug : null
const id = typeof args.id === "string" ? args.id : null
if (!slug && !id) throw new Error("get_tenant requires { slug } or { id }")
if (id) {
try {
return summarize(await getTenant(arcadia, id))
} catch {
return null
}
}
const tenants = await listTenants(arcadia)
const found = tenants.find((t) => t.slug === slug)
return found ? summarize(found) : null
},
},
{
name: "get_platform_stats",
description:
"Aggregate platform stats. Returns total tenant count and a breakdown by status (active / suspended / deactivated / etc). Call this for big-picture questions like 'how is the platform doing'.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
isWrite: false,
run: async (_args, { arcadia }) => {
const tenants = await listTenants(arcadia)
const byStatus: Record<string, number> = {}
for (const t of tenants) {
byStatus[t.status] = (byStatus[t.status] ?? 0) + 1
}
const byPlan: Record<string, number> = {}
for (const t of tenants) {
const plan = t.plan?.name ?? "(no plan)"
byPlan[plan] = (byPlan[plan] ?? 0) + 1
}
return {
tenants_total: tenants.length,
tenants_by_status: byStatus,
tenants_by_plan: byPlan,
}
},
},
{
name: "list_audit_log",
description:
"Recent audit log entries from the platform. Returns a terse summary per entry: actor_type, actor_id, action, target, inserted_at. Call this for 'who did what' questions or to investigate a recent change.",
parameters: {
type: "object",
properties: {
limit: {
type: "integer",
description: "Max entries to return (default 25, max 100).",
minimum: 1,
maximum: 100,
},
},
additionalProperties: false,
},
isWrite: false,
run: async (args, { arcadia }) => {
const limit = typeof args.limit === "number" ? Math.min(100, Math.max(1, args.limit)) : 25
const res = await arcadia.GET<{ data: AuditEntry[] }>(
`/api/v1/admin/audit-log?limit=${limit}`,
)
const entries = res.data ?? []
return entries.map((e) => ({
actor_type: e.actor_type,
actor_id: e.actor_id ?? null,
action: e.action,
target: e.target_type ? `${e.target_type}#${e.target_id ?? "?"}` : null,
inserted_at: e.inserted_at,
}))
},
},
{
name: "list_users",
description:
"List users in the currently-selected tenant context. Returns email, name, roles, status. Note: this is scoped to whichever tenant the assistant is currently logged into; use get_tenant first if you need to confirm which tenant.",
parameters: {
type: "object",
properties: {
limit: {
type: "integer",
description: "Max users to return (default 50, max 200).",
minimum: 1,
maximum: 200,
},
},
additionalProperties: false,
},
isWrite: false,
run: async (args, { arcadia }) => {
const limit = typeof args.limit === "number" ? Math.min(200, Math.max(1, args.limit)) : 50
const res = await arcadia.GET<{ data: UserEntry[] }>(`/api/v1/users?per_page=${limit}`)
const users = res.data ?? []
return users.map((u) => ({
id: u.id,
email: u.email,
name: [u.first_name, u.last_name].filter(Boolean).join(" ") || null,
roles: u.roles?.map((r) => r.slug ?? r.name).filter(Boolean) ?? [],
verified: u.email_verified ?? null,
inserted_at: u.inserted_at,
}))
},
},
{
name: "suspend_tenant",
description:
"Suspend a tenant by slug. Members can no longer sign in until reactivated. Use for temporary holds (overdue invoice, abuse investigation). REVERSIBLE via activate_tenant. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
slug: { type: "string", description: "The tenant's slug." },
},
required: ["slug"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const slug = typeof args.slug === "string" ? args.slug : null
if (!slug) throw new Error("suspend_tenant requires { slug }")
const tenants = await listTenants(arcadia)
const target = tenants.find((t) => t.slug === slug)
if (!target) throw new Error(`No tenant with slug "${slug}"`)
const updated = await suspendTenant(arcadia, target.id)
return summarize(updated)
},
},
{
name: "activate_tenant",
description:
"Re-activate a previously suspended (or deactivated) tenant by slug. Restores member sign-in. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
slug: { type: "string", description: "The tenant's slug." },
},
required: ["slug"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const slug = typeof args.slug === "string" ? args.slug : null
if (!slug) throw new Error("activate_tenant requires { slug }")
const tenants = await listTenants(arcadia)
const target = tenants.find((t) => t.slug === slug)
if (!target) throw new Error(`No tenant with slug "${slug}"`)
const updated = await activateTenant(arcadia, target.id)
return summarize(updated)
},
},
{
name: "search_docs",
description:
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.",
},
limit: {
type: "integer",
description: "Max passages to return. Default 5, cap 10.",
minimum: 1,
maximum: 10,
},
},
required: ["query"],
additionalProperties: false,
},
isWrite: false,
run: async (args) => {
const query = typeof args.query === "string" ? args.query.trim() : ""
if (!query) throw new Error("search_docs requires a non-empty { query }")
const limit = Math.min(
10,
Math.max(1, typeof args.limit === "number" ? args.limit : 5),
)
const hits = await searchDocs(query, limit)
return { query, count: hits.length, hits }
},
},
{
name: "get_block_schema",
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
BLOCK_INDEX,
)
.map(([k, v]) => `${k} (${v})`)
.join(", ")}.`,
parameters: {
type: "object",
properties: {
kind: {
type: "string",
description: "The block kind to fetch the schema for.",
enum: Object.keys(BLOCK_INDEX),
},
},
required: ["kind"],
additionalProperties: false,
},
isWrite: false,
run: async (args) => {
const kind = typeof args.kind === "string" ? args.kind : ""
const schema = getBlockSchema(kind)
if (!schema) {
return {
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
}
}
return { kind, schema }
},
},
]
interface AuditEntry {
actor_type: string
actor_id?: string
action: string
target_type?: string
target_id?: string
inserted_at: string
}
interface UserEntry {
id: string
email: string
first_name?: string
last_name?: string
email_verified?: boolean
inserted_at: string
roles?: { slug?: string; name?: string }[]
}
/** OpenAI-format tool list to pass into ChatRequest.tools. */
export function getOpenAITools(): Tool[] {
return TOOLS.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
}))
}
/** Split an LLM tool-call list into reads (run automatically) and writes
* (held for user confirmation). Unknown tools fall into reads so the runner
* can surface a structured "unknown tool" error to the model. */
export function classifyCalls(calls: LLMToolCall[]): {
reads: LLMToolCall[]
writes: LLMToolCall[]
} {
const reads: LLMToolCall[] = []
const writes: LLMToolCall[] = []
for (const c of calls) {
const def = TOOL_BY_NAME.get(c.name)
if (def?.isWrite) writes.push(c)
else reads.push(c)
}
return { reads, writes }
}
/** Synthesise tool-result messages saying the user denied a write call. */
export function buildDenialMessages(
calls: LLMToolCall[],
): { role: "tool"; content: string; toolCallId: string; name: string }[] {
return calls.map((c) => ({
role: "tool",
content: JSON.stringify({
error: "User denied this write. Do not retry without re-asking the user.",
}),
toolCallId: c.id,
name: c.name,
}))
}
/** Pretty-print args for the confirm UI. */
export function formatToolCallArgs(c: LLMToolCall): string {
try {
const parsed = c.arguments ? JSON.parse(c.arguments) : {}
const keys = Object.keys(parsed)
if (keys.length === 0) return ""
return keys.map((k) => `${k}=${JSON.stringify(parsed[k])}`).join(", ")
} catch {
return c.arguments
}
}
function summarize(t: Tenant) {
return {
id: t.id,
slug: t.slug,
name: t.name,
status: t.status,
plan: t.plan?.name ?? null,
inserted_at: t.inserted_at,
}
}
const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]))
function safeJson(value: unknown): string {
try {
const text = JSON.stringify(value, null, 2)
if (text.length > 6000) return text.slice(0, 6000) + "\n…(truncated)"
return text
} catch {
return "(unserializable)"
}
}
/** Run a list of provider-native tool calls and return `tool` role messages
* ready to push back into useChat history. */
export async function runLLMToolCalls(
calls: LLMToolCall[],
ctx: ToolCtx,
opts: { allowWrites?: boolean } = {},
): Promise<{
results: ToolResult[]
toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
}> {
const results: ToolResult[] = []
const toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[] = []
for (const call of calls) {
const def = TOOL_BY_NAME.get(call.name)
let parsed: Record<string, unknown> = {}
try {
parsed = call.arguments ? (JSON.parse(call.arguments) as Record<string, unknown>) : {}
} catch {
const err = `Could not parse arguments JSON: ${call.arguments}`
results.push({ name: call.name, args: {}, ok: false, error: err })
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
continue
}
if (!def) {
const err = `Unknown tool: ${call.name}`
results.push({ name: call.name, args: parsed, ok: false, error: err })
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
continue
}
if (def.isWrite && !opts.allowWrites) {
const err = "Write tools require user confirmation."
results.push({ name: call.name, args: parsed, ok: false, error: err })
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
continue
}
try {
const data = await def.run(parsed, ctx)
results.push({ name: call.name, args: parsed, ok: true, data })
toolMessages.push({ role: "tool", content: safeJson(data), toolCallId: call.id, name: call.name })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
results.push({ name: call.name, args: parsed, ok: false, error: msg })
toolMessages.push({ role: "tool", content: JSON.stringify({ error: msg }), toolCallId: call.id, name: call.name })
}
}
return { results, toolMessages }
}