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>
204 lines
5.7 KiB
TypeScript
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>
|
|
)
|
|
}
|