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:
203
app/routes/settings.tsx
Normal file
203
app/routes/settings.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user