fix: chat works with any configured provider + surfaces errors

The chat defaulted to a Claude model, so a user who saved only a Gemini key got
a silent failure (backend derived Anthropic, found no key, errored — and the UI
showed nothing).

- Backend: resolveModel now falls back to whichever provider actually has a key
  (preferring the configured one), so a Gemini key just works regardless of the
  picked model. Clear 400 if no provider is configured at all.
- Frontend: the chat seeds its model/effort from the saved AI config, and now
  surfaces request failures as both a persistent alert banner and a toast —
  never silent.
- Settings: switching provider auto-selects that provider's default model.
- Refreshed the model catalog to current ids (Gemini 2.5 Pro/Flash + 2.0 Flash;
  dropped the retired gemini-1.5-pro); per-provider default model updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-13 19:27:37 +03:00
parent a80086ef72
commit ddf4b49d82
5 changed files with 139 additions and 32 deletions
+54 -3
View File
@@ -2,6 +2,7 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { AlertTriangle } from "lucide-react";
import { nanoid } from "nanoid";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -38,8 +39,10 @@ import {
getModel,
} from "@/lib/ai-models";
import type { TemetroUIMessage } from "@/lib/ai-chat";
import { getAiConfig } from "@/lib/ai-settings";
import { API_BASE_URL } from "@/lib/api-client";
import { getPatient } from "@/lib/patients";
import { notify } from "@/lib/toast";
// Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that
// pulls records instantly without the LLM (also works offline).
@@ -65,9 +68,36 @@ export function ChatPanel() {
[],
);
const { messages, setMessages, sendMessage, status, stop } =
const { messages, setMessages, sendMessage, status, stop, error } =
useChat<TemetroUIMessage>({ transport });
// Seed the model + effort from the user's saved AI config so the chat uses the
// provider they actually configured (e.g. their Gemini default), not a stale
// hardcoded default.
useEffect(() => {
let cancelled = false;
getAiConfig()
.then((cfg) => {
if (cancelled) return;
setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel);
setEffort(cfg.defaultEffort);
})
.catch(() => {
// Keep defaults; the chat still works and the backend falls back to any
// configured provider.
});
return () => {
cancelled = true;
};
}, []);
// Pop a toast whenever a request errors, so failures are never silent.
useEffect(() => {
if (error) {
notify.error(t("chat.error.title"), error.message || t("chat.error.body"));
}
}, [error, t]);
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
// Run the LLM agent for a message (after any consent gate).
@@ -158,6 +188,21 @@ export function ChatPanel() {
/>
);
const errorAlert = error ? (
<div
className="flex w-full items-start gap-2 rounded-2xl border border-destructive/40 bg-destructive/8 px-4 py-3 text-sm text-destructive-foreground"
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">{t("chat.error.title")}</p>
<p className="text-destructive-foreground/90">
{error.message || t("chat.error.body")}
</p>
</div>
</div>
) : null;
const consentDialog = (
<Dialog onOpenChange={setConsentOpen} open={consentOpen}>
<DialogPopup>
@@ -191,7 +236,10 @@ export function ChatPanel() {
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{t("chat.heading")}
</h1>
{promptInput}
<div className="flex w-full flex-col gap-3">
{errorAlert}
{promptInput}
</div>
</div>
{consentDialog}
</div>
@@ -240,7 +288,10 @@ export function ChatPanel() {
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 pb-4">
{errorAlert}
{promptInput}
</div>
{consentDialog}
</div>
);
+14 -3
View File
@@ -71,6 +71,19 @@ export function AIPanel() {
const set = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) =>
setConfig((prev) => ({ ...prev, [key]: value }));
// Switching provider: if the current default model doesn't belong to the new
// provider, pick that provider's first model so they never end up mismatched.
const setProvider = (provider: ApiProvider) =>
setConfig((prev) => {
const models = AI_MODELS.filter((m) => m.provider === provider);
const stillValid = models.some((m) => m.id === prev.defaultModel);
return {
...prev,
provider,
defaultModel: stillValid ? prev.defaultModel : (models[0]?.id ?? prev.defaultModel),
};
});
// Models available for the currently selected cloud provider.
const providerModels = useMemo(
() => AI_MODELS.filter((m) => m.provider === config.provider),
@@ -169,9 +182,7 @@ export function AIPanel() {
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
<Select
onValueChange={(value) =>
set("provider", value as ApiProvider)
}
onValueChange={(value) => setProvider(value as ApiProvider)}
value={config.provider}
>
<SelectTrigger>