Files
crema-app-aifirst-template/app/routes/assistant.tsx
jules 286e2daf95 feat: initial commit — crema-app-aifirst-template
Hybrid traditional + AI-first webapp scaffold. Sibling to crema-app-template,
adds the AI assistant surface, command bus, scripts dialog, and virtual
cursor.

What's pre-wired:
- 6 routes: Overview, Resources, Activity, Assistant, Library, Settings
- Collapsible rail + appbar + avatar dropdown shell (template code, not a lib)
- Mobile sheet at <md
- /assistant: streaming chat via @crema/llm-ui, mock fallback, model selector,
  token meter, retry probe, stop-while-streaming, persistent UI Control toggle
- /settings: editable LM Studio endpoint + context window + response cap, with
  test-connection button
- Markdown rendering for assistant replies; ```action``` blocks rendered as a
  small "Ran N actions" pill
- ⌘⇧P script runner dialog + Play icon in the appbar
- Two demo scripts in public/scripts/
- mightypix theme as default, scoped via <AppShell theme="mightypix">

Libs wired in tsconfig + app.css:
- @crema/action-bus (the bus, parser, runner, cursor, provider, ws, llm-bridge)
- @crema/llm-ui, @crema/chat-ui, @crema/aifirst-ui, @crema/notification-ui
- lib-theme-mightypix

Docs:
- README.md — pitch + quick start + structure
- docs/AI_FIRST.md — full system tour (data-action contract, bus, DSL, scripts,
  cursor, LLM integration)
- app/components/layout/THEME_CONTRACT.md — every CSS variable a theme must declare
- CLAUDE.md — orientation for an LLM working in the repo

Genericized from comfy-cloud (the original prototype):
- Brand defaults to "App" / Sparkles icon (override via app/lib/identity.ts)
- User defaults to a stub (swap useUser() for real auth)
- localStorage namespace is "crema.*" (was "comfy.*")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:31:46 +10:00

442 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { RefreshCw, Square } from "lucide-react"
const PROBE_TIMEOUT_MS = 3000
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
return new Promise<T>((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<Status>({ kind: "probing" })
const [model, setModel] = useState<string>(() => {
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 (
<AppShell title="Assistant" theme="mightypix">
<LLMProvider adapter={adapter} model={activeModel}>
<AssistantSurface
status={status}
model={model}
onModelChange={setModel}
contextTokens={settings.contextTokens}
responseBudget={settings.responseBudget}
baseURL={settings.baseURL}
onRetryProbe={probe}
/>
</LLMProvider>
</AppShell>
)
}
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<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(UI_CONTROL_KEY) === "1"
})
const [actionLog, setActionLog] = useState<string | null>(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<HTMLDivElement>(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 (
<div className="flex h-[calc(100svh-3.5rem-3rem)] flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card/50 px-3 py-2">
<StatusDot status={status} />
<span className="text-sm text-muted-foreground">
{status.kind === "probing" && "Probing…"}
{status.kind === "live" && `Live · ${baseURL.replace(/^https?:\/\//, "")}`}
{status.kind === "mock" && `Mock LLM · ${status.reason}`}
</span>
{status.kind === "mock" && (
<Button
data-action="assistant-retry-probe"
variant="ghost"
size="icon-sm"
onClick={onRetryProbe}
aria-label="Retry connection"
title="Retry connection to LM Studio"
>
<RefreshCw className="size-4" />
</Button>
)}
<span
className={`rounded-md border px-2 py-1 text-xs tabular-nums ${
usedTokens > 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}
</span>
<div className="ml-auto flex items-center gap-2">
{status.kind === "live" ? (
<select
data-action="assistant-model"
value={model || status.models[0]}
onChange={(e) => onModelChange(e.target.value)}
className="h-8 rounded-md border bg-background px-2 text-sm"
>
{status.models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<span className="rounded-md border bg-muted px-2 py-1 text-xs">
mock
</span>
)}
<Button
data-action="assistant-ui-control"
variant={uiControl ? "default" : "outline"}
size="sm"
onClick={() => setUiControl((v) => !v)}
title="Let the assistant drive the UI on your behalf"
>
{uiControl ? "UI Control: ON" : "Enable UI Control"}
</Button>
{isStreaming ? (
<Button
data-action="assistant-stop"
variant="outline"
size="sm"
onClick={abort}
>
<Square className="size-3 fill-current" /> Stop
</Button>
) : (
<Button
data-action="assistant-clear"
variant="ghost"
size="sm"
onClick={reset}
disabled={messages.length === 0}
>
Clear
</Button>
)}
</div>
</div>
{uiControl && (
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground/80">
UI control is on. The assistant can navigate, click, and fill on your
behalf. Watch the cursor.
</div>
)}
<div
ref={scrollerRef}
className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4"
>
{messages.length === 0 ? (
<EmptyState uiControl={uiControl} />
) : (
<div className="flex flex-col gap-3">
{messages.map((m, i) =>
m.role === "user" ? (
<ChatBubble
key={i}
content={m.content}
sender="user"
name="You"
/>
) : (
<div key={i} className="flex flex-row gap-2">
<div className="flex max-w-[80%] flex-col items-start">
<span className="mb-0.5 px-1 text-xs font-medium text-muted-foreground">
Assistant
</span>
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground">
{m.content ? (
<MessageBody content={m.content} />
) : isStreaming && i === messages.length - 1 ? (
"…"
) : null}
</div>
</div>
</div>
),
)}
{isStreaming && messages.at(-1)?.role !== "assistant" && (
<TypingIndicator />
)}
</div>
)}
</div>
{actionLog && (
<div className="rounded-lg border bg-card/40 px-3 py-2 text-xs text-muted-foreground">
{actionLog}
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error.message}
</div>
)}
<CommandBar
chips={suggestions}
position="bottom"
placeholder={
uiControl ? "Ask me to do something…" : "Ask the assistant…"
}
onSubmit={handleSend}
onChipSelect={handleSend}
/>
</div>
)
}
function StatusDot({ status }: { status: Status }) {
const color =
status.kind === "probing"
? "bg-muted-foreground/40"
: status.kind === "live"
? "bg-green-500"
: "bg-amber-500"
return (
<span
aria-hidden
className={`size-2 rounded-full ${color}`}
data-status={status.kind}
/>
)
}
function EmptyState({ uiControl }: { uiControl: boolean }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<p
style={{ fontFamily: "var(--font-ai-prose)" }}
className="text-lg text-foreground"
>
{uiControl ? "Tell me what you'd like to do." : "How can I help?"}
</p>
<p className="text-sm text-muted-foreground">
{uiControl
? "I can navigate, click buttons, and fill forms. Watch the cursor."
: "Pick a suggestion below or type a question."}
</p>
</div>
)
}