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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user