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:
43
app/routes/activity.tsx
Normal file
43
app/routes/activity.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Activity } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Activity")
|
||||
|
||||
export default function ActivityRoute() {
|
||||
return (
|
||||
<AppShell title="Activity">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Event stream, audit log, recent changes.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
||||
<Activity className="size-6" />
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium">No activity yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Once your app is doing things, this is where audit events,
|
||||
webhook deliveries, and recent changes show up — pair with{" "}
|
||||
<code className="font-mono text-xs">@crema/log-ui</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
96
app/routes/home.tsx
Normal file
96
app/routes/home.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
|
||||
import { Link } from "react-router"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Overview")
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
to: "/assistant",
|
||||
icon: Sparkles,
|
||||
title: "Assistant",
|
||||
body: "AI-first surface — chat, suggestions, and full UI control.",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
to: "/resources",
|
||||
icon: Boxes,
|
||||
title: "Resources",
|
||||
body: "Traditional list + detail surface for managed entities.",
|
||||
},
|
||||
{
|
||||
to: "/activity",
|
||||
icon: Activity,
|
||||
title: "Activity",
|
||||
body: "Event stream and audit log.",
|
||||
},
|
||||
{
|
||||
to: "/library",
|
||||
icon: BookOpen,
|
||||
title: "Library",
|
||||
body: "Saved items, templates, reusable artifacts.",
|
||||
},
|
||||
]
|
||||
|
||||
export default function HomeRoute() {
|
||||
return (
|
||||
<AppShell title="Overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
<CardDescription>
|
||||
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
|
||||
the Assistant can drive the UI on your behalf — try{" "}
|
||||
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘⇧P
|
||||
</kbd>{" "}
|
||||
for the script runner.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<Link
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
data-action={`home-tile-${t.title.toLowerCase()}`}
|
||||
className="group block"
|
||||
>
|
||||
<Card
|
||||
className={[
|
||||
"h-full transition-colors",
|
||||
t.accent
|
||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
: "hover:border-foreground/20",
|
||||
].join(" ")}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t.title}
|
||||
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</CardTitle>
|
||||
<CardDescription>{t.body}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
43
app/routes/library.tsx
Normal file
43
app/routes/library.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Library")
|
||||
|
||||
export default function LibraryRoute() {
|
||||
return (
|
||||
<AppShell title="Library">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Library</CardTitle>
|
||||
<CardDescription>
|
||||
Saved items, templates, and reusable artifacts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
||||
<BookOpen className="size-6" />
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium">Library is empty</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
A home for AI-generated artifacts and saved snippets — pair
|
||||
with <code className="font-mono text-xs">@crema/artifact-ui</code>{" "}
|
||||
when you start producing them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
44
app/routes/resources.tsx
Normal file
44
app/routes/resources.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Boxes } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Resources")
|
||||
|
||||
export default function ResourcesRoute() {
|
||||
return (
|
||||
<AppShell title="Resources">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<CardDescription>
|
||||
A list/detail surface for the entities your app manages.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
||||
<Boxes className="size-6" />
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium">No resources yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This route is the canonical "traditional" surface. Drop in{" "}
|
||||
<code className="font-mono text-xs">@crema/table-ui</code> or{" "}
|
||||
<code className="font-mono text-xs">@crema/data-ui</code> when
|
||||
you have data to show.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
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