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>
This commit is contained in:
441
app/routes/assistant.tsx
Normal file
441
app/routes/assistant.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user