import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import {
Archive,
ArrowRight,
BookmarkPlus,
ChevronDown,
Command as CommandIcon,
Copy,
Download,
FileText,
Loader2,
Mic,
MicOff,
Plus,
RefreshCw,
RotateCcw,
Square,
Trash2,
Undo2,
X,
} from "lucide-react"
import {
LLMProvider,
MockLLM,
OpenAICompatibleAdapter,
listModels,
useChat,
useCompletion,
type LLMAdapter,
} from "@crema/llm-ui"
import { TypingIndicator } from "@crema/chat-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 { useLLMSettings } from "~/lib/llm-settings"
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 {
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 { 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}
}
const SNAPSHOT_KEY = "crema.ai.snapshot"
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 = useLLMSettings()
const agents = useAgents()
const [status, setStatus] = useState({ kind: "probing" })
const [model, setModel] = useState(() => {
if (typeof window === "undefined") return ""
return localStorage.getItem(MODEL_KEY) ?? ""
})
const [activeAgentId, setActiveAgentIdState] = useState(() =>
loadActiveAgentId(),
)
const setActiveAgentId = useCallback((id: string) => {
saveActiveAgentId(id)
setActiveAgentIdState(id)
}, [])
const activeAgent =
agents.find((a) => a.id === activeAgentId) ?? agents[0]
const probe = useCallback(() => {
const ac = new AbortController()
setStatus({ kind: "probing" })
withTimeout(
listModels({ baseURL: settings.baseURL, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
.then((rows) => {
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 : ids[0]))
})
.catch(() => {
setStatus({ kind: "mock", reason: "endpoint unreachable" })
})
return () => ac.abort()
}, [settings.baseURL])
useEffect(() => probe(), [probe])
useEffect(() => {
if (model) localStorage.setItem(MODEL_KEY, model)
}, [model])
const adapter: LLMAdapter = useMemo(() => {
if (status.kind === "live") {
return new OpenAICompatibleAdapter({
baseURL: settings.baseURL,
apiKey: settings.apiKey || "lm-studio",
})
}
return mockAdapter
}, [status.kind, settings.baseURL, settings.apiKey])
const activeModel =
status.kind === "live" ? model || status.models[0] : "mock"
const availableModels = status.kind === "live" ? status.models : ["mock"]
return (
)
}
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}`
: ""
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. Reads run automatically;
// writes are held in `pendingConfirm` until the operator clicks Confirm
// or Deny in the inline ConfirmCard.
const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming)
const MAX_TOOL_ITERATIONS = 3
const [pendingConfirm, setPendingConfirm] = useState<{
/** Message index that emitted the write calls. */
afterIndex: number
writes: ToolCall[]
readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
} | null>(null)
const [confirmBusy, setConfirmBusy] = useState(false)
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 { reads, writes } = classifyCalls(calls)
const { toolMessages: readMsgs } =
reads.length > 0
? await runLLMToolCalls(reads, { arcadia })
: { toolMessages: [] }
if (writes.length > 0) {
setPendingConfirm({ afterIndex: lastIdx, writes, readMessages: readMsgs })
return
}
void continueChat(readMsgs, {
system: systemPrompt,
tools: getOpenAITools(),
})
})()
}, [messages, isStreaming, arcadia, continueChat, systemPrompt])
const onConfirmWrites = useCallback(async () => {
if (!pendingConfirm) return
setConfirmBusy(true)
try {
const { toolMessages: writeMsgs } = await runLLMToolCalls(
pendingConfirm.writes,
{ arcadia },
{ allowWrites: true },
)
void continueChat([...pendingConfirm.readMessages, ...writeMsgs], {
system: systemPrompt,
tools: getOpenAITools(),
})
} finally {
setPendingConfirm(null)
setConfirmBusy(false)
}
}, [pendingConfirm, arcadia, continueChat, systemPrompt])
const onDenyWrites = useCallback(() => {
if (!pendingConfirm) return
const denials = buildDenialMessages(pendingConfirm.writes)
void continueChat([...pendingConfirm.readMessages, ...denials], {
system: systemPrompt,
tools: getOpenAITools(),
})
setPendingConfirm(null)
}, [pendingConfirm, continueChat, systemPrompt])
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
const [input, setInput] = useState("")
const [showPromptOpen, setShowPromptOpen] = useState(false)
const [hasCompactSnapshot, setHasCompactSnapshot] = useState(
() => !!loadAISnapshot(),
)
const hasAssistantReply = messages.some((m) => m.role === "assistant")
const buildTranscript = useCallback(() => {
const lines: string[] = [
`# Conversation`,
"",
activeAgent
? `**Persona:** ${activeAgent.name} — ${activeAgent.role}`
: "",
`**Date:** ${new Date().toISOString()}`,
"",
].filter(Boolean)
for (const m of messages) {
lines.push(`### ${m.role === "user" ? "User" : "Assistant"}`)
lines.push("")
lines.push(m.content.trim())
lines.push("")
}
return lines.join("\n")
}, [messages, activeAgent])
const copyMarkdown = useCallback(async () => {
if (messages.length === 0) return
try {
await navigator.clipboard.writeText(buildTranscript())
} catch {}
}, [buildTranscript, messages.length])
const exportMarkdown = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
a.download = `ai-${stamp}.md`
a.click()
URL.revokeObjectURL(url)
}, [buildTranscript, messages.length])
const saveToLibrary = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
addLibraryItem({
kind: "conversation",
title:
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
"AI conversation",
content: md,
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
agentName: activeAgent?.name,
agentRole: activeAgent?.role,
messageCount: messages.length,
})
}, [buildTranscript, messages, activeAgent])
const regenerateLast = useCallback(() => {
if (isStreaming) return
let lastUserIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
lastUserIdx = i
break
}
}
if (lastUserIdx === -1) return
const text = messages[lastUserIdx].content
setMessages(messages.slice(0, lastUserIdx))
// Defer so the state flush completes before send() reads `messages`.
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.", { tools: getOpenAITools() })
}, [isStreaming, messages.length, send])
const compactConversation = useCallback(async () => {
if (compacting || isStreaming || messages.length < 2) return
const transcript = messages
.map(
(m) =>
`${m.role === "user" ? "User" : "Assistant"}: ${m.content.trim()}`,
)
.join("\n\n")
const summarySystem =
"You compress conversations. Output a tight 1–2 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings."
try {
const summary = await completeOneShot(
[
{ role: "system", content: summarySystem },
{
role: "user",
content: `Summarize this conversation:\n\n${transcript}`,
},
],
{ maxTokens: 800 },
)
// Snapshot first so Restore can undo.
saveAISnapshot(messages as StoredMessage[])
setHasCompactSnapshot(true)
setMessages([
{
role: "assistant",
content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`,
},
])
} catch {}
}, [compacting, isStreaming, messages, completeOneShot, setMessages])
const restoreCompact = useCallback(() => {
const snap = loadAISnapshot()
if (!snap) return
setMessages(snap)
clearAISnapshot()
setHasCompactSnapshot(false)
}, [setMessages])
const endRef = useRef(null)
const composerRef = useRef(null)
const lastContent = messages.at(-1)?.content ?? ""
// Track the composer's actual height so the auto-scroll sentinel can
// keep the latest text ~24px above its top edge regardless of how many
// lines the textarea has grown to.
const [composerHeight, setComposerHeight] = useState(160)
useEffect(() => {
const el = composerRef.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(([entry]) => {
if (entry) setComposerHeight(entry.contentRect.height)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// Auto-stick to the bottom only when the user is already near it. If they
// scroll up to read earlier turns mid-stream, don't yank them back down.
// The ref dodges React render cycles so scroll events feel instant.
const stickRef = useRef(true)
useEffect(() => {
const onScroll = () => {
const distFromBottom =
document.documentElement.scrollHeight -
(window.scrollY + window.innerHeight)
stickRef.current = distFromBottom < 120
}
window.addEventListener("scroll", onScroll, { passive: true })
onScroll()
return () => window.removeEventListener("scroll", onScroll)
}, [])
useEffect(() => {
if (!stickRef.current) return
endRef.current?.scrollIntoView({ block: "end" })
}, [messages.length, lastContent, isStreaming])
const submit = useCallback(() => {
const text = input.trim()
if (!text || isStreaming) return
setInput("")
stickRef.current = true
void send(text, { tools: getOpenAITools() })
}, [input, isStreaming, send])
const isEmpty = messages.length === 0
return (
{/* Greeting — only when empty, fades out as composer slides down */}
How can I help you today?
{/* Messages — rendered when there are any. In empty state a flex-grow
* spacer takes its place so the sticky-bottom composer lands at the
* actual bottom of the surface (otherwise it'd sit at the top with
* nothing above it, and the lift transform would push it off-screen). */}
{isEmpty ? (
) : (
)
}
function deriveAgentActivity({
isStreaming,
lastMessage,
pendingConfirm,
confirmBusy,
}: {
isStreaming: boolean
lastMessage: LLMMessage | undefined
pendingConfirm: boolean
confirmBusy: boolean
}): "idle" | "thinking" | "working" | "waiting" | "speaking" {
if (confirmBusy) return "working"
if (pendingConfirm) return "waiting"
if (!isStreaming) return "idle"
if (!lastMessage || lastMessage.role !== "assistant") return "thinking"
if (lastMessage.content.trim().length > 0) return "speaking"
return "thinking"
}
function buildAgentToolCall(
tc: ToolCall,
allMessages: LLMMessage[],
isStreaming: boolean,
pendingConfirm: boolean,
): AgentToolCall {
const result = allMessages.find(
(m) => m.role === "tool" && m.toolCallId === tc.id,
)
let parsedArgs: Record | undefined
try {
parsedArgs = tc.arguments ? JSON.parse(tc.arguments) : undefined
} catch {
parsedArgs = undefined
}
if (result) {
let parsedResult: unknown = result.content
let errorMsg: string | undefined
try {
const obj = JSON.parse(result.content) as Record
if (obj && typeof obj === "object" && typeof obj.error === "string") {
errorMsg = obj.error
} else {
parsedResult = obj
}
} catch {
// leave as raw text
}
return {
id: tc.id,
name: tc.name,
status: errorMsg ? "error" : "success",
args: parsedArgs,
result: errorMsg ? undefined : parsedResult,
error: errorMsg,
}
}
let status: ToolCallStatus = "running"
if (pendingConfirm) status = "pending"
else if (!isStreaming) status = "running"
return {
id: tc.id,
name: tc.name,
status,
args: parsedArgs,
}
}
function MessageRow({
role,
content,
toolCalls,
}: {
role: "user" | "assistant"
content: string
toolCalls?: ToolCall[]
}) {
if (role === "user") {
return (
{content}
)
}
// Assistant messages with only tool_calls and no prose render nothing here —
// the ToolCallCards beneath them carry the visual weight.
if (!content.trim()) return null
return (