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,7 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
@@ -29,12 +28,16 @@ import {
|
||||
import {
|
||||
LLMProvider,
|
||||
MockLLM,
|
||||
OpenAICompatibleAdapter,
|
||||
listModels,
|
||||
useChat,
|
||||
useCompletion,
|
||||
type LLMAdapter,
|
||||
} from "@crema/llm-ui"
|
||||
import {
|
||||
buildAdapter,
|
||||
getProvider,
|
||||
useSettings as useProviderSettings,
|
||||
} from "@crema/llm-providers-ui"
|
||||
import { TypingIndicator } from "@crema/chat-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
@@ -51,7 +54,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
import { useLLMSettings } from "~/lib/llm-settings"
|
||||
import {
|
||||
loadActiveAgentId,
|
||||
saveActiveAgentId,
|
||||
@@ -87,6 +89,37 @@ function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
|
||||
}
|
||||
|
||||
const SNAPSHOT_KEY = "crema.ai.snapshot"
|
||||
// Separate key for the live conversation that survives navigation. The
|
||||
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
|
||||
const LIVE_KEY = "crema.ai.live"
|
||||
|
||||
function loadLive(): LLMMessage[] | null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
const raw = localStorage.getItem(LIVE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed as LLMMessage[]
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
function saveLive(msgs: LLMMessage[]) {
|
||||
if (typeof window === "undefined") return
|
||||
if (msgs.length === 0) {
|
||||
localStorage.removeItem(LIVE_KEY)
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(LIVE_KEY, JSON.stringify(msgs))
|
||||
} catch {
|
||||
// Quota exceeded or similar — silently drop persistence.
|
||||
}
|
||||
}
|
||||
function clearLive() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(LIVE_KEY)
|
||||
}
|
||||
|
||||
type StoredMessage = { role: "user" | "assistant"; content: string }
|
||||
function loadAISnapshot(): StoredMessage[] | null {
|
||||
if (typeof window === "undefined") return null
|
||||
@@ -146,13 +179,16 @@ function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal) {
|
||||
}
|
||||
|
||||
export default function AIRoute() {
|
||||
const settings = useLLMSettings()
|
||||
const settings = useProviderSettings()
|
||||
const arcadia = useArcadiaClient()
|
||||
const provider = getProvider(settings.providerId)
|
||||
const agents = useAgents()
|
||||
const [status, setStatus] = useState<Status>({ kind: "probing" })
|
||||
const [model, setModel] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return ""
|
||||
return localStorage.getItem(MODEL_KEY) ?? ""
|
||||
})
|
||||
const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
|
||||
const [activeAgentId, setActiveAgentIdState] = useState<string>(() =>
|
||||
loadActiveAgentId(),
|
||||
)
|
||||
@@ -163,28 +199,110 @@ export default function AIRoute() {
|
||||
const activeAgent =
|
||||
agents.find((a) => a.id === activeAgentId) ?? agents[0]
|
||||
|
||||
// When the user changes provider/model in Settings, follow along.
|
||||
useEffect(() => {
|
||||
if (settings.model) setModel(settings.model)
|
||||
}, [settings.providerId, settings.model])
|
||||
|
||||
// Resolve the API key from the vault (direct mode) or build the proxy
|
||||
// adapter (proxy mode), then refresh the model list.
|
||||
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 resolveSecret = async (name: string): Promise<string> => {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||
`/api/v1/secrets/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return res.data.value
|
||||
}
|
||||
|
||||
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
|
||||
;(async () => {
|
||||
// Build the adapter first so chat works even if the model probe fails.
|
||||
try {
|
||||
const a = await buildAdapter({
|
||||
settings,
|
||||
resolveSecret,
|
||||
arcadiaBaseURL,
|
||||
arcadiaAuthToken,
|
||||
arcadiaTenantId,
|
||||
})
|
||||
setAdapter(a)
|
||||
} catch {
|
||||
setAdapter(mockAdapter)
|
||||
}
|
||||
|
||||
// Probe for a live model list. Anthropic has no /models endpoint, so
|
||||
// fall back to the provider catalog's default models.
|
||||
if (provider.transport === "anthropic") {
|
||||
const ids = provider.defaultModels.length
|
||||
? provider.defaultModels
|
||||
: ["claude-opus-4-7"]
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
return
|
||||
}
|
||||
|
||||
const baseURL = settings.baseURL || provider.baseURL
|
||||
let apiKey: string | undefined
|
||||
if (provider.requiresKey && settings.secretName) {
|
||||
try {
|
||||
apiKey = await resolveSecret(settings.secretName)
|
||||
} catch {
|
||||
// Fall through; listModels may still work for some providers without a key.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTimeout(
|
||||
listModels({ baseURL, apiKey, signal: ac.signal }),
|
||||
PROBE_TIMEOUT_MS,
|
||||
ac.signal,
|
||||
)
|
||||
const ids = rows.map((m) => m.id)
|
||||
if (ids.length === 0) {
|
||||
setStatus({ kind: "mock", reason: "endpoint returned no models" })
|
||||
return
|
||||
}
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus({ kind: "mock", reason: "endpoint unreachable" })
|
||||
})
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
} catch {
|
||||
// Probe failed but adapter may still be usable; show the catalog default
|
||||
// models so the user can pick one and just try sending.
|
||||
if (provider.defaultModels.length) {
|
||||
setStatus({ kind: "live", models: provider.defaultModels })
|
||||
setModel((cur) =>
|
||||
cur && provider.defaultModels.includes(cur)
|
||||
? cur
|
||||
: settings.model || provider.defaultModels[0],
|
||||
)
|
||||
} else {
|
||||
setStatus({ kind: "mock", reason: "endpoint unreachable" })
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => ac.abort()
|
||||
}, [settings.baseURL])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
arcadia,
|
||||
settings.providerId,
|
||||
settings.baseURL,
|
||||
settings.secretName,
|
||||
settings.mode,
|
||||
settings.model,
|
||||
provider.transport,
|
||||
provider.baseURL,
|
||||
provider.requiresKey,
|
||||
])
|
||||
|
||||
useEffect(() => probe(), [probe])
|
||||
|
||||
@@ -192,16 +310,6 @@ export default function AIRoute() {
|
||||
if (model) localStorage.setItem(MODEL_KEY, model)
|
||||
}, [model])
|
||||
|
||||
const adapter: LLMAdapter = useMemo(() => {
|
||||
if (status.kind === "live") {
|
||||
return new OpenAICompatibleAdapter({
|
||||
baseURL: settings.baseURL,
|
||||
apiKey: settings.apiKey || "lm-studio",
|
||||
})
|
||||
}
|
||||
return mockAdapter
|
||||
}, [status.kind, settings.baseURL, settings.apiKey])
|
||||
|
||||
const activeModel =
|
||||
status.kind === "live" ? model || status.models[0] : "mock"
|
||||
|
||||
@@ -256,10 +364,29 @@ function ChatSurface({
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
const arcadia = useArcadiaClient()
|
||||
// Hydrate from the persisted live conversation so navigating away and
|
||||
// back doesn't reset the chat. Read once on mount.
|
||||
const initialLive = useRef<LLMMessage[] | null>(null)
|
||||
if (initialLive.current === null) {
|
||||
initialLive.current = loadLive() ?? []
|
||||
}
|
||||
const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({
|
||||
system: systemPrompt,
|
||||
initialMessages: initialLive.current,
|
||||
})
|
||||
|
||||
// Persist on every change. Streaming partials get saved too, which is what
|
||||
// we want — refreshing mid-stream restores the partial assistant message.
|
||||
useEffect(() => {
|
||||
saveLive(messages)
|
||||
}, [messages])
|
||||
|
||||
// Wrap reset so "Clear conversation" also drops the persisted snapshot.
|
||||
const resetAndClear = useCallback(() => {
|
||||
reset()
|
||||
clearLive()
|
||||
}, [reset])
|
||||
|
||||
// Auto tool-loop using native function calls. Reads run automatically;
|
||||
// writes are held in `pendingConfirm` until the operator clicks Confirm
|
||||
// or Deny in the inline ConfirmCard.
|
||||
@@ -642,7 +769,7 @@ function ChatSurface({
|
||||
onSaveToLibrary={saveToLibrary}
|
||||
onShowPrompt={() => setShowPromptOpen(true)}
|
||||
onRetryProbe={onRetryProbe}
|
||||
onClear={reset}
|
||||
onClear={resetAndClear}
|
||||
hasMessages={messages.length > 0}
|
||||
hasUserMessage={messages.some((m) => m.role === "user")}
|
||||
hasCompactSnapshot={hasCompactSnapshot}
|
||||
|
||||
Reference in New Issue
Block a user