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>
This commit is contained in:
jules
2026-05-01 20:08:47 +10:00
parent e7cb8c942b
commit fe93f2766c
9 changed files with 577 additions and 82 deletions

View File

@@ -53,7 +53,6 @@ import {
} from "~/components/ui/popover"
import { useLLMSettings } from "~/lib/llm-settings"
import {
composeSystemPrompt,
loadActiveAgentId,
saveActiveAgentId,
useAgents,
@@ -62,6 +61,11 @@ import {
import { addLibraryItem } from "~/lib/library"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client"
import type { ToolCall } from "@crema/llm-ui"
import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import { formatAdminContextForPrompt } from "~/lib/admin-context"
const SNAPSHOT_KEY = "crema.ai.snapshot"
type StoredMessage = { role: "user" | "assistant"; content: string }
@@ -221,12 +225,52 @@ function ChatSurface({
isMock: boolean
onRetryProbe: () => void
}) {
const baseSystem =
"You are a helpful AI assistant. Be concise, warm, and direct."
const systemPrompt = composeSystemPrompt(baseSystem, activeAgent)
const { messages, setMessages, send, abort, isStreaming, reset } = useChat({
const persona = activeAgent
? `Active persona: ${activeAgent.name}${activeAgent.role}\n${activeAgent.prompt}`
: ""
const systemPrompt = [
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
ARCADIA_KNOWLEDGE,
persona,
formatAdminContextForPrompt(),
]
.filter(Boolean)
.join("\n\n")
const arcadia = useArcadiaClient()
const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({
system: systemPrompt,
})
// Auto tool-loop using native function calls.
const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming)
const MAX_TOOL_ITERATIONS = 3
useEffect(() => {
const justFinished = prevStreamingRef.current && !isStreaming
prevStreamingRef.current = isStreaming
if (!justFinished) return
const lastIdx = messages.length - 1
if (lastIdx < 0) return
const last = messages[lastIdx]
if (last.role !== "assistant") return
if (processedTurnRef.current === lastIdx) return
processedTurnRef.current = lastIdx
const calls = last.toolCalls ?? []
if (calls.length === 0) {
toolIterationsRef.current = 0
return
}
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return
toolIterationsRef.current += 1
void (async () => {
const { toolMessages } = await runLLMToolCalls(calls, { arcadia })
void continueChat(toolMessages, {
system: systemPrompt,
tools: getOpenAITools(),
})
})()
}, [messages, isStreaming, arcadia, continueChat, systemPrompt])
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
const [input, setInput] = useState("")
const [showPromptOpen, setShowPromptOpen] = useState(false)
@@ -304,12 +348,12 @@ function ChatSurface({
const text = messages[lastUserIdx].content
setMessages(messages.slice(0, lastUserIdx))
// Defer so the state flush completes before send() reads `messages`.
setTimeout(() => void send(text), 0)
setTimeout(() => void send(text, { tools: getOpenAITools() }), 0)
}, [messages, setMessages, send, isStreaming])
const continueLast = useCallback(() => {
if (isStreaming || messages.length === 0) return
void send("Please continue your previous reply.")
void send("Please continue your previous reply.", { tools: getOpenAITools() })
}, [isStreaming, messages.length, send])
const compactConversation = useCallback(async () => {
@@ -396,7 +440,7 @@ function ChatSurface({
if (!text || isStreaming) return
setInput("")
stickRef.current = true
void send(text)
void send(text, { tools: getOpenAITools() })
}, [input, isStreaming, send])
const isEmpty = messages.length === 0
@@ -426,9 +470,16 @@ function ChatSurface({
) : (
<div className="flex-1 px-4 py-6 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
{messages.map((m, i) => (
<MessageRow key={i} role={m.role} content={m.content} />
))}
{messages
.filter((m) => m.role !== "system")
.map((m, i) => (
<MessageRow
key={i}
role={m.role as "user" | "assistant" | "tool"}
content={m.content}
toolCalls={m.toolCalls}
/>
))}
{isStreaming && messages.at(-1)?.role !== "assistant" && (
<div className="self-start">
<TypingIndicator />
@@ -499,10 +550,19 @@ function ChatSurface({
function MessageRow({
role,
content,
toolCalls,
}: {
role: "user" | "assistant"
role: "user" | "assistant" | "tool"
content: string
toolCalls?: ToolCall[]
}) {
if (role === "tool") {
return (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} isToolResult />
</div>
)
}
if (role === "user") {
return (
<div className="self-end">
@@ -520,7 +580,7 @@ function MessageRow({
}
return (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} />
<MessageBody content={content} toolCalls={toolCalls} />
</div>
)
}