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,
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 { 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 {
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}
}
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