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" 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 ? (