mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(coach): bring back the in-context provider and model switch
The Coach overlay had become a read-only line with a link to Settings > AI, which took away switching the model mid-conversation. The provider and model selects are back; they write through the same routes and the same stored preference as Settings > AI, so there is still one setting, and the link to Settings > AI stays below them.
This commit is contained in:
1 parent
9cfc72fe1f
commit
0229b69981
12 files changed
+425
-73
No files matched your search
@@ -807,14 +807,12 @@ test.describe("Coach page frame", () => {
|
||||
const popover = page.locator('[data-slot="coach-settings-popover"]');
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(popover).toHaveAttribute("role", "dialog");
|
||||
// The model is chosen in Settings > AI; the overlay shows what is in
|
||||
// effect and links there.
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-quick-provider"]'),
|
||||
).toHaveAttribute("data-value", "local");
|
||||
).toHaveValue("local");
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-quick-model"]'),
|
||||
).toHaveAttribute("data-value", "qwen2.5");
|
||||
).toHaveValue("qwen2.5");
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-sources-window"]'),
|
||||
).toBeVisible();
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "Kein Anbieter verfügbar",
|
||||
"allSettings": "Alle KI-Einstellungen",
|
||||
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
|
||||
"changeModel": "In Einstellungen > KI ändern"
|
||||
"changeModel": "In Einstellungen > KI ändern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saveFailed": "Die Änderung ließ sich nicht speichern.",
|
||||
"providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.",
|
||||
"operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Zur neuesten Nachricht",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "No provider available",
|
||||
"allSettings": "All AI settings",
|
||||
"operatorCodex": "ChatGPT (shared by the operator)",
|
||||
"changeModel": "Change in Settings > AI"
|
||||
"changeModel": "Change in Settings > AI",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "That change could not be saved.",
|
||||
"providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.",
|
||||
"operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Jump to the latest message",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "Ningún proveedor disponible",
|
||||
"allSettings": "Todos los ajustes de IA",
|
||||
"operatorCodex": "ChatGPT (compartido por el operador)",
|
||||
"changeModel": "Cambiar en Ajustes > IA"
|
||||
"changeModel": "Cambiar en Ajustes > IA",
|
||||
"saving": "Guardando…",
|
||||
"saveFailed": "No se pudo guardar el cambio.",
|
||||
"providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.",
|
||||
"operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Ir al último mensaje",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "Aucun fournisseur disponible",
|
||||
"allSettings": "Tous les paramètres IA",
|
||||
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
|
||||
"changeModel": "Modifier dans Réglages > IA"
|
||||
"changeModel": "Modifier dans Réglages > IA",
|
||||
"saving": "Enregistrement…",
|
||||
"saveFailed": "Impossible d’enregistrer cette modification.",
|
||||
"providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.",
|
||||
"operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Aller au dernier message",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "Nessun fornitore disponibile",
|
||||
"allSettings": "Tutte le impostazioni IA",
|
||||
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
|
||||
"changeModel": "Cambia in Impostazioni > IA"
|
||||
"changeModel": "Cambia in Impostazioni > IA",
|
||||
"saving": "Salvataggio…",
|
||||
"saveFailed": "Impossibile salvare la modifica.",
|
||||
"providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.",
|
||||
"operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Vai all'ultimo messaggio",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "사용할 수 있는 제공자가 없어요",
|
||||
"allSettings": "모든 AI 설정",
|
||||
"operatorCodex": "ChatGPT (운영자 공유)",
|
||||
"changeModel": "설정 > AI에서 변경"
|
||||
"changeModel": "설정 > AI에서 변경",
|
||||
"saving": "저장 중…",
|
||||
"saveFailed": "변경 사항을 저장하지 못했어요.",
|
||||
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
|
||||
"operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "최신 메시지로 이동",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "Geen provider beschikbaar",
|
||||
"allSettings": "Alle AI-instellingen",
|
||||
"operatorCodex": "ChatGPT (gedeeld door de serverbeheerder)",
|
||||
"changeModel": "Wijzigen in Instellingen > AI"
|
||||
"changeModel": "Wijzigen in Instellingen > AI",
|
||||
"saving": "Opslaan…",
|
||||
"saveFailed": "Die wijziging kon niet worden opgeslagen.",
|
||||
"providerNotActive": "{provider} kon het niet overnemen: er is geen werkende aanmelding of sleutel. Stel deze provider in bij de AI-instellingen.",
|
||||
"operatorManaged": "De beheerder van je server beheert deze verbinding. Zodra een van je eigen providers is ingesteld, antwoordt die als eerste."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Naar het nieuwste bericht",
|
||||
|
||||
+5
-1
@@ -3275,7 +3275,11 @@
|
||||
"noProvider": "Brak dostępnego dostawcy",
|
||||
"allSettings": "Wszystkie ustawienia AI",
|
||||
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
|
||||
"changeModel": "Zmień w Ustawieniach > AI"
|
||||
"changeModel": "Zmień w Ustawieniach > AI",
|
||||
"saving": "Zapisywanie…",
|
||||
"saveFailed": "Nie udało się zapisać zmiany.",
|
||||
"providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.",
|
||||
"operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Przejdź do najnowszej wiadomości",
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { UserAIProvider } from "@/components/settings/ai/shared";
|
||||
import type {
|
||||
ProviderChainData,
|
||||
UserAIProvider,
|
||||
} from "@/components/settings/ai/shared";
|
||||
|
||||
import { modelControlFor } from "../coach-model-picker";
|
||||
import {
|
||||
chainWithFirst,
|
||||
modelControlFor,
|
||||
selectableProviders,
|
||||
switchDidNotTake,
|
||||
} from "../coach-model-picker";
|
||||
|
||||
const CHAIN: ProviderChainData["configuredChain"] = [
|
||||
{ providerType: "codex", enabled: true, available: false },
|
||||
{ providerType: "openai", enabled: true, available: true },
|
||||
{ providerType: "anthropic", enabled: false, available: true },
|
||||
{ providerType: "local", enabled: true, available: true },
|
||||
{ providerType: "admin-openai", enabled: true, available: true },
|
||||
];
|
||||
|
||||
function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
|
||||
return {
|
||||
@@ -24,6 +40,55 @@ function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
describe("chainWithFirst", () => {
|
||||
it("moves the chosen provider to the front and keeps the rest in order", () => {
|
||||
expect(chainWithFirst(CHAIN, "local")).toEqual([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
{ providerType: "codex", priority: 2, enabled: true },
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
{ providerType: "anthropic", priority: 4, enabled: false },
|
||||
{ providerType: "admin-openai", priority: 5, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never sends a reasoning setting, so the server keeps the stored one", () => {
|
||||
for (const entry of chainWithFirst(CHAIN, "openai")) {
|
||||
expect(Object.keys(entry).sort()).toEqual([
|
||||
"enabled",
|
||||
"priority",
|
||||
"providerType",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectableProviders", () => {
|
||||
it("offers only enabled providers that can answer", () => {
|
||||
expect(selectableProviders(CHAIN)).toEqual([
|
||||
"openai",
|
||||
"local",
|
||||
"admin-openai",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchDidNotTake", () => {
|
||||
it("is quiet when the chosen provider leads the chain the server resolves", () => {
|
||||
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks up when the server still answers with another provider", () => {
|
||||
// A provider without a working key was saved to the front of the chain;
|
||||
// the resolver skipped it and the select would jump back silently.
|
||||
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelControlFor", () => {
|
||||
it("offers the presets for the provider whose key the person saved", () => {
|
||||
const control = modelControlFor("openai", userProvider());
|
||||
|
||||
@@ -70,32 +70,43 @@ describe("<CoachSettingsBody>", () => {
|
||||
expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings</);
|
||||
});
|
||||
|
||||
it("shows the provider and model in effect and links to the one place they are chosen", () => {
|
||||
it("offers only the providers that can answer, with the active one selected", () => {
|
||||
const html = render(<CoachSettingsBody />);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-quick-provider"[^>]*data-value="local"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-quick-model"[^>]*data-value="qwen2.5"/,
|
||||
);
|
||||
const select = html.match(
|
||||
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
|
||||
)?.[0];
|
||||
expect(select).toBeDefined();
|
||||
expect(select).toContain('value="local"');
|
||||
expect(select).toContain('value="admin-openai"');
|
||||
expect(select).not.toContain('value="codex"');
|
||||
// The same preference Settings > AI writes; the link there stays.
|
||||
expect(html).toContain('href="/settings/ai#ai-provider"');
|
||||
// v1.44 — no second copy of the selects here.
|
||||
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-provider"/);
|
||||
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-model"/);
|
||||
expect(html).toContain('id="coach-quick-reasoning"');
|
||||
// Keys and base URLs are not here.
|
||||
expect(html).not.toContain("http://localhost:11434/v1");
|
||||
expect(html).not.toMatch(/type="password"/);
|
||||
});
|
||||
|
||||
it("names the operator's central Codex when it answers", () => {
|
||||
it("shows the operator's central Codex as the active provider when it answers", () => {
|
||||
// `admin-codex` is never a chain entry, so no option matched it and the
|
||||
// select showed its first option as the one answering.
|
||||
const html = render(<CoachSettingsBody />, {
|
||||
...CHAIN,
|
||||
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
|
||||
});
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-quick-provider"[^>]*data-value="admin-codex"[^>]*>ChatGPT \(shared by the operator\)</,
|
||||
const select = html.match(
|
||||
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
|
||||
)?.[0];
|
||||
expect(select).toMatch(
|
||||
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
|
||||
);
|
||||
expect(select).not.toMatch(/value="local"[^>]*selected/);
|
||||
expect(html).toContain('data-slot="coach-quick-provider-operator"');
|
||||
});
|
||||
|
||||
it("shows the model and reasoning controls the active provider owns", () => {
|
||||
const html = render(<CoachSettingsBody />);
|
||||
expect(html).toContain('data-slot="coach-quick-model"');
|
||||
expect(html).toContain('id="coach-quick-reasoning"');
|
||||
// Keys, base URLs and the chain order are not here.
|
||||
expect(html).not.toContain("http://localhost:11434/v1");
|
||||
expect(html).not.toMatch(/type="password"/);
|
||||
});
|
||||
|
||||
it("locks the reasoning select while a change is saving", async () => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
LOCAL_MODEL_PRESETS,
|
||||
@@ -13,23 +19,89 @@ import {
|
||||
type ProviderType,
|
||||
type UserAIProvider,
|
||||
} from "@/components/settings/ai/shared";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
|
||||
/**
|
||||
* Which provider answers the Coach and which model it uses, shown in the
|
||||
* Coach's settings overlay with a link to where it is chosen.
|
||||
* The quick model choice in the Coach's settings overlay: which provider
|
||||
* answers and which model it uses. How hard the Coach thinks is the Coach's
|
||||
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
|
||||
* reasoning select stays in Settings → AI for everything else.
|
||||
*
|
||||
* The model is chosen in one place, Settings > AI. Until v1.44 this overlay
|
||||
* carried a second copy of the provider and model selects, writing through
|
||||
* the same routes; two controls for one setting meant a change in one place
|
||||
* surprised the person in the other. The overlay now reads the same two
|
||||
* queries and says what is in effect.
|
||||
* It is a shortcut onto the same settings Settings → AI writes, never a second
|
||||
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
|
||||
* fallback chain stay there; this reads the same two queries and writes
|
||||
* through the same two routes:
|
||||
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
|
||||
* to the front and every other entry in its current order. The entries'
|
||||
* reasoning settings are not sent, so the server keeps what it stored.
|
||||
* - Model: `PATCH /api/user/ai-provider`, only where a person
|
||||
* owns the setting (their own key's provider, or their gateway). The
|
||||
* operator's shared provider and Codex show their state read-only.
|
||||
* Every write invalidates the AI input keys, so the capability on
|
||||
* `/api/auth/me` comes back from the server rather than being guessed here.
|
||||
*
|
||||
* v1.44 briefly replaced the selects with a read-only line; switching the
|
||||
* model mid-conversation is how people use it, so the selects are back, with
|
||||
* the link to Settings > AI kept below them for everything else.
|
||||
*/
|
||||
|
||||
/** The configured chain with `provider` first; everything else keeps order. */
|
||||
export function chainWithFirst(
|
||||
chain: ProviderChainData["configuredChain"],
|
||||
provider: ProviderType,
|
||||
): { providerType: ProviderType; priority: number; enabled: boolean }[] {
|
||||
const rest = chain.filter((entry) => entry.providerType !== provider);
|
||||
const ordered = [
|
||||
{ providerType: provider, enabled: true },
|
||||
...rest.map((entry) => ({
|
||||
providerType: entry.providerType,
|
||||
enabled: entry.enabled,
|
||||
})),
|
||||
];
|
||||
return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
|
||||
}
|
||||
|
||||
/** The providers a person can switch to: enabled and able to answer. */
|
||||
export function selectableProviders(
|
||||
chain: ProviderChainData["configuredChain"],
|
||||
): ProviderType[] {
|
||||
return chain
|
||||
.filter((entry) => entry.enabled && entry.available)
|
||||
.map((entry) => entry.providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a saved switch did not take: the chain the server resolves after
|
||||
* the save is not led by the provider the person chose. A provider whose key
|
||||
* stops working between the read and the save lands here.
|
||||
*/
|
||||
export function switchDidNotTake(
|
||||
chosen: ProviderType,
|
||||
fresh: Pick<ProviderChainData, "activeProvider">,
|
||||
): boolean {
|
||||
return fresh.activeProvider !== chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the server answers with when the select has no option for it:
|
||||
* the operator's central Codex (never a chain entry), or an entry that answers
|
||||
* without being offered. Shown as a disabled option so the select tells the
|
||||
* truth instead of showing its first option as active.
|
||||
*/
|
||||
export function activeOutsideOptions(
|
||||
active: string | null,
|
||||
options: readonly string[],
|
||||
): string | null {
|
||||
return active !== null && !options.includes(active) ? active : null;
|
||||
}
|
||||
|
||||
export type ModelControl =
|
||||
| { kind: "preset"; presets: readonly string[]; value: string | null }
|
||||
| { kind: "gateway"; value: string | null }
|
||||
@@ -64,6 +136,7 @@ export function modelControlFor(
|
||||
|
||||
export function CoachModelPicker() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const chainQuery = useQuery({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
@@ -74,6 +147,59 @@ export function CoachModelPicker() {
|
||||
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const onSaved = () => {
|
||||
setError(null);
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
};
|
||||
const onFailed = (e: unknown) =>
|
||||
setError(
|
||||
e instanceof Error && e.message
|
||||
? e.message
|
||||
: t("insights.coach.frame.saveFailed"),
|
||||
);
|
||||
|
||||
const switchProvider = useMutation({
|
||||
mutationFn: async (provider: ProviderType) => {
|
||||
setNotice(null);
|
||||
await apiPut("/api/insights/provider-chain", {
|
||||
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
|
||||
});
|
||||
// Read back what the server now resolves, so a switch that did not
|
||||
// take is said rather than shown as the select jumping back.
|
||||
return apiGet<ProviderChainData>("/api/insights/provider-chain");
|
||||
},
|
||||
onSuccess: (fresh, provider) => {
|
||||
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
|
||||
onSaved();
|
||||
if (switchDidNotTake(provider, fresh)) {
|
||||
setNotice(
|
||||
t("insights.coach.frame.providerNotActive", {
|
||||
provider: t(`settings.ai.providerChain.types.${provider}`),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: onFailed,
|
||||
});
|
||||
const patchProvider = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiPatch("/api/user/ai-provider", body),
|
||||
onSuccess: onSaved,
|
||||
onError: onFailed,
|
||||
});
|
||||
|
||||
const chain = chainQuery.data;
|
||||
const userProvider = providerQuery.data;
|
||||
const active = chain?.activeProvider ?? null;
|
||||
const options = chain ? selectableProviders(chain.configuredChain) : [];
|
||||
const model = modelControlFor(active, userProvider);
|
||||
const busy = switchProvider.isPending || patchProvider.isPending;
|
||||
// The server may answer with `admin-codex`, which the UI's type omits.
|
||||
const unlisted = activeOutsideOptions(active as string | null, options);
|
||||
|
||||
if (chainQuery.isError || providerQuery.isError) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
@@ -88,8 +214,6 @@ export function CoachModelPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
const chain = chainQuery.data;
|
||||
const userProvider = providerQuery.data;
|
||||
if (!chain || !userProvider) {
|
||||
return (
|
||||
<p
|
||||
@@ -105,37 +229,112 @@ export function CoachModelPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
// The server may answer with `admin-codex`, which the UI's type omits.
|
||||
const active = (chain.activeProvider ?? null) as string | null;
|
||||
const model = modelControlFor(chain.activeProvider ?? null, userProvider);
|
||||
const providerLabel =
|
||||
active === null
|
||||
? t("insights.coach.frame.noProvider")
|
||||
: active === "admin-codex"
|
||||
? t("insights.coach.frame.operatorCodex")
|
||||
: t(`settings.ai.providerChain.types.${active}`);
|
||||
const modelLabel =
|
||||
model.kind === "managed"
|
||||
? t("insights.coach.frame.modelManaged")
|
||||
: (model.value ?? t("settings.ai.modelOptionDefault"));
|
||||
|
||||
return (
|
||||
<div data-slot="coach-model-picker" className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">
|
||||
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="coach-quick-provider" noColon>
|
||||
{t("insights.coach.frame.provider")}
|
||||
</dt>
|
||||
<dd data-slot="coach-quick-provider" data-value={active ?? ""}>
|
||||
{providerLabel}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("settings.ai.modelLabel")}</dt>
|
||||
<dd
|
||||
data-slot="coach-quick-model"
|
||||
data-value={model.kind === "managed" ? "" : (model.value ?? "")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="coach-quick-provider"
|
||||
data-slot="coach-quick-provider"
|
||||
className="mt-1"
|
||||
value={active ?? ""}
|
||||
disabled={busy || options.length === 0}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as ProviderType;
|
||||
if (next && next !== active) switchProvider.mutate(next);
|
||||
}}
|
||||
>
|
||||
{modelLabel}
|
||||
</dd>
|
||||
</dl>
|
||||
{active === null ? (
|
||||
<option value="" disabled>
|
||||
{t("insights.coach.frame.noProvider")}
|
||||
</option>
|
||||
) : null}
|
||||
{unlisted ? (
|
||||
<option value={unlisted} disabled>
|
||||
{unlisted === "admin-codex"
|
||||
? t("insights.coach.frame.operatorCodex")
|
||||
: t(`settings.ai.providerChain.types.${unlisted}`)}
|
||||
</option>
|
||||
) : null}
|
||||
{options.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`settings.ai.providerChain.types.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{unlisted === "admin-codex" ? (
|
||||
<p
|
||||
data-slot="coach-quick-provider-operator"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t("insights.coach.frame.operatorManaged")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{model.kind === "preset" ? (
|
||||
<div>
|
||||
<Label htmlFor="coach-quick-model" noColon>
|
||||
{t("settings.ai.modelLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="coach-quick-model"
|
||||
data-slot="coach-quick-model"
|
||||
className="mt-1"
|
||||
value={model.value ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
patchProvider.mutate({ model: e.target.value || null })
|
||||
}
|
||||
>
|
||||
<option value="">{t("settings.ai.modelOptionDefault")}</option>
|
||||
{model.value && !model.presets.includes(model.value) ? (
|
||||
<option value={model.value}>{model.value}</option>
|
||||
) : null}
|
||||
{model.presets.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
) : model.kind === "gateway" ? (
|
||||
<GatewayModelField
|
||||
key={model.value ?? ""}
|
||||
value={model.value}
|
||||
disabled={busy}
|
||||
onSave={(next) => patchProvider.mutate({ compatModel: next })}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
data-slot="coach-quick-model-managed"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.coach.frame.modelManaged")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{busy ? (
|
||||
<p role="status" className="text-muted-foreground text-xs">
|
||||
{t("insights.coach.frame.saving")}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="coach-quick-provider-notice"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link href="/settings/ai#ai-provider" data-slot="coach-quick-change">
|
||||
@@ -147,3 +346,50 @@ export function CoachModelPicker() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A gateway names its own models, so its model is free text with a save. */
|
||||
function GatewayModelField({
|
||||
value,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
value: string | null;
|
||||
disabled: boolean;
|
||||
onSave: (next: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [draft, setDraft] = useState(value ?? "");
|
||||
const changed = draft.trim() !== (value ?? "");
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (changed) onSave(draft.trim() || null);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="coach-quick-gateway-model" noColon>
|
||||
{t("settings.ai.modelLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="coach-quick-gateway-model"
|
||||
data-slot="coach-quick-gateway-model"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || !changed}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("settings.ai.saveCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user