import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { createPortal } from "react-dom"
import {
Archive,
ArrowRight,
BookmarkPlus,
ChevronDown,
Command as CommandIcon,
Copy,
Download,
FileText,
Loader2,
Mic,
MicOff,
Plus,
RefreshCw,
RotateCcw,
Sparkles,
Square,
Trash2,
Undo2,
X,
} from "lucide-react"
import {
LLMProvider,
MockLLM,
listModels,
useChat,
useCompletion,
type LLMAdapter,
} from "@crema/llm-ui"
import {
buildAdapter,
getProvider,
useSettings as useProviderSettings,
} from "@crema/llm-providers-ui"
import { TypingIndicator } from "@crema/chat-ui"
import { useToast } from "@crema/notification-ui"
import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import {
loadActiveAgentId,
saveActiveAgentId,
useAgents,
type Agent,
} from "~/lib/agents"
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 { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
import type { DocHit } from "~/lib/docs-search"
import {
AgentAvatar,
ToolCallCard,
type ToolCall as AgentToolCall,
type ToolCallStatus,
} from "@crema/agent-ui"
import {
buildDenialMessages,
classifyCalls,
getOpenAITools,
runLLMToolCalls,
} from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import {
loadActiveReasoning,
saveActiveReasoning,
subscribeActiveReasoning,
type ReasoningEffort,
} from "~/lib/arcadia/llm-configs"
import { formatAdminContextForPrompt } from "~/lib/admin-context"
import { ConfirmCard } from "~/components/assistant/confirm-card"
import { renderToolResult } from "~/components/assistant/tool-result-renderers"
function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
const rich = renderToolResult(name, result)
if (!rich) return null
return
{rich}
}
// Synthetic assistant message that exercises every typed rich-output block.
// Wired to the "preview rich-output blocks" button in the empty state — used
// to eyeball renderer + theme without driving a live model. Safe to delete
// once Phase 2 has been validated end-to-end.
const BLOCK_SAMPLES_CONTENT = `Here's one example of every rich-output block, in roughly the order a model would emit them.
A **kpi** strip for headline numbers:
\`\`\`kpi
{ "items": [
{ "label": "Tenants", "value": 42 },
{ "label": "Active users", "value": 318, "unit": "/day" },
{ "label": "Suspended", "value": 4 },
{ "label": "Storage", "value": "1.2", "unit": "TB" }
] }
\`\`\`
A **table** for tabular data:
\`\`\`table
{ "columns": [
{ "id": "slug", "header": "Tenant" },
{ "id": "users", "header": "Users", "align": "right" },
{ "id": "status", "header": "Status" }
],
"rows": [
{ "slug": "acme", "users": 42, "status": "active" },
{ "slug": "globex", "users": 18, "status": "suspended" },
{ "slug": "initech", "users": 73, "status": "active" }
],
"idKey": "slug" }
\`\`\`
A **chart-bar** for category comparison and a **chart-line** for a trend:
\`\`\`chart-bar
{ "title": "Users by tenant",
"data": [
{ "label": "acme", "value": 42 },
{ "label": "globex", "value": 18 },
{ "label": "initech", "value": 73 },
{ "label": "umbrella", "value": 11 }
] }
\`\`\`
\`\`\`chart-line
{ "title": "Signups over time",
"series": [
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 },
{ "x": 4, "y": 31 }, { "x": 5, "y": 28 }, { "x": 6, "y": 42 }
] }
\`\`\`
A **chart-donut** for part-to-whole and a **chart-spark** inline:
\`\`\`chart-donut
{ "title": "Status breakdown",
"data": [
{ "label": "active", "value": 38 },
{ "label": "suspended", "value": 4 },
{ "label": "deactivated", "value": 2 }
] }
\`\`\`
\`\`\`chart-spark
{ "values": [3, 5, 4, 8, 12, 9, 14, 11, 18, 16, 22] }
\`\`\`
A **code** block and a **diff**:
\`\`\`code
{ "code": "SELECT slug, count(*) AS users\\nFROM tenants t\\nJOIN users u ON u.tenant_id = t.id\\nWHERE t.status = 'active'\\nGROUP BY slug\\nORDER BY users DESC;",
"language": "sql",
"title": "Active tenants by user count",
"lineNumbers": true }
\`\`\`
\`\`\`diff
{ "oldCode": "max_users: 100\\nplan: free\\n",
"newCode": "max_users: 250\\nplan: pro\\n",
"language": "yaml",
"title": "Tenant quota change",
"mode": "unified" }
\`\`\`
A **flowchart** for control flow and an **orgchart** for hierarchy:
\`\`\`flowchart
{ "nodes": [
{ "id": "a", "type": "start", "label": "Receive request", "x": 80, "y": 20 },
{ "id": "b", "type": "process", "label": "Validate token", "x": 80, "y": 110 },
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 80, "y": 200 },
{ "id": "d", "type": "process", "label": "Process", "x": 260, "y": 200 },
{ "id": "e", "type": "end", "label": "Reject (401)", "x": 80, "y": 310 }
],
"edges": [
{ "from": "a", "to": "b" },
{ "from": "b", "to": "c" },
{ "from": "c", "to": "d", "label": "yes" },
{ "from": "c", "to": "e", "label": "no" }
] }
\`\`\`
\`\`\`orgchart
{ "data": {
"id": "root", "name": "Platform", "title": "Tenant",
"children": [
{ "id": "a", "name": "Auth", "title": "Service",
"children": [
{ "id": "a1", "name": "Sessions", "title": "Module" },
{ "id": "a2", "name": "MFA", "title": "Module" }
] },
{ "id": "b", "name": "Billing", "title": "Service",
"children": [
{ "id": "b1", "name": "Invoices", "title": "Module" }
] }
] } }
\`\`\`
A **steps** trail for a multi-step plan:
\`\`\`steps
{ "steps": [
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
{ "id": "2", "title": "Filter suspended", "status": "running" },
{ "id": "3", "title": "Build report", "status": "queued" },
{ "id": "4", "title": "Email summary", "status": "queued" }
] }
\`\`\`
A **welcome** hero, a **checklist**, and a **hint**:
\`\`\`welcome
{ "title": "Welcome to Arcadia Admin",
"description": "Manage tenants, users, and platform settings from one place.",
"badge": "v2",
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
\`\`\`
\`\`\`checklist
{ "title": "Get started",
"description": "Finish setting up your tenant.",
"tasks": [
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
] }
\`\`\`
\`\`\`hint
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
\`\`\`
And the legacy **card** kinds — pill, stat, callout:
\`\`\`card
{ "kind": "pill", "status": "active", "label": "active" }
\`\`\`
\`\`\`card
{ "kind": "stat", "label": "MRR", "value": "$12.4k" }
\`\`\`
\`\`\`card
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "Suspending a tenant blocks all of its users immediately." }
\`\`\`
Clear the conversation to dismiss the preview.`
const SNAPSHOT_KEY = "crema.ai.snapshot"
// Separate key for the live conversation that survives navigation. The
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
const LIVE_KEY = "crema.ai.live"
function loadLive(): LLMMessage[] | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(LIVE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as LLMMessage[]
} catch {}
return null
}
function saveLive(msgs: LLMMessage[]) {
if (typeof window === "undefined") return
if (msgs.length === 0) {
localStorage.removeItem(LIVE_KEY)
return
}
try {
localStorage.setItem(LIVE_KEY, JSON.stringify(msgs))
} catch {
// Quota exceeded or similar — silently drop persistence.
}
}
/* Per-message agent attribution + the set of agents that have produced
* turns in the current conversation. Persisted alongside LIVE_KEY so a
* reload mid-thread preserves both who-said-what and the hand-off note
* the next turn carries.
*
* Stored as plain JSON shapes (Maps don't serialize):
* AGENTS_KEY: Array<[agentId, Agent]> ← agentHistory
* MSG_AGENTS_KEY: Array<[index, Agent]> ← messageAgents
*/
const AGENTS_KEY = "crema.ai.agent-history"
const MSG_AGENTS_KEY = "crema.ai.message-agents"
function loadAgentHistory(): Map {
if (typeof window === "undefined") return new Map()
try {
const raw = localStorage.getItem(AGENTS_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][])
} catch {}
return new Map()
}
function saveAgentHistory(m: Map) {
if (typeof window === "undefined") return
if (m.size === 0) {
localStorage.removeItem(AGENTS_KEY)
return
}
try {
localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()]))
} catch {}
}
function loadMessageAgents(): Map {
if (typeof window === "undefined") return new Map()
try {
const raw = localStorage.getItem(MSG_AGENTS_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][])
} catch {}
return new Map()
}
function saveMessageAgents(m: Map) {
if (typeof window === "undefined") return
if (m.size === 0) {
localStorage.removeItem(MSG_AGENTS_KEY)
return
}
try {
localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()]))
} catch {}
}
function clearAgentMaps() {
if (typeof window === "undefined") return
localStorage.removeItem(AGENTS_KEY)
localStorage.removeItem(MSG_AGENTS_KEY)
}
function clearLive() {
if (typeof window === "undefined") return
localStorage.removeItem(LIVE_KEY)
}
/* Per-conversation reasoning override. Cycle order matters — the composer
* chip walks this array. Storage helpers (load/save/subscribe) live in
* lib/arcadia/llm-configs.ts so the settings panel and the /ai composer
* coordinate via the same crema.ai.reasoning key. */
const REASONING_LEVELS: ReasoningEffort[] = ["off", "low", "medium", "high", "max"]
function withReasoning>(
extras: T,
effort: ReasoningEffort,
): T & { reasoning_effort?: string } {
if (effort === "off") return extras
return { ...extras, reasoning_effort: effort }
}
type StoredMessage = { role: "user" | "assistant"; content: string }
function loadAISnapshot(): StoredMessage[] | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(SNAPSHOT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as StoredMessage[]
} catch {}
return null
}
function saveAISnapshot(msgs: StoredMessage[]) {
if (typeof window === "undefined") return
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(msgs))
}
function clearAISnapshot() {
if (typeof window === "undefined") return
localStorage.removeItem(SNAPSHOT_KEY)
}
export const meta = () => pageTitle("AI")
const MODEL_KEY = "crema.ai.model"
const PROBE_TIMEOUT_MS = 3000
type Status =
| { kind: "probing" }
| { kind: "live"; models: string[] }
| { kind: "mock"; reason: string }
const mockAdapter = new MockLLM({
label: "Mock",
delayMs: 18,
fallback:
"I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.",
responses: [],
})
function withTimeout(p: Promise, ms: number, signal: AbortSignal) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error("timeout")), ms)
signal.addEventListener("abort", () => {
clearTimeout(t)
reject(new DOMException("Aborted", "AbortError"))
})
p.then(
(v) => {
clearTimeout(t)
resolve(v)
},
(e) => {
clearTimeout(t)
reject(e)
},
)
})
}
export default function AIRoute() {
const settings = useProviderSettings()
const arcadia = useArcadiaClient()
const provider = getProvider(settings.providerId)
const agents = useAgents()
const [status, setStatus] = useState({ kind: "probing" })
const [model, setModel] = useState(() => {
if (typeof window === "undefined") return ""
return localStorage.getItem(MODEL_KEY) ?? ""
})
const [adapter, setAdapter] = useState(mockAdapter)
const [activeAgentId, setActiveAgentIdState] = useState(() =>
loadActiveAgentId(),
)
const setActiveAgentId = useCallback((id: string) => {
saveActiveAgentId(id)
setActiveAgentIdState(id)
}, [])
const activeAgent =
agents.find((a) => a.id === activeAgentId) ?? agents[0]
// When the user changes provider/model in Settings, follow along.
useEffect(() => {
if (settings.model) setModel(settings.model)
}, [settings.providerId, settings.model])
// Resolve the API key from the vault (direct mode) or build the proxy
// adapter (proxy mode), then refresh the model list.
const probe = useCallback(() => {
const ac = new AbortController()
setStatus({ kind: "probing" })
const resolveSecret = async (name: string): Promise => {
const res = await arcadia.GET<{ data: { value: string } }>(
`/api/v1/secrets/${encodeURIComponent(name)}`,
)
return res.data.value
}
const arcadiaBaseURL =
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
const arcadiaTenantId =
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
const arcadiaAuthToken =
typeof window !== "undefined"
? sessionStorage.getItem("arcadia_access_token") ?? undefined
: undefined
;(async () => {
// Build the adapter first so chat works even if the model probe fails.
try {
const a = await buildAdapter({
settings,
resolveSecret,
arcadiaBaseURL,
arcadiaAuthToken,
arcadiaTenantId,
})
setAdapter(a)
} catch {
setAdapter(mockAdapter)
}
// Probe for a live model list. Anthropic has no /models endpoint, so
// fall back to the provider catalog's default models.
if (provider.transport === "anthropic") {
const ids = provider.defaultModels.length
? provider.defaultModels
: ["claude-opus-4-7"]
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
return
}
const baseURL = settings.baseURL || provider.baseURL
let apiKey: string | undefined
if (provider.requiresKey && settings.secretName) {
try {
apiKey = await resolveSecret(settings.secretName)
} catch {
// Fall through; listModels may still work for some providers without a key.
}
}
try {
const rows = await withTimeout(
listModels({ baseURL, apiKey, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
const ids = rows.map((m) => m.id)
if (ids.length === 0) {
setStatus({ kind: "mock", reason: "endpoint returned no models" })
return
}
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
} catch {
// Probe failed but adapter may still be usable; show the catalog default
// models so the user can pick one and just try sending.
if (provider.defaultModels.length) {
setStatus({ kind: "live", models: provider.defaultModels })
setModel((cur) =>
cur && provider.defaultModels.includes(cur)
? cur
: settings.model || provider.defaultModels[0],
)
} else {
setStatus({ kind: "mock", reason: "endpoint unreachable" })
}
}
})()
return () => ac.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
arcadia,
settings.providerId,
settings.baseURL,
settings.secretName,
settings.mode,
settings.model,
provider.transport,
provider.baseURL,
provider.requiresKey,
])
useEffect(() => probe(), [probe])
useEffect(() => {
if (model) localStorage.setItem(MODEL_KEY, model)
}, [model])
const activeModel =
status.kind === "live" ? model || status.models[0] : "mock"
const availableModels = status.kind === "live" ? status.models : ["mock"]
return (
{/* Console aesthetic is scoped to this wrapper only, so the appbar
* and sidebar keep using the global skyrise tokens (light/dark
* toggle still works for them). */}
)
}
function ChatSurface({
models,
model,
onModelChange,
agents,
activeAgent,
onAgentChange,
isMock,
onRetryProbe,
}: {
models: string[]
model: string
onModelChange: (m: string) => void
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
isMock: boolean
onRetryProbe: () => void
}) {
const persona = activeAgent
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
: ""
// Track every agent that has produced turns in the current conversation.
// When the operator switches mid-thread, we augment the system prompt so
// the new agent knows it's stepping into a transcript started by another
// persona — without that note it answers as if it produced every prior
// turn itself, which is jarring.
// Both maps are seeded from localStorage so a reload mid-thread keeps
// attribution + hand-off context intact. Persisted on every change via
// the effect lower down, cleared together with the live snapshot.
const [agentHistory, setAgentHistory] = useState