import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { RefreshCw, Square } from "lucide-react" const PROBE_TIMEOUT_MS = 3000 function withTimeout(p: Promise, ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => { reject(new Error(`timeout after ${ms}ms`)) }, ms) signal.addEventListener("abort", () => { clearTimeout(t) reject(new DOMException("Aborted", "AbortError")) }) p.then( (v) => { clearTimeout(t) resolve(v) }, (e) => { clearTimeout(t) reject(e) }, ) }) } import { LLMProvider, MockLLM, OpenAICompatibleAdapter, listModels, useChat, type LLMAdapter, } from "@crema/llm-ui" import { ChatBubble, TypingIndicator } from "@crema/chat-ui" import { CommandBar } from "@crema/aifirst-ui" import { AppShell } from "~/components/layout/app-shell" import { MessageBody } from "~/components/assistant/message-body" import { Button } from "~/components/ui/button" import { buildSystemPrompt, estimateTokens, runActionBlocks, trimMessages, } from "@crema/action-bus" import { useLLMSettings } from "~/lib/llm-settings" import { pageTitle } from "~/lib/page-meta" export const meta = () => pageTitle("Assistant") const STORAGE_KEY = "crema.assistant.model" const UI_CONTROL_KEY = "crema.assistant.uiControl" 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: [ { matches: (req) => /hello|hi\b|hey/i.test(req.messages.at(-1)?.content ?? ""), response: "Hi — I'm the mock assistant. Try asking me anything; I'll stream a generic reply.", }, { matches: (req) => /(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test( req.messages.at(-1)?.content ?? "", ), response: [ "On it.\n\n", "```action\n", "navigate /resources\n", "```\n", ], }, ], }) export default function AssistantRoute() { const settings = useLLMSettings() const [status, setStatus] = useState({ kind: "probing" }) const [model, setModel] = useState(() => { if (typeof window === "undefined") return "mock" return localStorage.getItem(STORAGE_KEY) ?? "" }) 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: "LM Studio returned no models" }) return } setStatus({ kind: "live", models: ids }) setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0])) }) .catch((err: unknown) => { if ((err as DOMException)?.name === "AbortError") return setStatus({ kind: "mock", reason: err instanceof Error ? err.message : "LM Studio unreachable", }) }) return () => ac.abort() }, [settings.baseURL]) useEffect(() => probe(), [probe]) useEffect(() => { if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model) }, [model]) const adapter: LLMAdapter = useMemo( () => status.kind === "live" ? new OpenAICompatibleAdapter({ baseURL: settings.baseURL }) : mockAdapter, [status.kind, settings.baseURL], ) const activeModel = status.kind === "live" ? model || status.models[0] : "mock" return ( ) } function AssistantSurface({ status, model, onModelChange, contextTokens, responseBudget, baseURL, onRetryProbe, }: { status: Status model: string onModelChange: (m: string) => void contextTokens: number responseBudget: number baseURL: string onRetryProbe: () => void }) { const [uiControl, setUiControl] = useState(() => { if (typeof window === "undefined") return false return localStorage.getItem(UI_CONTROL_KEY) === "1" }) const [actionLog, setActionLog] = useState(null) useEffect(() => { localStorage.setItem(UI_CONTROL_KEY, uiControl ? "1" : "0") }, [uiControl]) const { messages, send, abort, isStreaming, error, reset } = useChat({ system: "You are a concise assistant inside this app. Prefer short, clear answers.", }) const scrollerRef = useRef(null) const lastContent = messages.at(-1)?.content useEffect(() => { const el = scrollerRef.current if (!el) return el.scrollTop = el.scrollHeight }, [messages.length, lastContent, isStreaming]) // Run action blocks when an assistant turn completes (and UI control is on). const wasStreaming = useRef(false) useEffect(() => { if (wasStreaming.current && !isStreaming) { const last = messages.at(-1) if (uiControl && last?.role === "assistant" && last.content) { void runActionBlocks(last.content).then((res) => { if (res.ran > 0) { setActionLog(`Ran ${res.ran} action block${res.ran > 1 ? "s" : ""}.`) } else if (res.errors.length > 0) { setActionLog(`Action error: ${res.errors[0]}`) } }) } } wasStreaming.current = isStreaming }, [isStreaming, messages, uiControl]) const handleSend = (text: string) => { const system = uiControl ? buildSystemPrompt({ path: window.location.pathname }) : "You are a concise assistant inside this app. Prefer short, clear answers." 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, { system, messages: trimmed, maxTokens: responseBudget, }) } const usedTokens = useMemo(() => { const system = uiControl ? buildSystemPrompt({ path: typeof window !== "undefined" ? window.location.pathname : "" }) : "" const sysT = estimateTokens(system) const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0) return sysT + histT }, [messages, uiControl]) const suggestions = uiControl ? [ "Take me to the resources page", "Show me what's on the screen", "Open the settings", "Go back to overview", ] : [ "What can this app do?", "Draft a release note", "Summarize this week's activity", "Show me a SQL example", ] return (
{status.kind === "probing" && "Probing…"} {status.kind === "live" && `Live · ${baseURL.replace(/^https?:\/\//, "")}`} {status.kind === "mock" && `Mock LLM · ${status.reason}`} {status.kind === "mock" && ( )} contextTokens - responseBudget ? "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300" : "bg-muted text-muted-foreground" }`} title={`System + history estimate. Response capped at ${responseBudget}.`} > {usedTokens} / {contextTokens}
{status.kind === "live" ? ( ) : ( mock )} {isStreaming ? ( ) : ( )}
{uiControl && (
UI control is on. The assistant can navigate, click, and fill on your behalf. Watch the cursor.
)}
{messages.length === 0 ? ( ) : (
{messages.map((m, i) => m.role === "user" ? ( ) : (
Assistant
{m.content ? ( ) : isStreaming && i === messages.length - 1 ? ( "…" ) : null}
), )} {isStreaming && messages.at(-1)?.role !== "assistant" && ( )}
)}
{actionLog && (
{actionLog}
)} {error && (
{error.message}
)}
) } function StatusDot({ status }: { status: Status }) { const color = status.kind === "probing" ? "bg-muted-foreground/40" : status.kind === "live" ? "bg-green-500" : "bg-amber-500" return ( ) } function EmptyState({ uiControl }: { uiControl: boolean }) { return (

{uiControl ? "Tell me what you'd like to do." : "How can I help?"}

{uiControl ? "I can navigate, click buttons, and fill forms. Watch the cursor." : "Pick a suggestion below or type a question."}

) }