diff --git a/messages/de.json b/messages/de.json
index 9580a0822..62709141e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1217,8 +1217,63 @@
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell",
"admin-openai": "Admin-OpenAI"
- }
- }
+ },
+ "title": "Fallback-Kette",
+ "description": "Wenn der primäre Provider ausfällt, läuft HealthLog die Kette der Reihe nach durch. Reihenfolge per Pfeile ändern, Schalter deaktiviert einen Eintrag ohne ihn zu entfernen.",
+ "moveUp": "Nach oben",
+ "moveDown": "Nach unten",
+ "removeFromChain": "Aus Kette entfernen",
+ "addProvider": "Provider hinzufügen",
+ "addNoneAvailable": "Alle Provider sind bereits in der Kette",
+ "saveOrder": "Reihenfolge speichern",
+ "saved": "Kette gespeichert",
+ "saveFailed": "Speichern der Kette fehlgeschlagen",
+ "resetDefaults": "Zurücksetzen",
+ "resetConfirmTitle": "Kette auf Standard zurücksetzen?",
+ "resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
+ },
+ "activeProviderHeading": "Aktiver KI-Provider",
+ "activeProviderBody": "Wähle den Provider, der zuerst genutzt wird. Das Formular darunter konfiguriert ausschließlich den ausgewählten Provider; die Fallback-Kette unten regelt, was passiert, wenn er ausfällt.",
+ "activeProviderLabel": "Primärer Provider",
+ "providerConfigTitle": "Provider-Konfiguration",
+ "providerSelect": {
+ "codex": "ChatGPT-Account (Codex)",
+ "openai": "OpenAI (eigener API-Key)",
+ "anthropic": "Anthropic (Claude)",
+ "local": "Lokales Modell (OpenAI-kompatibel)",
+ "admin-openai": "Admin-OpenAI"
+ },
+ "openai": {
+ "modelSelect": "Modell",
+ "modelOptionCustom": "Eigener Slug…",
+ "modelCustomLabel": "Eigener Modell-Slug",
+ "modelCustomPlaceholder": "gpt-5",
+ "baseUrlLabel": "Base URL (erweitert)",
+ "baseUrlPlaceholder": "https://api.openai.com/v1",
+ "baseUrlHelp": "Nur überschreiben, wenn ein OpenAI-kompatibles Gateway genutzt wird. Für OpenAI selbst leer lassen.",
+ "showAdvanced": "Erweitert anzeigen",
+ "hideAdvanced": "Erweitert ausblenden",
+ "apiKey": "API-Key",
+ "apiKeyPlaceholder": "sk-…"
+ },
+ "codex": {
+ "statusConnected": "Verbunden",
+ "statusDisconnected": "Nicht verbunden",
+ "statusExpired": "Verbindung abgelaufen",
+ "connectButton": "Mit ChatGPT verbinden",
+ "disconnectButton": "Trennen",
+ "modelSlugLabel": "Modell-Slug",
+ "modelSlugBody": "Codex nutzt das Modell, auf das dein ChatGPT-Abo routet. Die Umgebungsvariable CODEX_MODEL kann das instanzweit überschreiben.",
+ "lastInsight": "Letzte Analyse: {when}"
+ },
+ "adminOpenai": {
+ "title": "Admin-OpenAI",
+ "body": "Vom Betreiber bereitgestellter OpenAI-Key. Wird als letzter Fallback genutzt, wenn kein persönlicher Provider konfiguriert ist. Hier ist nichts einzustellen — die Anzeige bedeutet, dass der Betreiber ihn eingerichtet hat.",
+ "notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert."
+ },
+ "testProvider": "Aktiven Provider testen",
+ "testSuccess": "OK — {provider} ({model})",
+ "testFailedShort": "Test fehlgeschlagen: {message}"
},
"withings": "Withings",
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
diff --git a/messages/en.json b/messages/en.json
index 62f3fcdea..0a9f2314d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1217,8 +1217,63 @@
"anthropic": "Anthropic (Claude)",
"local": "Local model",
"admin-openai": "Admin OpenAI"
- }
- }
+ },
+ "title": "Fallback chain",
+ "description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "removeFromChain": "Remove from chain",
+ "addProvider": "Add provider",
+ "addNoneAvailable": "All providers already in chain",
+ "saveOrder": "Save chain order",
+ "saved": "Chain saved",
+ "saveFailed": "Saving the chain failed",
+ "resetDefaults": "Reset to defaults",
+ "resetConfirmTitle": "Reset chain to defaults?",
+ "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched."
+ },
+ "activeProviderHeading": "Active AI provider",
+ "activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.",
+ "activeProviderLabel": "Primary provider",
+ "providerConfigTitle": "Provider configuration",
+ "providerSelect": {
+ "codex": "ChatGPT account (Codex)",
+ "openai": "OpenAI (your API key)",
+ "anthropic": "Anthropic (Claude)",
+ "local": "Local model (OpenAI-compatible)",
+ "admin-openai": "Admin-provided OpenAI"
+ },
+ "openai": {
+ "modelSelect": "Model",
+ "modelOptionCustom": "Custom slug…",
+ "modelCustomLabel": "Custom model slug",
+ "modelCustomPlaceholder": "gpt-5",
+ "baseUrlLabel": "Base URL (advanced)",
+ "baseUrlPlaceholder": "https://api.openai.com/v1",
+ "baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.",
+ "showAdvanced": "Show advanced",
+ "hideAdvanced": "Hide advanced",
+ "apiKey": "API key",
+ "apiKeyPlaceholder": "sk-…"
+ },
+ "codex": {
+ "statusConnected": "Connected",
+ "statusDisconnected": "Not connected",
+ "statusExpired": "Connection expired",
+ "connectButton": "Connect with ChatGPT",
+ "disconnectButton": "Disconnect",
+ "modelSlugLabel": "Model slug",
+ "modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.",
+ "lastInsight": "Last insight: {when}"
+ },
+ "adminOpenai": {
+ "title": "Admin OpenAI",
+ "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.",
+ "notConfigured": "The operator has not configured a shared OpenAI key on this instance."
+ },
+ "testProvider": "Test active provider",
+ "testSuccess": "OK — {provider} ({model})",
+ "testFailedShort": "Test failed: {message}"
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx
new file mode 100644
index 000000000..c4a6430fd
--- /dev/null
+++ b/src/components/settings/__tests__/ai-section.test.tsx
@@ -0,0 +1,194 @@
+/**
+ * v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
+ *
+ * The new section replaces the v1.4.15-vintage top/bottom split (Marc's
+ * `feedback_settings_no_split.md`): a single Select picks the active
+ * provider; the form below is switch-rendered for that provider; the
+ * fallback chain at the bottom reorders / toggles entries with arrow
+ * controls (no new dependency — dnd-kit is not in package.json).
+ *
+ * Tests cover:
+ * 1. Codex selected → Codex form (connect button) renders, OpenAI
+ * form does NOT.
+ * 2. OpenAI selected → API-key field + model select render, Codex
+ * connect button does NOT.
+ * 3. Fallback-chain rows surface in priority order with the right
+ * i18n labels.
+ *
+ * Each case feeds the section a different `?provider=…` URL parameter
+ * so the SSR render lands on the desired branch — the production
+ * component reads the same parameter via `useSearchParams()`. This
+ * keeps the test SSR-only (no jsdom dependency, no testing-library)
+ * and matches the existing settings-section test style.
+ */
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+let activeProviderQuery: string | null = null;
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ usePathname: () => "/settings/ai",
+ useSearchParams: () =>
+ new URLSearchParams(
+ activeProviderQuery ? `provider=${activeProviderQuery}` : "",
+ ),
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", role: "USER" },
+ isAuthenticated: true,
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+// Stable mocks for every TanStack Query the section reads. The chain
+// fixture mirrors the default order (codex first) so the rows render in
+// a known order; insights settings + ai-provider GET return enough to
+// keep the form branches content (status, hasOpenaiKey etc.).
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
+ const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
+ if (key === "insights/settings") {
+ return {
+ data: {
+ codexStatus: "disconnected",
+ codexConnectedAt: null,
+ hasAdminKey: false,
+ codexOauthConfigured: true,
+ privacyMode: "aggregated",
+ lastInsightAt: null,
+ },
+ isLoading: false,
+ };
+ }
+ if (key === "user/ai-provider") {
+ return {
+ data: {
+ provider: null,
+ model: null,
+ baseUrl: null,
+ hasAnthropicKey: false,
+ anthropicKeyPreview: null,
+ hasLocalKey: false,
+ hasOpenaiKey: false,
+ openaiKeyPreview: null,
+ },
+ isLoading: false,
+ };
+ }
+ if (key === "insights/provider-chain") {
+ return {
+ data: {
+ activeProvider: "codex",
+ cachedActiveProvider: null,
+ configuredChain: [
+ { providerType: "codex", available: true },
+ { providerType: "openai", available: true },
+ { providerType: "anthropic", available: true },
+ ],
+ },
+ isLoading: false,
+ };
+ }
+ return { data: null, isLoading: false };
+ },
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { AiSection } from "../ai-section";
+
+function render(locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ activeProviderQuery = null;
+});
+
+describe(" — dropdown-driven UX (B2)", () => {
+ it("renders the active-provider Select with the configured options", () => {
+ activeProviderQuery = "codex";
+ const html = render();
+ expect(html).toContain('data-testid="ai-active-provider-select"');
+ // The four selectable options resolve via the new
+ // `settings.ai.providerSelect.*` keys.
+ expect(html).toContain("ChatGPT account (Codex)");
+ expect(html).toContain("OpenAI (your API key)");
+ expect(html).toContain("Anthropic (Claude)");
+ expect(html).toContain("Local model (OpenAI-compatible)");
+ });
+
+ it("Codex selected → Codex provider form, no OpenAI form", () => {
+ activeProviderQuery = "codex";
+ const html = render();
+ // Provider-config card surfaces.
+ expect(html).toContain('data-testid="ai-provider-config-codex"');
+ // OpenAI's API-key field is NOT rendered when Codex is active.
+ expect(html).not.toContain('data-testid="ai-provider-config-openai"');
+ // Connect-with-ChatGPT CTA is the focal action.
+ expect(html).toContain("Connect with ChatGPT");
+ });
+
+ it("OpenAI selected → API-key field + model select, no Codex form", () => {
+ activeProviderQuery = "openai";
+ const html = render();
+ expect(html).toContain('data-testid="ai-provider-config-openai"');
+ expect(html).not.toContain('data-testid="ai-provider-config-codex"');
+ // OpenAI form surfaces the API-key input + model select.
+ expect(html).toContain('data-testid="ai-openai-api-key"');
+ expect(html).toContain('data-testid="ai-openai-model"');
+ });
+
+ it("Anthropic selected → Anthropic API-key form", () => {
+ activeProviderQuery = "anthropic";
+ const html = render();
+ expect(html).toContain('data-testid="ai-provider-config-anthropic"');
+ expect(html).not.toContain('data-testid="ai-provider-config-openai"');
+ });
+
+ it("Local selected → Local base-URL + key form", () => {
+ activeProviderQuery = "local";
+ const html = render();
+ expect(html).toContain('data-testid="ai-provider-config-local"');
+ expect(html).not.toContain('data-testid="ai-provider-config-codex"');
+ });
+
+ it("renders the fallback-chain card with rows in priority order", () => {
+ activeProviderQuery = "codex";
+ const html = render();
+ expect(html).toContain('data-testid="ai-fallback-chain"');
+ // Each row carries a stable `data-chain-row=""`
+ // marker so the e2e suite can target without leaning on i18n
+ // labels (which differ per locale).
+ expect(html).toContain('data-chain-row="codex"');
+ expect(html).toContain('data-chain-row="openai"');
+ expect(html).toContain('data-chain-row="anthropic"');
+ });
+
+ it("exposes a Test active provider button", () => {
+ activeProviderQuery = "codex";
+ const html = render();
+ expect(html).toContain('data-testid="ai-test-active-provider"');
+ expect(html).toContain("Test active provider");
+ });
+
+ it("renders the German labels when locale=de", () => {
+ activeProviderQuery = "codex";
+ const html = render("de");
+ expect(html).toContain("Mit ChatGPT verbinden");
+ expect(html).toContain("ChatGPT-Account (Codex)");
+ // Heading uses the locale's full string ("Aktiver KI-Provider"); a
+ // shorter substring would also match the "Aktiver Provider" line
+ // in the chain summary.
+ expect(html).toContain("Aktiver KI-Provider");
+ });
+});
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 4b1c82820..1b870b574 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -3,10 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
// Stub out next/navigation. The settings sections that read it (Account)
// only call `useRouter().push()` from inside an effect after mount, so a
-// no-op stub is enough for SSR smoke rendering.
+// no-op stub is enough for SSR smoke rendering. v1.4.16 phase B2:
+// `` reads `?provider=…` via `useSearchParams()` to pick the
+// dropdown branch, so the mock has to expose that hook too.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/account",
+ useSearchParams: () => new URLSearchParams(),
}));
// Stub TanStack Query — every section reads from it but we only need the
diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx
index c237db45e..072010890 100644
--- a/src/components/settings/ai-section.tsx
+++ b/src/components/settings/ai-section.tsx
@@ -1,8 +1,20 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Loader2, RefreshCw, Save, Sparkles, Trash2 } from "lucide-react";
+import {
+ ArrowDown,
+ ArrowUp,
+ Loader2,
+ PlusCircle,
+ RefreshCw,
+ RotateCcw,
+ Save,
+ Sparkles,
+ Trash2,
+ X,
+} from "lucide-react";
import {
AlertDialog,
@@ -50,29 +62,86 @@ interface UserAIProvider {
}
/**
- * Model presets per provider — keeps users out of the freetext box for
- * the common case and signals which models the resolver will actually
- * use as defaults if the field is left blank. The `__custom__` sentinel
- * surfaces a freetext Input so power users can still target any model
- * the underlying API speaks (e.g. preview models, fine-tunes).
+ * v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
+ * `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
+ * avoid pulling a server-only module into the client bundle.
*/
-const MODEL_PRESETS: Record> = {
- // P15: dropped `gpt-5` (not a released model — would surface as
- // model_not_found) and `o3-mini` (uses the o-series param contract:
- // `max_completion_tokens` instead of `max_tokens`, no `temperature`
- // override — would 400 with the current OpenAIClient body shape).
- OPENAI: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
- ANTHROPIC: [
- "claude-sonnet-4-6",
- "claude-opus-4-7",
- "claude-haiku-4-5",
- "claude-3-5-sonnet-latest",
- ],
- LOCAL: ["llama3.1:8b", "llama3.1:70b", "mistral", "qwen2.5"],
-};
+const PROVIDER_TYPES = [
+ "codex",
+ "openai",
+ "anthropic",
+ "local",
+ "admin-openai",
+] as const;
+type ProviderType = (typeof PROVIDER_TYPES)[number];
+interface ChainEntry {
+ providerType: ProviderType;
+ enabled: boolean;
+}
+
+interface ProviderChainData {
+ activeProvider: ProviderType | null;
+ cachedActiveProvider: ProviderType | null;
+ configuredChain: { providerType: ProviderType; available: boolean }[];
+}
+
+const DEFAULT_CHAIN: readonly ChainEntry[] = [
+ { providerType: "codex", enabled: true },
+ { providerType: "openai", enabled: true },
+ { providerType: "anthropic", enabled: true },
+ { providerType: "local", enabled: true },
+ { providerType: "admin-openai", enabled: true },
+];
+
+/**
+ * OpenAI model presets — the wire-up keeps users out of the freetext
+ * box for the common case. The `__custom__` sentinel surfaces a
+ * freetext input so power users can target preview models or
+ * fine-tunes (e.g. `gpt-5`, which is admitted via the env override on
+ * the Codex backend but not yet a documented OpenAI model id).
+ */
+const OPENAI_MODEL_PRESETS = [
+ "gpt-4o",
+ "gpt-4o-mini",
+ "gpt-4-turbo",
+] as const;
+const ANTHROPIC_MODEL_PRESETS = [
+ "claude-sonnet-4-6",
+ "claude-opus-4-7",
+ "claude-haiku-4-5",
+ "claude-3-5-sonnet-latest",
+] as const;
+const LOCAL_MODEL_PRESETS = [
+ "llama3.1:8b",
+ "llama3.1:70b",
+ "mistral",
+ "qwen2.5",
+] as const;
const CUSTOM_MODEL_SENTINEL = "__custom__";
+/**
+ * Map UI provider tag → the `aiProvider` enum understood by the
+ * legacy single-result `resolveProvider()` resolver. `codex` and
+ * `admin-openai` aren't user-level provider columns (they're handled
+ * by Codex OAuth + admin-key fallback), so they map to null and the
+ * legacy column is left blank.
+ */
+function uiToLegacyProviderEnum(p: ProviderType): string | null {
+ switch (p) {
+ case "openai":
+ return "OPENAI";
+ case "anthropic":
+ return "ANTHROPIC";
+ case "local":
+ return "LOCAL";
+ case "codex":
+ case "admin-openai":
+ default:
+ return null;
+ }
+}
+
export function AiSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -91,24 +160,31 @@ export function AiSection() {
-
+
);
}
-function InsightsSettingsCard({
- isAuthenticated,
-}: {
- isAuthenticated: boolean;
-}) {
+/**
+ * The single dropdown-driven configuration card. Replaces the
+ * v1.4.15-vintage two-card layout (Codex connect at the top, personal
+ * provider form at the bottom) per Marc's
+ * `feedback_settings_no_split.md`: one provider Select, one matching
+ * form below, one fallback-chain card at the bottom.
+ *
+ * The active-provider Select is URL-synced via `?provider=…` so deep
+ * links to a specific config form work, the SSR test can drive the
+ * branch deterministically, and a refresh keeps the user on the form
+ * they were editing.
+ */
+function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
- const [disconnecting, setDisconnecting] = useState(false);
- const [regenerating, setRegenerating] = useState(false);
- const [msg, setMsg] = useState(null);
- const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
- const { data: settings } = useQuery({
+ const { data: insightsSettings } = useQuery({
queryKey: ["insights", "settings"],
queryFn: async () => {
const res = await fetch("/api/insights/settings");
@@ -119,26 +195,265 @@ function InsightsSettingsCard({
enabled: isAuthenticated,
});
- const updateSettings = useMutation({
- mutationFn: async (body: Record) => {
- const res = await fetch("/api/insights/settings", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
+ const { data: userProvider } = useQuery({
+ queryKey: ["user", "ai-provider"],
+ queryFn: async () => {
+ const res = await fetch("/api/user/ai-provider");
+ if (!res.ok) return null;
const json = await res.json();
- if (!res.ok) throw new Error(json.error);
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["insights"] });
+ return json.data as UserAIProvider;
},
+ enabled: isAuthenticated,
});
- // OAuth callback handler — reads `?codex_connected=true|codex_error=...`
- // from the URL and surfaces a toast-equivalent inline message. We do the
- // read at render time (via a `useState` lazy initialiser) so the strict
- // `react-hooks/set-state-in-effect` rule isn't triggered. The effect then
- // drains the result by clearing the URL params and invalidating queries.
+ const { data: chainData } = useQuery({
+ queryKey: ["insights", "provider-chain"],
+ queryFn: async () => {
+ const res = await fetch("/api/insights/provider-chain");
+ if (!res.ok) return null;
+ const json = await res.json();
+ return json.data as ProviderChainData;
+ },
+ enabled: isAuthenticated,
+ });
+
+ // The Select is URL-driven so the SSR test can pick the branch and
+ // a deep link works. Default = `?provider=…` query param when the
+ // user navigated here intentionally, else the chain's active entry,
+ // else "codex". The seed-on-data-arrival pattern below avoids
+ // setState-in-effect (lint rule `react-hooks/set-state-in-effect`)
+ // by reading the resolved value at render time and consulting a
+ // `seededFor` marker so we only re-seed when the relevant inputs
+ // actually changed.
+ const queryProvider = searchParams?.get("provider") ?? null;
+ const [selectedProvider, setSelectedProvider] = useState(() =>
+ isProviderType(queryProvider) ? queryProvider : "codex",
+ );
+ const seedKey = `${queryProvider ?? ""}|${chainData?.activeProvider ?? ""}`;
+ const [seededFor, setSeededFor] = useState(seedKey);
+ if (seedKey !== seededFor) {
+ setSeededFor(seedKey);
+ if (isProviderType(queryProvider)) {
+ setSelectedProvider(queryProvider);
+ } else if (chainData?.activeProvider) {
+ setSelectedProvider(chainData.activeProvider);
+ }
+ }
+
+ function pickProvider(next: ProviderType) {
+ setSelectedProvider(next);
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(searchParams?.toString() ?? "");
+ params.set("provider", next);
+ router.replace(`${pathname}?${params.toString()}`);
+ }
+
+ return (
+
+
+
+
+
{t("settings.kiInsights")}
+
+
+
+
+ {t("settings.kiInsightsDescription")}
+
+
+
+
+
+
+
+
+
+ queryClient.invalidateQueries({ queryKey: ["insights"] })
+ }
+ onPrivacyChanged={() =>
+ queryClient.invalidateQueries({ queryKey: ["insights"] })
+ }
+ />
+
+ );
+}
+
+function isProviderType(v: string | null): v is ProviderType {
+ return v != null && (PROVIDER_TYPES as readonly string[]).includes(v);
+}
+
+function ProviderStatusBadges({
+ settings,
+ activeProvider,
+}: {
+ settings: InsightsSettings | null | undefined;
+ activeProvider: ProviderType | null;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ {settings?.codexStatus === "connected" && activeProvider === "codex" && (
+
+ {t("settings.ai.chatgptConnectedBadge")}
+
+ )}
+ {settings?.codexStatus !== "connected" &&
+ settings?.hasAdminKey &&
+ activeProvider !== "codex" && (
+
+ {t("settings.ai.adminAiActiveBadge")}
+
+ )}
+ {settings?.codexStatus === "expired" && (
+
+ {t("settings.ai.connectionExpiredBadge")}
+
+ )}
+ {settings?.lastInsightAt && (
+
+ {t("settings.lastGeneratedAt")}:{" "}
+ {formatDateTime(settings.lastInsightAt)}
+
+ )}
+
+ );
+}
+
+/**
+ * The single Pulldown that drives every form below it. Uses a native
+ * `