Files
crema-app-aifirst-template/app/routes/settings.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

204 lines
5.7 KiB
TypeScript

import { useEffect, useState } from "react"
import { Check, X, Loader2 } from "lucide-react"
import { listModels } from "@crema/llm-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import {
DEFAULT_SETTINGS,
saveLLMSettings,
useLLMSettings,
type LLMSettings,
} from "~/lib/llm-settings"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Settings")
type TestState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "ok"; count: number }
| { kind: "fail"; reason: string }
export default function SettingsRoute() {
const settings = useLLMSettings()
const [draft, setDraft] = useState<LLMSettings>(settings)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [test, setTest] = useState<TestState>({ kind: "idle" })
useEffect(() => {
setDraft(settings)
}, [settings])
const runTest = async () => {
setTest({ kind: "running" })
const ac = new AbortController()
const timeout = setTimeout(() => ac.abort(), 4000)
try {
const rows = await listModels({ baseURL: draft.baseURL, signal: ac.signal })
setTest({ kind: "ok", count: rows.length })
} catch (e) {
setTest({
kind: "fail",
reason: e instanceof Error ? e.message : String(e),
})
} finally {
clearTimeout(timeout)
}
}
const dirty =
draft.baseURL !== settings.baseURL ||
draft.contextTokens !== settings.contextTokens ||
draft.responseBudget !== settings.responseBudget
const save = () => {
saveLLMSettings(draft)
setSavedAt(Date.now())
}
const reset = () => {
setDraft(DEFAULT_SETTINGS)
}
return (
<AppShell title="Settings">
<Card>
<CardHeader>
<CardTitle>LLM</CardTitle>
<CardDescription>
Configure the local model endpoint and context budgets used by the
Assistant.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<Field
label="Base URL"
hint="OpenAI-compatible endpoint. LM Studio defaults to http://localhost:1234/v1."
>
<Input
data-action="settings-base-url"
value={draft.baseURL}
onChange={(e) =>
setDraft((d) => ({ ...d, baseURL: e.target.value }))
}
placeholder="http://localhost:1234/v1"
spellCheck={false}
autoComplete="off"
/>
</Field>
<Field
label="Context window (tokens)"
hint="Match this to the context length you've loaded in LM Studio."
>
<Input
data-action="settings-context-tokens"
type="number"
min={1024}
step={512}
value={draft.contextTokens}
onChange={(e) =>
setDraft((d) => ({
...d,
contextTokens: Number(e.target.value) || d.contextTokens,
}))
}
/>
</Field>
<Field
label="Response cap (max tokens)"
hint="Upper bound on each model reply. Smaller = faster, less rambling."
>
<Input
data-action="settings-response-budget"
type="number"
min={64}
step={64}
value={draft.responseBudget}
onChange={(e) =>
setDraft((d) => ({
...d,
responseBudget: Number(e.target.value) || d.responseBudget,
}))
}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="settings-save"
onClick={save}
disabled={!dirty}
>
Save
</Button>
<Button
data-action="settings-test"
variant="outline"
onClick={runTest}
disabled={test.kind === "running"}
>
{test.kind === "running" ? (
<Loader2 className="size-4 animate-spin" />
) : test.kind === "ok" ? (
<Check className="size-4 text-emerald-600" />
) : test.kind === "fail" ? (
<X className="size-4 text-destructive" />
) : null}
Test connection
</Button>
<Button
data-action="settings-reset"
variant="outline"
onClick={reset}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
<span className="text-sm text-muted-foreground">Saved.</span>
)}
{test.kind === "ok" && (
<span className="text-sm text-emerald-700 dark:text-emerald-400">
{test.count} model{test.count === 1 ? "" : "s"} available.
</span>
)}
{test.kind === "fail" && (
<span className="text-sm text-destructive" title={test.reason}>
Failed: {test.reason.slice(0, 60)}
</span>
)}
</div>
</CardContent>
</Card>
</AppShell>
)
}
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{children}
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</label>
)
}