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>
442 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|