Wire @crema/llm-providers-ui: multi-provider picker + AI persistence
Replaces the single-base-URL LLM settings with the new providers lib (OpenAI, Anthropic, DeepSeek, Qwen, LM Studio). Settings/LLM hosts the catalog-aware card; the /ai route builds adapters via buildAdapter() and resolves API keys from the arcadia vault per-call (direct mode). Anthropic skips the /v1/models probe (no such endpoint) and uses catalog defaults; failed probes for keyed providers fall back to the catalog instead of dropping to mock. AI conversation now persists across navigation and refresh via a new crema.ai.live localStorage key (separate from the compact-snapshot key). useChat hydrates from initialMessages on mount, saves on every change, and "Clear conversation" wipes both state and storage. Vite needs explicit resolve.alias for @crema/llm-ui and @crema/llm-providers-ui — when a sibling lib imports another @crema/*, tsconfigPaths can't resolve it (the importing file isn't in this project's tsconfig scope). Adds docs/LLM_PROXY_CONTRACT.md describing the POST /api/v1/ai/llm/chat endpoint the backend needs for proxy mode (keys never leave the server). Direct mode works against today's arcadia; proxy mode unblocks once that endpoint ships. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Cpu,
|
||||
Palette,
|
||||
User as UserIcon,
|
||||
@@ -12,6 +9,14 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { listModels } from "@crema/llm-ui"
|
||||
import {
|
||||
buildAdapter,
|
||||
LLMProvidersSettingsCard,
|
||||
resetSettings as resetProviderSettings,
|
||||
useSettings as useProviderSettings,
|
||||
type LLMProvidersSettings,
|
||||
} from "@crema/llm-providers-ui"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
@@ -22,15 +27,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
DEFAULT_SYSTEM_PROMPT,
|
||||
saveLLMSettings,
|
||||
useLLMSettings,
|
||||
type LLMSettings,
|
||||
} from "~/lib/llm-settings"
|
||||
import {
|
||||
loadActiveAgentId,
|
||||
newAgentId,
|
||||
@@ -71,53 +67,94 @@ const sections: {
|
||||
{ id: "about", label: "About", icon: Info, description: "Version & credits" },
|
||||
]
|
||||
|
||||
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" })
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(settings)
|
||||
}, [settings])
|
||||
|
||||
const runTest = async () => {
|
||||
setTest({ kind: "running" })
|
||||
const ac = new AbortController()
|
||||
const timeout = setTimeout(() => ac.abort(), 4000)
|
||||
const testConnection = async (
|
||||
s: LLMProvidersSettings,
|
||||
): Promise<{ ok: boolean; message: string }> => {
|
||||
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),
|
||||
const arcadiaBaseURL =
|
||||
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
|
||||
const arcadiaTenantId =
|
||||
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
|
||||
const arcadiaAuthToken =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token") ?? undefined
|
||||
: undefined
|
||||
|
||||
const adapter = await buildAdapter({
|
||||
settings: s,
|
||||
// Direct-mode resolver — fetches the API key from the vault.
|
||||
resolveSecret: async (name) => {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||
`/api/v1/secrets/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return res.data.value
|
||||
},
|
||||
// Proxy-mode coordinates.
|
||||
arcadiaBaseURL,
|
||||
arcadiaAuthToken,
|
||||
arcadiaTenantId,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
|
||||
// In proxy mode the adapter just being built is the strongest signal we
|
||||
// can get without actually firing a chat request — the proxy endpoint
|
||||
// doesn't exist on the backend yet, so any /models probe would 404.
|
||||
if (s.mode === "proxy") {
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
"Adapter built. Note: the backend proxy (/api/v1/ai/llm/chat) isn't deployed yet — see docs/LLM_PROXY_CONTRACT.md.",
|
||||
}
|
||||
}
|
||||
|
||||
// Direct mode — for OpenAI-compatible endpoints, /models is a cheap probe.
|
||||
if (s.providerId !== "anthropic") {
|
||||
const baseURL =
|
||||
s.baseURL ||
|
||||
(s.providerId === "lmstudio"
|
||||
? "http://localhost:1234/v1"
|
||||
: s.providerId === "openai"
|
||||
? "https://api.openai.com/v1"
|
||||
: s.providerId === "deepseek"
|
||||
? "https://api.deepseek.com/v1"
|
||||
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
|
||||
// Resolve key for the probe (lmstudio doesn't need one).
|
||||
let apiKey: string | undefined
|
||||
if (s.providerId !== "lmstudio" && s.secretName) {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||
`/api/v1/secrets/${encodeURIComponent(s.secretName)}`,
|
||||
)
|
||||
apiKey = res.data.value
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/404|not[_ ]found/i.test(msg)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `No vault secret named "${s.secretName}". Create it under /secrets first (paste the API key as the Value), then enter the secret's name here.`,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 5000)
|
||||
try {
|
||||
const rows = await listModels({ baseURL, apiKey, signal: ac.signal })
|
||||
return { ok: true, message: `Connected. ${rows.length} model(s) reachable.` }
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
// Anthropic doesn't expose a /models list; we just confirm adapter built.
|
||||
return { ok: true, message: `Adapter ready (${adapter.label ?? adapter.id}).` }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const [section, setSection] = useState<SectionId>(() => {
|
||||
if (typeof window === "undefined") return "llm"
|
||||
const stored = localStorage.getItem(SECTION_KEY)
|
||||
@@ -173,151 +210,36 @@ export default function SettingsRoute() {
|
||||
|
||||
<div className="min-w-0">
|
||||
{section === "llm" && (
|
||||
<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"
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>LLM</CardTitle>
|
||||
<CardDescription>
|
||||
Pick a provider, model, and the arcadia-vault secret holding the API key. Settings
|
||||
auto-save as you type. The Assistant picks them up on the next message.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LLMProvidersSettingsCard
|
||||
onTest={testConnection}
|
||||
hideTransportToggle={false}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Field
|
||||
label="Context window (tokens)"
|
||||
hint="Match this to the context length you've loaded in LM Studio."
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => resetProviderSettings()}
|
||||
data-action="settings-reset"
|
||||
>
|
||||
<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="System prompt"
|
||||
hint="Sent at the start of every conversation. Shapes the assistant's persona and scope. UI Control adds an action-driving preface on top of this when enabled."
|
||||
>
|
||||
<Textarea
|
||||
data-action="settings-system-prompt"
|
||||
value={draft.systemPrompt}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, systemPrompt: e.target.value }))
|
||||
}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-action="settings-system-prompt-reset"
|
||||
onClick={() =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
||||
}))
|
||||
}
|
||||
className="self-start text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Reset to default prompt
|
||||
</button>
|
||||
</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>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Need to manage stored keys? See <a href="/secrets" className="underline">Secrets</a>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "agents" && <AgentsPanel />}
|
||||
|
||||
Reference in New Issue
Block a user