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>
169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
// Agent personas — named, role-scoped sub-system prompts.
|
|
// Each persona stacks on top of the main systemPrompt to specialize the
|
|
// assistant for a task. Persisted in localStorage; reactive across tabs.
|
|
|
|
import { useEffect, useSyncExternalStore } from "react"
|
|
|
|
export type Agent = {
|
|
id: string
|
|
name: string
|
|
role: string
|
|
prompt: string
|
|
}
|
|
|
|
export const DEFAULT_AGENTS: Agent[] = [
|
|
{
|
|
id: "operator",
|
|
name: "Atlas",
|
|
role: "Platform Operator",
|
|
prompt:
|
|
"You're the platform admin's day-to-day operator inside Arcadia Admin. Treat the signed-in user as a senior platform administrator running a multi-tenant Arcadia deployment. Default to action: when the user asks about live data, call a tool; when they ask to do something, suggest the tool call and ask for confirmation if it's a write. Prefer tenant slugs over UUIDs in conversation. Keep replies tight — operators read fast.",
|
|
},
|
|
{
|
|
id: "auditor",
|
|
name: "Ledger",
|
|
role: "Auditor",
|
|
prompt:
|
|
"You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.",
|
|
},
|
|
{
|
|
id: "triage",
|
|
name: "Beacon",
|
|
role: "Incident Triage",
|
|
prompt:
|
|
"You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.",
|
|
},
|
|
{
|
|
id: "analyst",
|
|
name: "Tally",
|
|
role: "Platform Analyst",
|
|
prompt:
|
|
"You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.",
|
|
},
|
|
{
|
|
id: "ui-driver",
|
|
name: "Cursor",
|
|
role: "UI Operator",
|
|
prompt:
|
|
"You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.",
|
|
},
|
|
]
|
|
|
|
const STORAGE_KEY = "crema.agents"
|
|
const ACTIVE_KEY = "crema.assistant.activeAgent"
|
|
const CHANGE_EVENT = "crema:agents-change"
|
|
|
|
function isAgent(v: unknown): v is Agent {
|
|
return (
|
|
!!v &&
|
|
typeof v === "object" &&
|
|
typeof (v as Agent).id === "string" &&
|
|
typeof (v as Agent).name === "string" &&
|
|
typeof (v as Agent).role === "string" &&
|
|
typeof (v as Agent).prompt === "string"
|
|
)
|
|
}
|
|
|
|
// Old Vibespace agent ids — used to auto-migrate operators stuck on the
|
|
// generic defaults from before Arcadia Admin had its own personas.
|
|
const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"])
|
|
|
|
function isLegacyDefaultSet(agents: Agent[]): boolean {
|
|
return agents.some((a) => LEGACY_AGENT_IDS.has(a.id))
|
|
}
|
|
|
|
function readFromStorage(): Agent[] {
|
|
if (typeof window === "undefined") return DEFAULT_AGENTS
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
if (!raw) return DEFAULT_AGENTS
|
|
const parsed = JSON.parse(raw)
|
|
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
|
|
const cleaned = parsed.filter(isAgent)
|
|
if (cleaned.length === 0) return DEFAULT_AGENTS
|
|
if (isLegacyDefaultSet(cleaned)) {
|
|
// Auto-migrate: stored set still contains pre-arcadia personas.
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_AGENTS))
|
|
localStorage.removeItem(ACTIVE_KEY)
|
|
return DEFAULT_AGENTS
|
|
}
|
|
return cleaned
|
|
} catch {
|
|
return DEFAULT_AGENTS
|
|
}
|
|
}
|
|
|
|
export function loadAgents(): Agent[] {
|
|
return readFromStorage()
|
|
}
|
|
|
|
export function saveAgents(next: Agent[]) {
|
|
if (typeof window === "undefined") return
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
|
}
|
|
|
|
export function resetAgents() {
|
|
saveAgents(DEFAULT_AGENTS)
|
|
}
|
|
|
|
let cached: Agent[] | null = null
|
|
|
|
function subscribe(cb: () => void): () => void {
|
|
const onChange = () => {
|
|
cached = null
|
|
cb()
|
|
}
|
|
window.addEventListener(CHANGE_EVENT, onChange)
|
|
window.addEventListener("storage", (e) => {
|
|
if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange()
|
|
})
|
|
return () => {
|
|
window.removeEventListener(CHANGE_EVENT, onChange)
|
|
}
|
|
}
|
|
|
|
function getSnapshot(): Agent[] {
|
|
if (!cached) cached = readFromStorage()
|
|
return cached
|
|
}
|
|
|
|
function getServerSnapshot(): Agent[] {
|
|
return DEFAULT_AGENTS
|
|
}
|
|
|
|
export function useAgents(): Agent[] {
|
|
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
useEffect(() => {
|
|
cached = null
|
|
}, [])
|
|
return value
|
|
}
|
|
|
|
export function loadActiveAgentId(): string {
|
|
if (typeof window === "undefined") return DEFAULT_AGENTS[0].id
|
|
try {
|
|
return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id
|
|
} catch {
|
|
return DEFAULT_AGENTS[0].id
|
|
}
|
|
}
|
|
|
|
export function saveActiveAgentId(id: string) {
|
|
if (typeof window === "undefined") return
|
|
localStorage.setItem(ACTIVE_KEY, id)
|
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
|
}
|
|
|
|
export function composeSystemPrompt(
|
|
base: string,
|
|
agent: Agent | undefined,
|
|
): string {
|
|
if (!agent) return base
|
|
return `${base}\n\nActive persona: ${agent.name} — ${agent.role}\n${agent.prompt}`
|
|
}
|
|
|
|
export function newAgentId(): string {
|
|
return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
}
|