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:
@@ -1,30 +1,53 @@
|
|||||||
// Renders an assistant message: markdown for prose, and a small "Ran N
|
// Renders an assistant message: markdown for prose, plus pills for any
|
||||||
// actions" pill in place of any ```action``` fenced blocks (which are the
|
// command-bus action blocks or native tool calls attached to the message.
|
||||||
// machine-readable instructions the bus has already executed).
|
// Tool-role messages (results from a function call) render as a JSON card.
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import { Sparkles } from "lucide-react"
|
import { Sparkles, Wrench } from "lucide-react"
|
||||||
|
|
||||||
import { extractActionBlocks } from "@crema/action-bus"
|
import { extractActionBlocks } from "@crema/action-bus"
|
||||||
|
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
||||||
|
|
||||||
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
||||||
|
|
||||||
export function MessageBody({ content }: { content: string }) {
|
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 { prose, actionCount } = useMemo(() => {
|
||||||
const blocks = extractActionBlocks(content)
|
const blocks = extractActionBlocks(content)
|
||||||
|
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "").trim()
|
||||||
return {
|
return {
|
||||||
prose: content.replace(ACTION_BLOCK_RE, "").trim(),
|
prose: cleaned,
|
||||||
actionCount: blocks.length,
|
actionCount: blocks.length,
|
||||||
}
|
}
|
||||||
}, [content])
|
}, [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 (
|
return (
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
{prose && (
|
{prose && (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
components={{
|
components={{
|
||||||
// Tight overrides — keep paragraphs compact in chat bubbles
|
|
||||||
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||||
code: ({ children, className }) => {
|
code: ({ children, className }) => {
|
||||||
const isBlock = className?.startsWith("language-")
|
const isBlock = className?.startsWith("language-")
|
||||||
@@ -63,6 +86,15 @@ export function MessageBody({ content }: { content: string }) {
|
|||||||
Ran {actionCount} action{actionCount > 1 ? "s" : ""}
|
Ran {actionCount} action{actionCount > 1 ? "s" : ""}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
76
app/lib/admin-context.ts
Normal file
76
app/lib/admin-context.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Shared state surface that any admin page can publish to so the assistant
|
||||||
|
// can read live data without scraping the DOM.
|
||||||
|
//
|
||||||
|
// Pages call `useRegisterAdminContext("tenants", { tenants: [...] })` while
|
||||||
|
// mounted; the assistant calls `getAdminContextSnapshot()` each turn to
|
||||||
|
// inject a structured snapshot into the system prompt.
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
type Surface = Record<string, unknown>
|
||||||
|
|
||||||
|
export type AdminContextSnapshot = {
|
||||||
|
route: string
|
||||||
|
surfaces: Record<string, Surface>
|
||||||
|
}
|
||||||
|
|
||||||
|
const surfaces = new Map<string, Surface>()
|
||||||
|
|
||||||
|
export function publishAdminSurface(name: string, data: Surface): void {
|
||||||
|
surfaces.set(name, data)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAdminSurface(name: string): void {
|
||||||
|
surfaces.delete(name)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminContextSnapshot(): AdminContextSnapshot {
|
||||||
|
const route = typeof window !== "undefined" ? window.location.pathname : ""
|
||||||
|
return {
|
||||||
|
route,
|
||||||
|
surfaces: Object.fromEntries(surfaces.entries()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a snapshot as a markdown block for the LLM system prompt.
|
||||||
|
* Keeps it compact: route, then one section per surface with JSON.
|
||||||
|
*/
|
||||||
|
export function formatAdminContextForPrompt(snapshot = getAdminContextSnapshot()): string {
|
||||||
|
const sections: string[] = [`Admin context (read-only — for answering factual questions):`]
|
||||||
|
sections.push(`Route: ${snapshot.route || "?"}`)
|
||||||
|
const names = Object.keys(snapshot.surfaces)
|
||||||
|
if (names.length === 0) {
|
||||||
|
sections.push(`Surfaces: (none registered)`)
|
||||||
|
} else {
|
||||||
|
for (const name of names) {
|
||||||
|
const json = safeJson(snapshot.surfaces[name])
|
||||||
|
sections.push(`Surface "${name}":\n${json}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value: unknown): string {
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(value, null, 2)
|
||||||
|
if (text.length > 4000) return text.slice(0, 4000) + "\n…(truncated)"
|
||||||
|
return text
|
||||||
|
} catch {
|
||||||
|
return "(unserializable)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook: publish a surface while the component is mounted. */
|
||||||
|
export function useRegisterAdminContext(name: string, data: Surface): void {
|
||||||
|
useEffect(() => {
|
||||||
|
publishAdminSurface(name, data)
|
||||||
|
return () => clearAdminSurface(name)
|
||||||
|
}, [name, data])
|
||||||
|
}
|
||||||
161
app/lib/admin-tools.ts
Normal file
161
app/lib/admin-tools.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// 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 { getTenant, listTenants, type Tenant } from "~/lib/arcadia/tenants"
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -13,39 +13,39 @@ export type Agent = {
|
|||||||
|
|
||||||
export const DEFAULT_AGENTS: Agent[] = [
|
export const DEFAULT_AGENTS: Agent[] = [
|
||||||
{
|
{
|
||||||
id: "generalist",
|
id: "operator",
|
||||||
name: "Atlas",
|
name: "Atlas",
|
||||||
role: "Generalist",
|
role: "Platform Operator",
|
||||||
prompt:
|
prompt:
|
||||||
"You handle anything: chat, planning, summaries, casual questions. Match the user's tone. Keep replies as long as the task deserves — terse for quick questions, detailed when explaining.",
|
"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: "coder",
|
id: "auditor",
|
||||||
name: "Forge",
|
name: "Ledger",
|
||||||
role: "Software engineer",
|
role: "Auditor",
|
||||||
prompt:
|
prompt:
|
||||||
"You are a senior software engineer. Write idiomatic, well-typed code. Prefer concrete examples over abstract advice. When asked to fix a bug, identify root cause before patching. Use markdown code blocks with language tags. Mention edge cases briefly when relevant.",
|
"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: "writer",
|
id: "triage",
|
||||||
name: "Inkwell",
|
name: "Beacon",
|
||||||
role: "Writer",
|
role: "Incident Triage",
|
||||||
prompt:
|
prompt:
|
||||||
"You are a prose writer. Produce vivid, well-paced text — short stories, copy, emails, essays. Vary sentence length. Show, don't tell. When the user asks for a draft, deliver the draft, not a description of it.",
|
"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: "researcher",
|
id: "analyst",
|
||||||
name: "Pilot",
|
name: "Tally",
|
||||||
role: "Researcher",
|
role: "Platform Analyst",
|
||||||
prompt:
|
prompt:
|
||||||
"You are a careful researcher. Structure answers as: claim → evidence → caveat. Distinguish what is well-established from what is uncertain. Refuse to fabricate citations — if you don't know, say so.",
|
"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",
|
id: "ui-driver",
|
||||||
name: "Cursor",
|
name: "Cursor",
|
||||||
role: "UI Operator",
|
role: "UI Operator",
|
||||||
prompt:
|
prompt:
|
||||||
"You specialize in driving this app's UI on the user's behalf. Prefer doing over explaining. When the user asks for an action, emit an action block immediately. When they ask a question about the app, answer concisely and offer to do it.",
|
"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.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -64,6 +64,14 @@ function isAgent(v: unknown): v is Agent {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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[] {
|
function readFromStorage(): Agent[] {
|
||||||
if (typeof window === "undefined") return DEFAULT_AGENTS
|
if (typeof window === "undefined") return DEFAULT_AGENTS
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +80,14 @@ function readFromStorage(): Agent[] {
|
|||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
|
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
|
||||||
const cleaned = parsed.filter(isAgent)
|
const cleaned = parsed.filter(isAgent)
|
||||||
return cleaned.length > 0 ? cleaned : DEFAULT_AGENTS
|
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 {
|
} catch {
|
||||||
return DEFAULT_AGENTS
|
return DEFAULT_AGENTS
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/lib/arcadia-knowledge.ts
Normal file
35
app/lib/arcadia-knowledge.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Domain primer baked into the assistant's system prompt so it understands
|
||||||
|
// what arcadia-app is, what platform admins do, and how the data model fits
|
||||||
|
// together. Keep this tight — it costs context tokens on every turn.
|
||||||
|
|
||||||
|
export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer):
|
||||||
|
|
||||||
|
Arcadia is a multi-tenant SaaS backend (Elixir/Phoenix umbrella, OpenAPI at /api/v1, server-rendered platform UI at /platform/*). This admin app (Arcadia Admin) is one of several clients — it talks to Arcadia over JSON, scoped by an X-Tenant-ID header and a Bearer JWT.
|
||||||
|
|
||||||
|
Core entities and how they relate:
|
||||||
|
|
||||||
|
- **Tenant** — an isolated workspace (a customer org). Identified by a slug (e.g. "acme", "platform-admin", "default") and a UUID id. Owns its own users, roles, billing config, branding, settings. Most data is tenant-scoped.
|
||||||
|
- **Platform admin** — a separate identity that lives in the platform_admins table, NOT in any tenant. The signed-in operator using this app is one. Can read/write across all tenants. The first one is bootstrapped via /setup; \`is_root: true\` flags the original.
|
||||||
|
- **User** — a member of a single tenant. Has email + password (or SSO), system roles (\`admin\` / \`user\` / \`viewer\`) plus optional custom roles. Login goes through POST /api/v1/auth/login with the tenant slug in X-Tenant-ID.
|
||||||
|
- **Role** — permission bundle scoped to a tenant. \`admin\` / \`user\` / \`viewer\` are seeded as system roles per tenant. Permissions are wildcard-ish strings (e.g. \`tenants:read\`, \`*\`).
|
||||||
|
- **Plan** — subscription tier attached to a tenant: name + limits (seats, storage, API quota). Drives billing.
|
||||||
|
- **Audit log entry** — append-only record of who did what. \`actor_type\` is one of: \`user\`, \`platform_admin\`, \`api_key\`, \`system\`. Per-tenant and platform-wide entries coexist.
|
||||||
|
- **Feature flag** — boolean / variant gate. Platform-wide default + per-tenant override.
|
||||||
|
- **Storage / billing config / SSO IdP / inbound webhook / API quota / data retention policy / approval workflow / announcement** — per-tenant or platform-level configurations the operator can manage.
|
||||||
|
|
||||||
|
Tenant lifecycle (status field):
|
||||||
|
|
||||||
|
- **active** — normal operation. Members can sign in. Default state.
|
||||||
|
- **suspended** — members blocked from signing in. Reversible: activate to restore. Use for temporary holds (overdue invoice, abuse investigation).
|
||||||
|
- **deactivated** — stronger stop. Treat as effectively closed; usually flagged as terminal even if technically reversible. Use only when offboarding.
|
||||||
|
|
||||||
|
Things to keep in mind when assisting:
|
||||||
|
|
||||||
|
- Prefer tenant **slugs** in user-facing language ("the acme tenant"); slugs are stable, ids are UUIDs that aren't useful to humans.
|
||||||
|
- "Platform admin" ≠ "admin role inside a tenant". The first acts cross-tenant; the second is scoped to one tenant.
|
||||||
|
- Writes are auditable. Suggest the user double-check tenant slug and impact before suspend/deactivate. Deactivate is harsher than suspend — only use when clearly intended.
|
||||||
|
- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in".
|
||||||
|
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
|
||||||
|
- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`).
|
||||||
|
|
||||||
|
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. When they ask to do something destructive, summarise the impact in one sentence and ask for confirmation before suggesting a tool call.`
|
||||||
@@ -4,10 +4,16 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export type ThreadMessage = {
|
export type ThreadMessage = {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant" | "tool"
|
||||||
content: string
|
content: string
|
||||||
/** Persona that authored this assistant message (omitted for user msgs). */
|
/** Persona that authored this assistant message (omitted for user msgs). */
|
||||||
agentId?: string
|
agentId?: string
|
||||||
|
/** Native tool calls attached to an assistant message. */
|
||||||
|
toolCalls?: { id: string; name: string; arguments: string }[]
|
||||||
|
/** Tool role only — id of the matching assistant tool_call. */
|
||||||
|
toolCallId?: string
|
||||||
|
/** Tool role only — function name. */
|
||||||
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Thread = {
|
export type Thread = {
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
} from "~/components/ui/popover"
|
} from "~/components/ui/popover"
|
||||||
import { useLLMSettings } from "~/lib/llm-settings"
|
import { useLLMSettings } from "~/lib/llm-settings"
|
||||||
import {
|
import {
|
||||||
composeSystemPrompt,
|
|
||||||
loadActiveAgentId,
|
loadActiveAgentId,
|
||||||
saveActiveAgentId,
|
saveActiveAgentId,
|
||||||
useAgents,
|
useAgents,
|
||||||
@@ -62,6 +61,11 @@ import {
|
|||||||
import { addLibraryItem } from "~/lib/library"
|
import { addLibraryItem } from "~/lib/library"
|
||||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
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"
|
const SNAPSHOT_KEY = "crema.ai.snapshot"
|
||||||
type StoredMessage = { role: "user" | "assistant"; content: string }
|
type StoredMessage = { role: "user" | "assistant"; content: string }
|
||||||
@@ -221,12 +225,52 @@ function ChatSurface({
|
|||||||
isMock: boolean
|
isMock: boolean
|
||||||
onRetryProbe: () => void
|
onRetryProbe: () => void
|
||||||
}) {
|
}) {
|
||||||
const baseSystem =
|
const persona = activeAgent
|
||||||
"You are a helpful AI assistant. Be concise, warm, and direct."
|
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||||
const systemPrompt = composeSystemPrompt(baseSystem, activeAgent)
|
: ""
|
||||||
const { messages, setMessages, send, abort, isStreaming, reset } = useChat({
|
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,
|
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 { complete: completeOneShot, isLoading: compacting } = useCompletion()
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [showPromptOpen, setShowPromptOpen] = useState(false)
|
const [showPromptOpen, setShowPromptOpen] = useState(false)
|
||||||
@@ -304,12 +348,12 @@ function ChatSurface({
|
|||||||
const text = messages[lastUserIdx].content
|
const text = messages[lastUserIdx].content
|
||||||
setMessages(messages.slice(0, lastUserIdx))
|
setMessages(messages.slice(0, lastUserIdx))
|
||||||
// Defer so the state flush completes before send() reads `messages`.
|
// 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])
|
}, [messages, setMessages, send, isStreaming])
|
||||||
|
|
||||||
const continueLast = useCallback(() => {
|
const continueLast = useCallback(() => {
|
||||||
if (isStreaming || messages.length === 0) return
|
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])
|
}, [isStreaming, messages.length, send])
|
||||||
|
|
||||||
const compactConversation = useCallback(async () => {
|
const compactConversation = useCallback(async () => {
|
||||||
@@ -396,7 +440,7 @@ function ChatSurface({
|
|||||||
if (!text || isStreaming) return
|
if (!text || isStreaming) return
|
||||||
setInput("")
|
setInput("")
|
||||||
stickRef.current = true
|
stickRef.current = true
|
||||||
void send(text)
|
void send(text, { tools: getOpenAITools() })
|
||||||
}, [input, isStreaming, send])
|
}, [input, isStreaming, send])
|
||||||
|
|
||||||
const isEmpty = messages.length === 0
|
const isEmpty = messages.length === 0
|
||||||
@@ -426,8 +470,15 @@ function ChatSurface({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 px-4 py-6 sm:px-6">
|
<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">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||||
{messages.map((m, i) => (
|
{messages
|
||||||
<MessageRow key={i} role={m.role} content={m.content} />
|
.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" && (
|
{isStreaming && messages.at(-1)?.role !== "assistant" && (
|
||||||
<div className="self-start">
|
<div className="self-start">
|
||||||
@@ -499,10 +550,19 @@ function ChatSurface({
|
|||||||
function MessageRow({
|
function MessageRow({
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
|
toolCalls,
|
||||||
}: {
|
}: {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant" | "tool"
|
||||||
content: string
|
content: string
|
||||||
|
toolCalls?: ToolCall[]
|
||||||
}) {
|
}) {
|
||||||
|
if (role === "tool") {
|
||||||
|
return (
|
||||||
|
<div className="self-start max-w-[80ch]">
|
||||||
|
<MessageBody content={content} isToolResult />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
return (
|
return (
|
||||||
<div className="self-end">
|
<div className="self-end">
|
||||||
@@ -520,7 +580,7 @@ function MessageRow({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="self-start max-w-[80ch]">
|
<div className="self-start max-w-[80ch]">
|
||||||
<MessageBody content={content} />
|
<MessageBody content={content} toolCalls={toolCalls} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ const PROBE_TIMEOUT_MS = 3000
|
|||||||
// "Available actions" in the system prompt only lists what's on screen NOW;
|
// "Available actions" in the system prompt only lists what's on screen NOW;
|
||||||
// this catalog tells the model what exists elsewhere so it can plan
|
// this catalog tells the model what exists elsewhere so it can plan
|
||||||
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
||||||
const UI_CONTROL_PREFACE = `You are the assistant inside this app and you can drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
||||||
|
|
||||||
|
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Prefer \`click nav-<page>\` (e.g. \`click nav-settings\`) over \`navigate <path>\` so the user sees the cursor travel to the sidebar.
|
- Prefer \`click nav-<page>\` (e.g. \`click nav-settings\`) over \`navigate <path>\` so the user sees the cursor travel to the sidebar.
|
||||||
@@ -42,7 +44,7 @@ Rules:
|
|||||||
|
|
||||||
Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):
|
Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):
|
||||||
|
|
||||||
Sidebar / nav: nav-overview, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||||
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
|
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
|
||||||
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
|
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
|
||||||
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
|
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
|
||||||
@@ -54,6 +56,7 @@ Assistant agent picker: assistant-agent (dropdown — click to switch persona)
|
|||||||
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
||||||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||||||
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||||||
|
Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
|
||||||
Login page: login-email, login-password, login-submit
|
Login page: login-email, login-password, login-submit
|
||||||
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||||||
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||||||
@@ -77,6 +80,31 @@ wait_for assistant-ui-control
|
|||||||
\`\`\`"`
|
\`\`\`"`
|
||||||
|
|
||||||
|
|
||||||
|
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||||||
|
import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools"
|
||||||
|
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always includes domain knowledge + tools + admin context + persona.
|
||||||
|
* Adds the UI-control DSL/action catalog only when uiControl is on (those rules
|
||||||
|
* are about driving the cursor, irrelevant when the assistant is in plain Q&A).
|
||||||
|
*/
|
||||||
|
function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean): string {
|
||||||
|
const persona = activeAgent
|
||||||
|
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||||
|
: ""
|
||||||
|
const ctx = formatAdminContextForPrompt()
|
||||||
|
const parts = [
|
||||||
|
"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,
|
||||||
|
ctx,
|
||||||
|
]
|
||||||
|
if (uiControl) parts.push(UI_CONTROL_PREFACE)
|
||||||
|
return parts.filter(Boolean).join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
|
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
@@ -395,8 +423,17 @@ function AssistantSurface({
|
|||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [uiControl])
|
}, [uiControl])
|
||||||
|
|
||||||
const { messages, send, abort, isStreaming, error, reset } = useChat({
|
// Always-on admin system prompt — domain primer + tools + persona, regardless
|
||||||
system: systemPrompt,
|
// of UI Control. Per-call `send(text, {system})` overrides may not always be
|
||||||
|
// honored across hook re-renders, so anchor it at construction too.
|
||||||
|
const constructorSystemPrompt = buildSystemPrompt({
|
||||||
|
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||||
|
preface: buildAdminPreface(activeAgent, uiControl),
|
||||||
|
includeActions: uiControl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { messages, send, continueChat, abort, isStreaming, error, reset } = useChat({
|
||||||
|
system: constructorSystemPrompt,
|
||||||
initialMessages: thread.messages as StoredMessage[],
|
initialMessages: thread.messages as StoredMessage[],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -408,8 +445,21 @@ function AssistantSurface({
|
|||||||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||||
const prior = thread.messages[i]
|
const prior = thread.messages[i]
|
||||||
if (m.role === "user") return { role: "user", content: m.content }
|
if (m.role === "user") return { role: "user", content: m.content }
|
||||||
|
if (m.role === "tool") {
|
||||||
|
return {
|
||||||
|
role: "tool",
|
||||||
|
content: m.content,
|
||||||
|
toolCallId: m.toolCallId,
|
||||||
|
name: m.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
const agentId = prior?.agentId ?? activeAgentId
|
const agentId = prior?.agentId ?? activeAgentId
|
||||||
return { role: "assistant", content: m.content, agentId }
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: m.content,
|
||||||
|
agentId,
|
||||||
|
...(m.toolCalls ? { toolCalls: m.toolCalls } : {}),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
updateThread(thread.id, {
|
updateThread(thread.id, {
|
||||||
messages: stamped,
|
messages: stamped,
|
||||||
@@ -523,6 +573,50 @@ function AssistantSurface({
|
|||||||
}, [messages, onRequestRetry, thread.id, thread.pinned])
|
}, [messages, onRequestRetry, thread.id, thread.pinned])
|
||||||
|
|
||||||
const handleSendRef = useRef<((t: string) => void) | null>(null)
|
const handleSendRef = useRef<((t: string) => void) | null>(null)
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const toolIterationsRef = useRef(0)
|
||||||
|
const processedTurnRef = useRef(-1)
|
||||||
|
const prevStreamingRef = useRef(isStreaming)
|
||||||
|
const MAX_TOOL_ITERATIONS = 3
|
||||||
|
|
||||||
|
// Auto tool-loop using native function calls. When the streaming assistant
|
||||||
|
// turn ends with toolCalls attached, run them, push tool result messages,
|
||||||
|
// and continue the chat so the model produces its final answer.
|
||||||
|
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 ?? []
|
||||||
|
console.debug("[admin-tools] assistant turn finished, tool_calls:", calls)
|
||||||
|
if (calls.length === 0) {
|
||||||
|
toolIterationsRef.current = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) {
|
||||||
|
console.warn("[admin-tools] tool-iteration cap reached, dropping calls", calls)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toolIterationsRef.current += 1
|
||||||
|
void (async () => {
|
||||||
|
console.debug("[admin-tools] running tool calls", calls)
|
||||||
|
const { results, toolMessages } = await runLLMToolCalls(calls, { arcadia })
|
||||||
|
console.debug("[admin-tools] tool results", results)
|
||||||
|
void continueChat(toolMessages, {
|
||||||
|
system: constructorSystemPrompt,
|
||||||
|
tools: getOpenAITools(),
|
||||||
|
maxTokens: responseBudget,
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}, [messages, isStreaming, arcadia, continueChat, constructorSystemPrompt, responseBudget])
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||||
const [editingDraft, setEditingDraft] = useState("")
|
const [editingDraft, setEditingDraft] = useState("")
|
||||||
const [speakingIndex, setSpeakingIndex] = useState<number | null>(null)
|
const [speakingIndex, setSpeakingIndex] = useState<number | null>(null)
|
||||||
@@ -597,18 +691,12 @@ function AssistantSurface({
|
|||||||
}, [thread.id])
|
}, [thread.id])
|
||||||
|
|
||||||
const composedSystemPrompt = useMemo(() => {
|
const composedSystemPrompt = useMemo(() => {
|
||||||
return uiControl
|
return buildSystemPrompt({
|
||||||
? buildSystemPrompt({
|
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||||
path:
|
preface: buildAdminPreface(activeAgent, uiControl),
|
||||||
typeof window !== "undefined" ? window.location.pathname : "",
|
includeActions: uiControl,
|
||||||
preface: `${UI_CONTROL_PREFACE}\n\n${
|
|
||||||
activeAgent
|
|
||||||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
})
|
})
|
||||||
: systemPrompt
|
}, [uiControl, activeAgent])
|
||||||
}, [uiControl, systemPrompt, activeAgent])
|
|
||||||
|
|
||||||
const buildTranscript = useCallback(() => {
|
const buildTranscript = useCallback(() => {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
@@ -768,22 +856,14 @@ function AssistantSurface({
|
|||||||
}, [isStreaming, messages, uiControl])
|
}, [isStreaming, messages, uiControl])
|
||||||
|
|
||||||
const handleSend: (text: string) => void = (text: string) => {
|
const handleSend: (text: string) => void = (text: string) => {
|
||||||
const system = uiControl
|
const system = buildSystemPrompt({
|
||||||
? buildSystemPrompt({
|
|
||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
preface: `${UI_CONTROL_PREFACE}\n\n${activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : ""}`,
|
preface: buildAdminPreface(activeAgent, uiControl),
|
||||||
|
includeActions: uiControl,
|
||||||
})
|
})
|
||||||
: systemPrompt
|
|
||||||
const sysTokens = estimateTokens(system)
|
|
||||||
const historyBudget = Math.max(
|
|
||||||
256,
|
|
||||||
contextTokens - sysTokens - responseBudget,
|
|
||||||
)
|
|
||||||
const userMsg = { role: "user" as const, content: text }
|
|
||||||
const trimmed = trimMessages([...messages, userMsg], historyBudget)
|
|
||||||
void send(text, {
|
void send(text, {
|
||||||
system,
|
system,
|
||||||
messages: trimmed,
|
tools: getOpenAITools(),
|
||||||
maxTokens: responseBudget,
|
maxTokens: responseBudget,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -800,16 +880,15 @@ function AssistantSurface({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const usedTokens = useMemo(() => {
|
const usedTokens = useMemo(() => {
|
||||||
const system = uiControl
|
const system = buildSystemPrompt({
|
||||||
? buildSystemPrompt({
|
|
||||||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||||
preface: UI_CONTROL_PREFACE,
|
preface: buildAdminPreface(activeAgent, uiControl),
|
||||||
|
includeActions: uiControl,
|
||||||
})
|
})
|
||||||
: systemPrompt
|
|
||||||
const sysT = estimateTokens(system)
|
const sysT = estimateTokens(system)
|
||||||
const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
|
const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
|
||||||
return sysT + histT
|
return sysT + histT
|
||||||
}, [messages, uiControl, systemPrompt])
|
}, [messages, uiControl, systemPrompt, activeAgent])
|
||||||
|
|
||||||
const suggestions = uiControl
|
const suggestions = uiControl
|
||||||
? [
|
? [
|
||||||
@@ -946,18 +1025,22 @@ function AssistantSurface({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isUser
|
m.role === "tool"
|
||||||
|
? "text-sm leading-relaxed"
|
||||||
|
: isUser
|
||||||
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
|
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
|
||||||
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
|
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{m.content ? (
|
{m.role === "tool" ? (
|
||||||
|
<MessageBody content={m.content} isToolResult />
|
||||||
|
) : m.content || (m.role === "assistant" && m.toolCalls?.length) ? (
|
||||||
isUser ? (
|
isUser ? (
|
||||||
<span className="whitespace-pre-wrap">
|
<span className="whitespace-pre-wrap">
|
||||||
{m.content}
|
{m.content}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<MessageBody content={m.content} />
|
<MessageBody content={m.content} toolCalls={m.toolCalls} />
|
||||||
)
|
)
|
||||||
) : isStreaming && i === messages.length - 1 ? (
|
) : isStreaming && i === messages.length - 1 ? (
|
||||||
"…"
|
"…"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from "~/lib/arcadia/tenants"
|
} from "~/lib/arcadia/tenants"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useSession } from "~/lib/session"
|
import { useSession } from "~/lib/session"
|
||||||
|
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Tenants")
|
export const meta = () => pageTitle("Tenants")
|
||||||
|
|
||||||
@@ -131,13 +132,36 @@ export default function TenantsRoute() {
|
|||||||
header: "",
|
header: "",
|
||||||
align: "right",
|
align: "right",
|
||||||
cell: (t) => (
|
cell: (t) => (
|
||||||
<ActionsCell items={rowActions(t, arcadia, refresh, setPending, setError)} />
|
<ActionsCell
|
||||||
|
items={rowActions(t, arcadia, refresh, setPending, setError)}
|
||||||
|
triggerDataAction={`tenant-${t.slug}-actions`}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[arcadia, refresh],
|
[arcadia, refresh],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tenantSummary = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: tenants.length,
|
||||||
|
byStatus: tenants.reduce<Record<string, number>>((acc, t) => {
|
||||||
|
acc[t.status] = (acc[t.status] ?? 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
|
tenants: tenants.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
status: t.status,
|
||||||
|
plan: t.plan?.name ?? null,
|
||||||
|
inserted_at: t.inserted_at,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
[tenants],
|
||||||
|
)
|
||||||
|
useRegisterAdminContext("tenants", tenantSummary)
|
||||||
|
|
||||||
const table = useTable<Tenant>({
|
const table = useTable<Tenant>({
|
||||||
data: tenants,
|
data: tenants,
|
||||||
columns,
|
columns,
|
||||||
@@ -304,6 +328,7 @@ function rowActions(
|
|||||||
id: "suspend",
|
id: "suspend",
|
||||||
label: "Suspend",
|
label: "Suspend",
|
||||||
icon: <Pause className="size-4" />,
|
icon: <Pause className="size-4" />,
|
||||||
|
dataAction: `tenant-${t.slug}-suspend`,
|
||||||
onSelect: () => setPending({ kind: "suspend", tenant: t }),
|
onSelect: () => setPending({ kind: "suspend", tenant: t }),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -311,6 +336,7 @@ function rowActions(
|
|||||||
id: "activate",
|
id: "activate",
|
||||||
label: "Activate",
|
label: "Activate",
|
||||||
icon: <Play className="size-4" />,
|
icon: <Play className="size-4" />,
|
||||||
|
dataAction: `tenant-${t.slug}-activate`,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
try {
|
try {
|
||||||
await activateTenant(arcadia, t.id)
|
await activateTenant(arcadia, t.id)
|
||||||
@@ -325,6 +351,7 @@ function rowActions(
|
|||||||
id: "deactivate",
|
id: "deactivate",
|
||||||
label: "Deactivate",
|
label: "Deactivate",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
|
dataAction: `tenant-${t.slug}-deactivate`,
|
||||||
onSelect: () => setPending({ kind: "deactivate", tenant: t }),
|
onSelect: () => setPending({ kind: "deactivate", tenant: t }),
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|||||||
Reference in New Issue
Block a user