diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index b4cdc2871..ae6bfbaec 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -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(); diff --git a/messages/de.json b/messages/de.json index b15ed4084..f2372f6ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 288b95b00..2d14f363e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 3fc3cfcab..1eaa503cc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 3121be5a9..4eb0009e4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index aa59f96eb..13d89eb9e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 3356413fa..4f02ccff3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3275,7 +3275,11 @@ "noProvider": "사용할 수 있는 제공자가 없어요", "allSettings": "모든 AI 설정", "operatorCodex": "ChatGPT (운영자 공유)", - "changeModel": "설정 > AI에서 변경" + "changeModel": "설정 > AI에서 변경", + "saving": "저장 중…", + "saveFailed": "변경 사항을 저장하지 못했어요.", + "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.", + "operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다." }, "answer": { "scrollToBottom": "최신 메시지로 이동", diff --git a/messages/nl.json b/messages/nl.json index 8f8e2aaee..5cf268024 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 2e6a1501f..318f838c0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx index be9b6a4f5..d24b0fca0 100644 --- a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx @@ -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 { return { @@ -24,6 +40,55 @@ function userProvider(over: Partial = {}): 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()); diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx index dfd13f529..5b5680c33 100644 --- a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx @@ -70,32 +70,43 @@ describe("", () => { expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings { + it("offers only the providers that can answer, with the active one selected", () => { const html = render(); - 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( + /]*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(/]*data-slot="coach-quick-provider"/); - expect(html).not.toMatch(/]*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(, { ...CHAIN, activeProvider: "admin-codex" as ProviderChainData["activeProvider"], }); - expect(html).toMatch( - /data-slot="coach-quick-provider"[^>]*data-value="admin-codex"[^>]*>ChatGPT \(shared by the operator\)]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/, + )?.[0]; + expect(select).toMatch( + /