From 23f4ee5b1e4fcd3c9952582fd2e566396b3d44bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 10 May 2026 01:09:07 +0200
Subject: [PATCH] feat(settings): AI provider section with single dropdown
driving form below
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the v1.4.15-vintage two-card layout (Codex connect at the top,
personal-provider form at the bottom) with the dropdown-driven UX
called out in feedback_settings_no_split.md: one Pulldown picks the
active provider, the form below adapts to that choice, and the
fallback chain at the bottom reorders / toggles entries with arrow
controls.
Five provider config forms live in this file (Codex, OpenAI, Anthropic,
Local, Admin OpenAI) and share a single card frame so transitions feel
like updating one concept rather than swapping pages. The Codex form
keeps the device-code flow + OAuth callback handling unchanged. OpenAI
gets a model dropdown plus a collapsed Base URL override (advanced).
Anthropic and Local mirror that shape with the right credentials. The
fallback chain calls the new PUT /api/insights/provider-chain to
persist reorders.
The Select is URL-synced via ?provider=… so deep links work, the SSR
test can drive each branch deterministically, and a refresh keeps
the user on the form they were editing. Native elements
keep the SSR-only render-to-static-markup test happy without bringing
a portal-based Radix tree into play. No new dependency — dnd-kit is
not in package.json so reorder uses up/down arrow buttons.
EN + DE i18n keys land alongside; the i18n parity guard test sees
both locales and the existing settings.ai.providerChain.types.* tree
keeps the per-provider labels stable across the chain summary and
the new chain-management UI.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
messages/de.json | 59 +-
messages/en.json | 59 +-
.../settings/__tests__/ai-section.test.tsx | 194 ++
.../settings/__tests__/sections.test.tsx | 5 +-
src/components/settings/ai-section.tsx | 2140 +++++++++++------
5 files changed, 1725 insertions(+), 732 deletions(-)
create mode 100644 src/components/settings/__tests__/ai-section.test.tsx
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
+ * `` so the SSR-only settings test renders deterministically
+ * without a portal-based Radix tree. Mobile: full-width, generous
+ * height (`h-10`) so finger taps land cleanly on the chevron.
+ */
+function ActiveProviderSelect({
+ value,
+ onChange,
+}: {
+ value: ProviderType;
+ onChange: (next: ProviderType) => void;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("settings.ai.activeProviderHeading")}
+
+
+ {t("settings.ai.activeProviderBody")}
+
+
+ {t("settings.ai.activeProviderLabel")}
+
+
{
+ const next = e.target.value;
+ if (isProviderType(next)) onChange(next);
+ }}
+ className="bg-background border-input mt-1 h-10 w-full rounded-md border px-3 text-sm sm:max-w-md"
+ >
+ {PROVIDER_TYPES.map((p) => (
+
+ {t(`settings.ai.providerSelect.${p}` as const)}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Switch-rendered provider configuration. The component is the
+ * "form below adapts dynamically" half of Marc's pulldown contract:
+ * pick Codex → see Codex form; pick OpenAI → see API-key + model
+ * dropdown + base-URL override. The card frame is the same in every
+ * branch so transitions feel like updating a single concept rather
+ * than swapping pages.
+ */
+function ProviderConfigCard({
+ provider,
+ insightsSettings,
+ userProvider,
+}: {
+ provider: ProviderType;
+ insightsSettings: InsightsSettings | null | undefined;
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("settings.ai.providerConfigTitle")}
+
+ {provider === "codex" && (
+
+ )}
+ {provider === "openai" && (
+
+ )}
+ {provider === "anthropic" && (
+
+ )}
+ {provider === "local" && (
+
+ )}
+ {provider === "admin-openai" && (
+
+ )}
+
+ );
+}
+
+/* ────────────────────────────────────────────────────────────────
+ * Codex (ChatGPT account) form — connect / disconnect / status.
+ * ──────────────────────────────────────────────────────────────── */
+
+function CodexProviderForm({
+ settings,
+}: {
+ settings: InsightsSettings | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [msg, setMsg] = useState(null);
+ const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
+ const [disconnecting, setDisconnecting] = useState(false);
+ const [deviceCode, setDeviceCode] = useState<{
+ userCode: string;
+ verificationUrl: string;
+ intervalSeconds: number;
+ } | null>(null);
+ const [devicePolling, setDevicePolling] = useState(false);
+
+ // OAuth callback handler — reads `?codex_connected=true|codex_error=…`
+ // from the URL and surfaces an inline message.
const [oauthOutcome] = useState<
{ kind: "connected" } | { kind: "error" } | null
>(() => {
@@ -151,17 +466,15 @@ function InsightsSettingsCard({
useEffect(() => {
if (!oauthOutcome) return;
- // The browser-history rewrite is the side effect; the matching i18n
- // message is already painted via the lazy initialiser below.
- window.history.replaceState({}, "", window.location.pathname);
+ const url = new URL(window.location.href);
+ url.searchParams.delete("codex_connected");
+ url.searchParams.delete("codex_error");
+ window.history.replaceState({}, "", url.toString());
if (oauthOutcome.kind === "connected") {
queryClient.invalidateQueries({ queryKey: ["insights"] });
}
}, [oauthOutcome, queryClient]);
- // Seed the initial inline message from the OAuth outcome. Reads happen
- // during render (allowed) — `setMsg` is only called by user actions after
- // mount, never inside an effect.
const [oauthMsgSeeded, setOauthMsgSeeded] = useState(false);
if (!oauthMsgSeeded && oauthOutcome) {
setOauthMsgSeeded(true);
@@ -174,21 +487,6 @@ function InsightsSettingsCard({
}
}
- const hasProvider =
- settings?.codexStatus === "connected" || settings?.hasAdminKey;
-
- // v1.4.7.1: device-code flow. The earlier "redirect to
- // auth.openai.com" path fails because the public Codex CLI client
- // ID's redirect-URI allow-list only covers localhost. The device
- // flow side-steps that — the user types a short code on chatgpt.com
- // and we poll for confirmation.
- const [deviceCode, setDeviceCode] = useState<{
- userCode: string;
- verificationUrl: string;
- intervalSeconds: number;
- } | null>(null);
- const [devicePolling, setDevicePolling] = useState(false);
-
async function handleConnect() {
setMsg(null);
setDevicePolling(true);
@@ -210,9 +508,6 @@ function InsightsSettingsCard({
}
}
- // Poll until the user finishes the approval at chatgpt.com or the
- // 15-minute device-code window expires. Polling stops as soon as
- // `deviceCode` is cleared (success / cancel / unmount).
useEffect(() => {
if (!deviceCode) return;
let cancelled = false;
@@ -236,7 +531,6 @@ function InsightsSettingsCard({
queryClient.invalidateQueries({ queryKey: ["insights"] });
return;
}
- // Pending → schedule next tick.
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
@@ -282,344 +576,203 @@ function InsightsSettingsCard({
}
}
- async function togglePrivacyMode() {
- const newMode = settings?.privacyMode === "raw" ? "aggregated" : "raw";
- await updateSettings.mutateAsync({ privacyMode: newMode });
- }
-
- async function handleRegenerate() {
- setRegenerating(true);
- setMsg(null);
- setMsgType(null);
- try {
- const res = await fetch("/api/insights/generate", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ force: true }),
- });
- const json = await res.json();
- if (!res.ok) {
- if (res.status === 429) {
- setMsg(t("settings.regenerateRateLimit"));
- } else {
- setMsg(json.error || t("settings.savingError"));
- }
- setMsgType("error");
- return;
- }
- setMsg(t("settings.regenerateSuccess"));
- setMsgType("success");
- queryClient.invalidateQueries({ queryKey: ["insights"] });
- } catch {
- setMsg(t("settings.savingError"));
- setMsgType("error");
- } finally {
- setRegenerating(false);
- }
- }
+ const status = settings?.codexStatus;
+ const isConnected = status === "connected";
return (
-
-
-
-
-
{t("settings.kiInsights")}
-
-
- {settings?.codexStatus === "connected" && (
-
- {t("settings.ai.chatgptConnectedBadge")}
-
- )}
- {settings?.codexStatus !== "connected" && settings?.hasAdminKey && (
-
- {t("settings.ai.adminAiActiveBadge")}
-
- )}
- {settings?.codexStatus === "expired" && (
-
- {t("settings.ai.connectionExpiredBadge")}
-
- )}
- {settings?.lastInsightAt && (
-
- {t("settings.lastGeneratedAt")}:{" "}
- {formatDateTime(settings.lastInsightAt)}
-
- )}
-
+
+
+
+ {t("settings.ai.codex.modelSlugLabel")}
+
+ {isConnected ? (
+
+ {t("settings.ai.codex.statusConnected")}
+
+ ) : status === "expired" ? (
+
+ {t("settings.ai.codex.statusExpired")}
+
+ ) : (
+
+ {t("settings.ai.codex.statusDisconnected")}
+
+ )}
-
- {t("settings.kiInsightsDescription")}
+
+ {t("settings.ai.codex.modelSlugBody")}
+ {settings?.lastInsightAt && (
+
+ {t("settings.ai.codex.lastInsight", {
+ when: formatDateTime(settings.lastInsightAt),
+ })}
+
+ )}
+ {settings?.codexConnectedAt && isConnected && (
+
+ {t("settings.ai.connectedSince", {
+ when: formatDateTime(settings.codexConnectedAt),
+ })}
+
+ )}
-
-
- {settings?.codexStatus === "connected" ? (
-
-
-
- {t("settings.ai.chatgptConnectedHeading")}
-
-
- {t("settings.ai.chatgptConnectedBody")}
- {settings.codexConnectedAt && (
- <>
- {" "}
- {t("settings.ai.connectedSince", {
- when: formatDateTime(settings.codexConnectedAt),
- })}
- >
- )}
-
-
- {/* v1.4.3: aligned with the Withings/moodLog disconnect
- buttons — outline + text-destructive so the same action
- reads the same everywhere in Settings. */}
-
- {disconnecting ? (
-
- ) : (
-
- )}
- {t("settings.ai.disconnect")}
-
-
+ {settings?.codexOauthConfigured === false ? (
+
+ {t("settings.ai.oauthNotConfigured")}
+
+ ) : isConnected ? (
+
+ {disconnecting ? (
+
) : (
-
-
-
- {t("settings.ai.connectChatgptHeading")}
-
-
- {t("settings.ai.connectChatgptBody")}
-
-
- {settings?.codexOauthConfigured ? (
- deviceCode ? (
-
-
- {t("settings.ai.deviceCodeHeading")}
-
-
-
- {t("settings.ai.deviceCodeStep1")}{" "}
-
- {deviceCode.verificationUrl}
-
-
-
- {t("settings.ai.deviceCodeStep2")}
-
- {deviceCode.userCode}
-
- navigator.clipboard?.writeText(
- deviceCode.userCode,
- )
- }
- className="text-muted-foreground hover:text-foreground text-xs underline"
- >
- {t("settings.ai.deviceCodeCopy")}
-
-
-
- {t("settings.ai.deviceCodeStep3")}
-
-
- {devicePolling && (
-
-
- {t("settings.ai.deviceCodeWaiting")}
-
- )}
-
- {t("settings.ai.deviceCodeCancel")}
-
-
-
- ) : (
-
- {devicePolling ? (
-
- ) : (
-
- )}
- {t("settings.ai.connectChatgptCta")}
-
- )
- ) : (
-
- {t("settings.ai.oauthNotConfigured")}
-
- )}
- {settings?.hasAdminKey && (
-
- {t("settings.ai.adminFallback")}
-
- )}
-
+
)}
-
-
- {msg && (
-
- {msg}
+ {t("settings.ai.codex.disconnectButton")}
+
+ ) : deviceCode ? (
+
+
+ {t("settings.ai.deviceCodeHeading")}
- )}
-
- {hasProvider && (
-
-
-
-
{t("settings.rawData")}
-
- {settings?.privacyMode === "raw"
- ? t("settings.rawDataOnDescription")
- : t("settings.rawDataOffDescription")}
-
-
-
-
-
-
- {settings?.privacyMode === "raw" && (
-
- {t("settings.rawDataWarning")}
+
+
+ {t("settings.ai.deviceCodeStep1")}{" "}
+
+ {deviceCode.verificationUrl}
+
+
+
+ {t("settings.ai.deviceCodeStep2")}
+
+ {deviceCode.userCode}
+
+ navigator.clipboard?.writeText(deviceCode.userCode)
+ }
+ className="text-muted-foreground hover:text-foreground text-xs underline"
+ >
+ {t("settings.ai.deviceCodeCopy")}
+
+
+ {t("settings.ai.deviceCodeStep3")}
+
+
+ {devicePolling && (
+
+
+ {t("settings.ai.deviceCodeWaiting")}
+
)}
+
+ {t("settings.ai.deviceCodeCancel")}
+
- )}
+
+ ) : (
+
+ {devicePolling ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.codex.connectButton")}
+
+ )}
- {hasProvider && (
-
- {regenerating ? (
-
- ) : (
-
- )}
- {t("settings.regenerateInsights")}
-
- )}
-
-
-
-
-
+ {msg && (
+
+ {msg}
+
+ )}
);
}
-function UserAIProviderSubsection() {
+/* ────────────────────────────────────────────────────────────────
+ * OpenAI form — API key + model select + collapsed Base URL override.
+ * Save mutation flips both `aiProvider` (for the legacy single-result
+ * resolver) and `aiOpenaiKeyEncrypted` (the user's key) so an OPENAI
+ * pick is visible to every code path that reads the row.
+ * ──────────────────────────────────────────────────────────────── */
+
+function OpenAIProviderForm({
+ userProvider,
+}: {
+ userProvider: UserAIProvider | null | undefined;
+}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
- const [provider, setProvider] = useState
("");
+
+ const [apiKey, setApiKey] = useState("");
const [modelChoice, setModelChoice] = useState("");
- const [customModel, setCustomModel] = useState("");
- const [baseUrl, setBaseUrl] = useState("");
- const [anthropicKey, setAnthropicKey] = useState("");
- const [localKey, setLocalKey] = useState("");
- const [openaiKey, setOpenaiKey] = useState("");
- const [saveMsg, setSaveMsg] = useState(null);
- const [saveOk, setSaveOk] = useState(false);
- const [testMsg, setTestMsg] = useState(null);
- const [testOk, setTestOk] = useState(false);
- const [testing, setTesting] = useState(false);
+ const [customModel, setCustomModel] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
- const { data, isLoading } = 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();
- return json.data as UserAIProvider;
- },
- });
-
- // React-recommended sync-from-server pattern (no setState-in-effect).
- // The model dropdown reflects the saved value: an exact match against
- // the preset list selects that preset; anything else (or the empty
- // "use default" string) collapses to the custom-input mode so the
- // user keeps full control.
- const dataKey = data
- ? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}`
- : null;
- const [seededKey, setSeededKey] = useState(null);
- if (dataKey && dataKey !== seededKey) {
- setSeededKey(dataKey);
- const nextProvider = data!.provider ?? "";
- setProvider(nextProvider);
- const presets = MODEL_PRESETS[nextProvider] ?? [];
- const savedModel = data!.model ?? "";
- if (savedModel && presets.includes(savedModel)) {
- setModelChoice(savedModel);
- setCustomModel("");
- } else if (savedModel) {
- setModelChoice(CUSTOM_MODEL_SENTINEL);
- setCustomModel(savedModel);
- } else {
- setModelChoice("");
- setCustomModel("");
+ const seededKey =
+ userProvider != null
+ ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
+ : null;
+ const [previousSeed, setPreviousSeed] = useState(null);
+ if (seededKey && seededKey !== previousSeed) {
+ setPreviousSeed(seededKey);
+ if (userProvider?.provider === "OPENAI") {
+ const saved = userProvider.model ?? "";
+ if (
+ saved &&
+ (OPENAI_MODEL_PRESETS as readonly string[]).includes(saved)
+ ) {
+ setModelChoice(saved);
+ setCustomModel("");
+ } else if (saved) {
+ setModelChoice(CUSTOM_MODEL_SENTINEL);
+ setCustomModel(saved);
+ }
+ setBaseUrl(userProvider.baseUrl ?? "");
}
- setBaseUrl(data!.baseUrl ?? "");
}
- const presets = MODEL_PRESETS[provider] ?? [];
const effectiveModel =
modelChoice === CUSTOM_MODEL_SENTINEL
? customModel.trim()
: modelChoice.trim();
- // Reset model dropdown when provider changes — the preset list is
- // provider-specific. Don't wipe the custom model the user typed in
- // case they wanted to keep using it (rare but cheap).
- function handleProviderChange(next: string) {
- setProvider(next);
- setModelChoice("");
- }
-
const saveMutation = useMutation({
mutationFn: async () => {
const body: Record = {
- provider: provider || null,
+ provider: uiToLegacyProviderEnum("openai"),
model: effectiveModel || null,
- baseUrl: baseUrl || null,
+ baseUrl: baseUrl.trim() || null,
};
- if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
- if (localKey.trim()) body.localKey = localKey.trim();
- if (openaiKey.trim()) body.openaiKey = openaiKey.trim();
+ if (apiKey.trim()) body.openaiKey = apiKey.trim();
const res = await fetch("/api/user/ai-provider", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -629,263 +782,108 @@ function UserAIProviderSubsection() {
if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
},
onSuccess: () => {
- setSaveMsg(t("settings.ai.saved"));
- setSaveOk(true);
- setAnthropicKey("");
- setLocalKey("");
- setOpenaiKey("");
+ setOk(true);
+ setMsg(t("settings.ai.saved"));
+ setApiKey("");
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
queryClient.invalidateQueries({ queryKey: ["insights"] });
},
onError: (e) => {
- setSaveMsg(
- e instanceof Error ? e.message : t("settings.ai.errorGeneric"),
- );
- setSaveOk(false);
+ setOk(false);
+ setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
});
- // v1.5 phase-5 — "Remove saved key" closes the v1.4.6 deferred polish.
- // Sends an explicit `null` for whichever provider key is stored so the
- // PATCH route clears it (the route already handles the empty-string /
- // null case). Provider/model/baseUrl are left alone so the user can
- // keep their selection while just rotating the secret.
- const removeKeyMutation = useMutation({
- mutationFn: async (target: "openaiKey" | "anthropicKey" | "localKey") => {
- const res = await fetch("/api/user/ai-provider", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ [target]: null }),
- });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
- },
- onSuccess: () => {
- setSaveMsg(t("settings.ai.keyRemoved"));
- setSaveOk(true);
- queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
- queryClient.invalidateQueries({ queryKey: ["insights"] });
- },
- onError: (e) => {
- setSaveMsg(
- e instanceof Error ? e.message : t("settings.ai.errorGeneric"),
- );
- setSaveOk(false);
- },
- });
-
- /**
- * Maps the currently selected provider to its stored-key flag and the
- * PATCH-body field name. Returns null when there is no saved key for
- * the active provider — the "Remove saved key" button hides itself.
- */
- const removableKey: "openaiKey" | "anthropicKey" | "localKey" | null =
- (() => {
- if (provider === "OPENAI" && data?.hasOpenaiKey) return "openaiKey";
- if (provider === "ANTHROPIC" && data?.hasAnthropicKey)
- return "anthropicKey";
- if (provider === "LOCAL" && data?.hasLocalKey) return "localKey";
- return null;
- })();
-
- async function runTest() {
- setTesting(true);
- setTestMsg(null);
- try {
- const overrideBody: Record = {};
- if (provider) overrideBody.provider = provider;
- if (effectiveModel) overrideBody.model = effectiveModel;
- if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim();
- if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim();
- if (localKey.trim()) overrideBody.localKey = localKey.trim();
- if (openaiKey.trim()) overrideBody.openaiKey = openaiKey.trim();
- const hasOverride = Object.keys(overrideBody).length > 0;
- const res = await fetch("/api/ai/test", {
- method: "POST",
- headers: hasOverride ? { "Content-Type": "application/json" } : {},
- body: hasOverride ? JSON.stringify(overrideBody) : undefined,
- });
- const json = await res.json();
- if (!res.ok) {
- setTestMsg(json.error || `HTTP ${res.status}`);
- setTestOk(false);
- return;
- }
- setTestMsg(
- `OK — ${json.data.providerType} (${json.data.model})${
- json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : ""
- }`,
- );
- setTestOk(true);
- } catch (e) {
- setTestMsg(e instanceof Error ? e.message : t("settings.ai.testFailed"));
- setTestOk(false);
- } finally {
- setTesting(false);
- }
- }
-
- if (isLoading) return null;
-
return (
-
-
-
- {t("settings.ai.personalProviderHeading")}
-
-
- {t("settings.ai.personalProviderBody")}
-
+
+
+
+ {t("settings.ai.openai.apiKey")}
+ {userProvider?.hasOpenaiKey && (
+
+ {t("settings.ai.savedPreview", {
+ preview: userProvider.openaiKeyPreview ?? "",
+ })}
+
+ )}
+
+
setApiKey(e.target.value)}
+ placeholder={t("settings.ai.openai.apiKeyPlaceholder")}
+ className="mt-1"
+ />
-
+
+
+ {t("settings.ai.openai.modelSelect")}
+
+ setModelChoice(e.target.value)}
+ className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
+ >
+ {t("settings.ai.modelOptionDefault")}
+ {OPENAI_MODEL_PRESETS.map((m) => (
+
+ {m}
+
+ ))}
+
+ {t("settings.ai.openai.modelOptionCustom")}
+
+
+
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && (
-
- {t("settings.ai.providerLabel")}
+
+ {t("settings.ai.openai.modelCustomLabel")}
- handleProviderChange(e.target.value)}
- className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
- >
- {t("settings.ai.providerOptionDefault")}
-
- {t("settings.ai.providerOptionOpenai")}
-
-
- {t("settings.ai.providerOptionAnthropic")}
-
-
- {t("settings.ai.providerOptionLocal")}
-
-
+ setCustomModel(e.target.value)}
+ placeholder={t("settings.ai.openai.modelCustomPlaceholder")}
+ className="mt-1"
+ />
+ )}
-
- {t("settings.ai.modelLabel")}
- setModelChoice(e.target.value)}
- disabled={!provider}
- className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
- >
- {t("settings.ai.modelOptionDefault")}
- {presets.map((m) => (
-
- {m}
-
- ))}
- {provider && (
-
- {t("settings.ai.modelOptionCustom")}
-
- )}
-
-
-
- {modelChoice === CUSTOM_MODEL_SENTINEL && provider && (
-
-
- {t("settings.ai.customModelLabel")}
+
+
setShowAdvanced((v) => !v)}
+ className="text-muted-foreground hover:text-foreground text-xs underline"
+ >
+ {showAdvanced
+ ? t("settings.ai.openai.hideAdvanced")
+ : t("settings.ai.openai.showAdvanced")}
+
+ {showAdvanced && (
+
+
+ {t("settings.ai.openai.baseUrlLabel")}
setCustomModel(e.target.value)}
- placeholder={
- provider === "ANTHROPIC"
- ? "claude-3-5-sonnet-latest"
- : provider === "LOCAL"
- ? "llama3:8b"
- : "gpt-4o-mini"
- }
- className="mt-1"
+ id="ai-openai-base-url"
+ value={baseUrl}
+ onChange={(e) => setBaseUrl(e.target.value)}
+ placeholder={t("settings.ai.openai.baseUrlPlaceholder")}
/>
-
- )}
-
- {provider === "OPENAI" && (
-
-
- {t("settings.ai.openaiKeyLabel")}
- {data?.hasOpenaiKey && (
-
- {t("settings.ai.savedPreview", {
- preview: data.openaiKeyPreview ?? "",
- })}
-
- )}
-
-
setOpenaiKey(e.target.value)}
- placeholder="sk-..."
- className="mt-1"
- />
-
- {t("settings.ai.openaiKeyHelp")}
+
+ {t("settings.ai.openai.baseUrlHelp")}
)}
-
- {provider === "ANTHROPIC" && (
-
-
- {t("settings.ai.anthropicKeyLabel")}
- {data?.hasAnthropicKey && (
-
- {t("settings.ai.savedPreview", {
- preview: data.anthropicKeyPreview ?? "",
- })}
-
- )}
-
-
setAnthropicKey(e.target.value)}
- placeholder="sk-ant-..."
- className="mt-1"
- />
-
- )}
-
- {provider === "LOCAL" && (
- <>
-
-
- {t("settings.ai.baseUrlLabel")}
-
- setBaseUrl(e.target.value)}
- placeholder="http://localhost:11434/v1"
- className="mt-1"
- />
-
-
-
- {t("settings.ai.localKeyLabel")}
-
-
setLocalKey(e.target.value)}
- placeholder={
- data?.hasLocalKey ? t("settings.ai.savedShort") : ""
- }
- className="mt-1"
- />
-
- >
- )}
-
+
saveMutation.mutate()}
@@ -898,142 +896,830 @@ function UserAIProviderSubsection() {
)}
{t("settings.ai.saveCta")}
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
+
+/* ────────────────────────────────────────────────────────────────
+ * Anthropic form — API key + model dropdown.
+ * ──────────────────────────────────────────────────────────────── */
+
+function AnthropicProviderForm({
+ userProvider,
+}: {
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [apiKey, setApiKey] = useState("");
+ const [modelChoice, setModelChoice] = useState("");
+ const [customModel, setCustomModel] = useState("");
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const seededKey =
+ userProvider != null
+ ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}`
+ : null;
+ const [previousSeed, setPreviousSeed] = useState(null);
+ if (seededKey && seededKey !== previousSeed) {
+ setPreviousSeed(seededKey);
+ if (userProvider?.provider === "ANTHROPIC") {
+ const saved = userProvider.model ?? "";
+ if (
+ saved &&
+ (ANTHROPIC_MODEL_PRESETS as readonly string[]).includes(saved)
+ ) {
+ setModelChoice(saved);
+ setCustomModel("");
+ } else if (saved) {
+ setModelChoice(CUSTOM_MODEL_SENTINEL);
+ setCustomModel(saved);
+ }
+ }
+ }
+
+ const effectiveModel =
+ modelChoice === CUSTOM_MODEL_SENTINEL
+ ? customModel.trim()
+ : modelChoice.trim();
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const body: Record = {
+ provider: uiToLegacyProviderEnum("anthropic"),
+ model: effectiveModel || null,
+ };
+ if (apiKey.trim()) body.anthropicKey = apiKey.trim();
+ const res = await fetch("/api/user/ai-provider", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.saved"));
+ setApiKey("");
+ queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
+ queryClient.invalidateQueries({ queryKey: ["insights"] });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
+ },
+ });
+
+ return (
+
+
+
+ {t("settings.ai.anthropicKeyLabel")}
+ {userProvider?.hasAnthropicKey && (
+
+ {t("settings.ai.savedPreview", {
+ preview: userProvider.anthropicKeyPreview ?? "",
+ })}
+
+ )}
+
+
setApiKey(e.target.value)}
+ placeholder="sk-ant-…"
+ className="mt-1"
+ />
+
+
+
+
+ {t("settings.ai.modelLabel")}
+
+ setModelChoice(e.target.value)}
+ className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
+ >
+ {t("settings.ai.modelOptionDefault")}
+ {ANTHROPIC_MODEL_PRESETS.map((m) => (
+
+ {m}
+
+ ))}
+
+ {t("settings.ai.modelOptionCustom")}
+
+
+
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && (
+
+
+ {t("settings.ai.customModelLabel")}
+
+ setCustomModel(e.target.value)}
+ placeholder="claude-3-5-sonnet-latest"
+ className="mt-1"
+ />
+
+ )}
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.saveCta")}
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
+
+/* ────────────────────────────────────────────────────────────────
+ * Local (OpenAI-compatible) form — base URL + optional key + model.
+ * ──────────────────────────────────────────────────────────────── */
+
+function LocalProviderForm({
+ userProvider,
+}: {
+ userProvider: UserAIProvider | null | undefined;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const [baseUrl, setBaseUrl] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const [modelChoice, setModelChoice] = useState("");
+ const [customModel, setCustomModel] = useState("");
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const seededKey =
+ userProvider != null
+ ? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
+ : null;
+ const [previousSeed, setPreviousSeed] = useState(null);
+ if (seededKey && seededKey !== previousSeed) {
+ setPreviousSeed(seededKey);
+ if (userProvider?.provider === "LOCAL") {
+ setBaseUrl(userProvider.baseUrl ?? "");
+ const saved = userProvider.model ?? "";
+ if (
+ saved &&
+ (LOCAL_MODEL_PRESETS as readonly string[]).includes(saved)
+ ) {
+ setModelChoice(saved);
+ setCustomModel("");
+ } else if (saved) {
+ setModelChoice(CUSTOM_MODEL_SENTINEL);
+ setCustomModel(saved);
+ }
+ }
+ }
+
+ const effectiveModel =
+ modelChoice === CUSTOM_MODEL_SENTINEL
+ ? customModel.trim()
+ : modelChoice.trim();
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const body: Record = {
+ provider: uiToLegacyProviderEnum("local"),
+ baseUrl: baseUrl.trim() || null,
+ model: effectiveModel || null,
+ };
+ if (apiKey.trim()) body.localKey = apiKey.trim();
+ const res = await fetch("/api/user/ai-provider", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed"));
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.saved"));
+ setApiKey("");
+ queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
+ queryClient.invalidateQueries({ queryKey: ["insights"] });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
+ },
+ });
+
+ return (
+
+
+
+ {t("settings.ai.baseUrlLabel")}
+
+ setBaseUrl(e.target.value)}
+ placeholder="http://localhost:11434/v1"
+ className="mt-1"
+ />
+
+
+
{t("settings.ai.localKeyLabel")}
+
setApiKey(e.target.value)}
+ placeholder={
+ userProvider?.hasLocalKey ? t("settings.ai.savedShort") : ""
+ }
+ className="mt-1"
+ />
+
+
+ {t("settings.ai.modelLabel")}
+ setModelChoice(e.target.value)}
+ className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
+ >
+ {t("settings.ai.modelOptionDefault")}
+ {LOCAL_MODEL_PRESETS.map((m) => (
+
+ {m}
+
+ ))}
+
+ {t("settings.ai.modelOptionCustom")}
+
+
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && (
+
+
+ {t("settings.ai.customModelLabel")}
+
+ setCustomModel(e.target.value)}
+ placeholder="llama3:8b"
+ className="mt-1"
+ />
+
+ )}
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.saveCta")}
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
+
+/* ────────────────────────────────────────────────────────────────
+ * Admin OpenAI form — read-only "the operator has set this up".
+ * ──────────────────────────────────────────────────────────────── */
+
+function AdminOpenAIProviderForm({
+ hasAdminKey,
+}: {
+ hasAdminKey: boolean | undefined;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("settings.ai.adminOpenai.title")}
+
+
+ {hasAdminKey
+ ? t("settings.ai.adminOpenai.body")
+ : t("settings.ai.adminOpenai.notConfigured")}
+
+
+ );
+}
+
+/* ────────────────────────────────────────────────────────────────
+ * Fallback chain card — reorder via arrows, toggle, remove, add,
+ * reset. Uses no new dependency (dnd-kit isn't in package.json).
+ * ──────────────────────────────────────────────────────────────── */
+
+function FallbackChainCard({
+ chain,
+ selected,
+ onSelect,
+}: {
+ chain: { providerType: ProviderType; available: boolean }[];
+ selected: ProviderType;
+ onSelect: (next: ProviderType) => void;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ // Local working copy. Server-confirmed values arrive via `chain`
+ // prop; we keep our own state so the user can shuffle multiple rows
+ // before clicking "Save chain order".
+ const seededKey = chain.map((c) => c.providerType).join(",");
+ const [seeded, setSeeded] = useState(null);
+ const [entries, setEntries] = useState(() =>
+ chain.map((c) => ({ providerType: c.providerType, enabled: true })),
+ );
+ if (seededKey !== seeded) {
+ setSeeded(seededKey);
+ setEntries(
+ chain.map((c) => ({
+ providerType: c.providerType,
+ enabled: true,
+ })),
+ );
+ }
+
+ const [msg, setMsg] = useState(null);
+ const [ok, setOk] = useState(false);
+
+ const saveMutation = useMutation({
+ mutationFn: async (next: ChainEntry[]) => {
+ const body = {
+ chain: next.map((entry, idx) => ({
+ providerType: entry.providerType,
+ priority: idx + 1,
+ enabled: entry.enabled,
+ })),
+ };
+ const res = await fetch("/api/insights/provider-chain", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json();
+ if (!res.ok) {
+ throw new Error(
+ json.error || t("settings.ai.providerChain.saveFailed"),
+ );
+ }
+ },
+ onSuccess: () => {
+ setOk(true);
+ setMsg(t("settings.ai.providerChain.saved"));
+ queryClient.invalidateQueries({ queryKey: ["insights"] });
+ },
+ onError: (e) => {
+ setOk(false);
+ setMsg(
+ e instanceof Error
+ ? e.message
+ : t("settings.ai.providerChain.saveFailed"),
+ );
+ },
+ });
+
+ function move(idx: number, delta: -1 | 1) {
+ setEntries((prev) => {
+ const next = [...prev];
+ const target = idx + delta;
+ if (target < 0 || target >= next.length) return prev;
+ [next[idx], next[target]] = [next[target], next[idx]];
+ return next;
+ });
+ }
+
+ function toggle(idx: number) {
+ setEntries((prev) => {
+ const next = [...prev];
+ next[idx] = { ...next[idx], enabled: !next[idx].enabled };
+ return next;
+ });
+ }
+
+ function remove(idx: number) {
+ setEntries((prev) => prev.filter((_, i) => i !== idx));
+ }
+
+ const present = useMemo(
+ () => new Set(entries.map((e) => e.providerType)),
+ [entries],
+ );
+ const addable = PROVIDER_TYPES.filter((p) => !present.has(p));
+
+ function add(p: ProviderType) {
+ setEntries((prev) => [...prev, { providerType: p, enabled: true }]);
+ }
+
+ function resetToDefaults() {
+ setEntries(DEFAULT_CHAIN.map((d) => ({ ...d })));
+ }
+
+ return (
+
+
+
+ {t("settings.ai.providerChain.title")}
+
+
+ {t("settings.ai.providerChain.description")}
+
+
+
+
+ {entries.map((entry, idx) => (
+
+
+ {idx + 1}.
+
+ onSelect(entry.providerType)}
+ className="flex-1 text-left text-sm font-medium hover:underline"
+ >
+ {t(`settings.ai.providerChain.types.${entry.providerType}`)}
+
+ toggle(idx)}
+ aria-label={t(
+ `settings.ai.providerChain.types.${entry.providerType}`,
+ )}
+ />
+ move(idx, -1)}
+ >
+
+
+ move(idx, 1)}
+ >
+
+
+ remove(idx)}
+ disabled={entries.length <= 1}
+ >
+
+
+
+ ))}
+
+
+
+ {addable.length > 0 ? (
+
+ ) : (
+
+ {t("settings.ai.providerChain.addNoneAvailable")}
+
+ )}
+
saveMutation.mutate(entries)}
+ disabled={saveMutation.isPending || entries.length === 0}
+ data-testid="ai-fallback-chain-save"
+ >
+ {saveMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.ai.providerChain.saveOrder")}
+
+
+
+
+
+ {t("settings.ai.providerChain.resetDefaults")}
+
+
+
+
+
+ {t("settings.ai.providerChain.resetConfirmTitle")}
+
+
+ {t("settings.ai.providerChain.resetConfirmBody")}
+
+
+
+ {t("common.cancel")}
+
+ {t("settings.ai.providerChain.resetDefaults")}
+
+
+
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ );
+}
+
+function AddProviderControl({
+ addable,
+ onAdd,
+}: {
+ addable: readonly ProviderType[];
+ onAdd: (p: ProviderType) => void;
+}) {
+ const { t } = useTranslations();
+ const [picked, setPicked] = useState(addable[0] ?? "");
+
+ return (
+
+
setPicked(e.target.value)}
+ className="bg-background border-input h-8 rounded-md border px-2 text-sm"
+ >
+ {addable.map((p) => (
+
+ {t(`settings.ai.providerChain.types.${p}`)}
+
+ ))}
+
+
{
+ if (isProviderType(picked)) onAdd(picked);
+ }}
+ >
+
+ {t("settings.ai.providerChain.addProvider")}
+
+
+ );
+}
+
+/* ────────────────────────────────────────────────────────────────
+ * Runtime actions — Test active provider, regenerate insights, raw-mode toggle.
+ * ──────────────────────────────────────────────────────────────── */
+
+function RuntimeActionsRow({
+ provider,
+ userProvider,
+ canRegenerate,
+ privacyMode,
+ lastInsightAt,
+ onRegenerated,
+ onPrivacyChanged,
+}: {
+ provider: ProviderType;
+ userProvider: UserAIProvider | null | undefined;
+ canRegenerate: boolean;
+ privacyMode: string;
+ lastInsightAt: string | null;
+ onRegenerated: () => void;
+ onPrivacyChanged: () => void;
+}) {
+ const { t } = useTranslations();
+
+ const [testing, setTesting] = useState(false);
+ const [testMsg, setTestMsg] = useState(null);
+ const [testOk, setTestOk] = useState(false);
+ const [regen, setRegen] = useState(false);
+ const [regenMsg, setRegenMsg] = useState(null);
+ const [regenOk, setRegenOk] = useState(false);
+
+ async function runTest() {
+ setTesting(true);
+ setTestMsg(null);
+ try {
+ const res = await fetch("/api/ai/test", { method: "POST" });
+ const json = await res.json();
+ if (!res.ok) {
+ setTestOk(false);
+ setTestMsg(
+ t("settings.ai.testFailedShort", {
+ message: json.error ?? `HTTP ${res.status}`,
+ }),
+ );
+ return;
+ }
+ setTestOk(true);
+ setTestMsg(
+ t("settings.ai.testSuccess", {
+ provider: json.data.providerType,
+ model: json.data.model,
+ }),
+ );
+ } catch (e) {
+ setTestOk(false);
+ setTestMsg(
+ t("settings.ai.testFailedShort", {
+ message: e instanceof Error ? e.message : "fetch error",
+ }),
+ );
+ } finally {
+ setTesting(false);
+ }
+ }
+
+ async function regenerate() {
+ setRegen(true);
+ setRegenMsg(null);
+ try {
+ const res = await fetch("/api/insights/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ force: true }),
+ });
+ const json = await res.json();
+ if (!res.ok) {
+ setRegenOk(false);
+ setRegenMsg(
+ res.status === 429
+ ? t("settings.regenerateRateLimit")
+ : json.error || t("settings.savingError"),
+ );
+ return;
+ }
+ setRegenOk(true);
+ setRegenMsg(t("settings.regenerateSuccess"));
+ onRegenerated();
+ } catch {
+ setRegenOk(false);
+ setRegenMsg(t("settings.savingError"));
+ } finally {
+ setRegen(false);
+ }
+ }
+
+ async function togglePrivacy() {
+ const next = privacyMode === "raw" ? "aggregated" : "raw";
+ await fetch("/api/insights/settings", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ privacyMode: next }),
+ });
+ onPrivacyChanged();
+ }
+
+ const lastInsightLine = lastInsightAt
+ ? `${t("settings.lastGeneratedAt")}: ${formatDateTime(lastInsightAt)}`
+ : null;
+
+ // Reserved for a future iteration that gates the Test button per
+ // provider (e.g. disable when admin-openai is selected and no admin
+ // key is present). For v1.4.16 the API decides.
+ void provider;
+ void userProvider;
+
+ return (
+
+
{testing ? (
) : (
)}
- {t("settings.ai.testCta")}
+ {t("settings.ai.testProvider")}
- {removableKey && (
-
-
-
- {removeKeyMutation.isPending ? (
-
- ) : (
-
- )}
- {t("settings.ai.removeKeyCta")}
-
-
-
-
-
- {t("settings.ai.removeKeyConfirmTitle")}
-
-
- {t("settings.ai.removeKeyConfirmBody")}
-
-
-
- {t("common.cancel")}
- removeKeyMutation.mutate(removableKey)}
- >
- {t("settings.ai.removeKeyCta")}
-
-
-
-
+ {canRegenerate && (
+
+ {regen ? (
+
+ ) : (
+
+ )}
+ {t("settings.regenerateInsights")}
+
+ )}
+ {lastInsightLine && (
+
+ {lastInsightLine}
+
)}
- {saveMsg && (
-
- {saveMsg}
-
+ {canRegenerate && (
+
+
+
+
{t("settings.rawData")}
+
+ {privacyMode === "raw"
+ ? t("settings.rawDataOnDescription")
+ : t("settings.rawDataOffDescription")}
+
+
+
+
+ {privacyMode === "raw" && (
+
+ {t("settings.rawDataWarning")}
+
+ )}
+
)}
+
{testMsg && (
{testMsg}
)}
-
- );
-}
-
-interface ProviderChainSummaryData {
- activeProvider: string | null;
- cachedActiveProvider: string | null;
- configuredChain: { providerType: string; available: boolean }[];
-}
-
-/**
- * v1.4.16 phase B5b — read-only summary of the user's AI provider
- * fallback chain. Surfaces "Active provider: codex" + "Configured:
- * codex, openai" so the user can see the cascade order without going
- * through admin logs. Full management UX (drag-to-reorder, add/remove)
- * is owned by phase B2 (AI provider settings cleanup); this panel is
- * deliberately tight so the two phases don't collide.
- */
-function ProviderChainSummary() {
- const { t } = useTranslations();
- const { data } = 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 ProviderChainSummaryData;
- },
- });
-
- if (!data) return null;
- if (data.configuredChain.length === 0) return null;
-
- const activeLabel = data.activeProvider
- ? t(`settings.ai.providerChain.types.${data.activeProvider}`)
- : t("settings.ai.providerChain.none");
- const chainLabels = data.configuredChain
- .map((entry) => t(`settings.ai.providerChain.types.${entry.providerType}`))
- .join(", ");
-
- return (
-
-
- {t("settings.ai.providerChain.heading")}
-
-
- {t("settings.ai.providerChain.activeLine", { provider: activeLabel })}
- {data.cachedActiveProvider &&
- data.cachedActiveProvider !== data.activeProvider
- ? ` ${t("settings.ai.providerChain.cachedSuffix", {
- provider: t(
- `settings.ai.providerChain.types.${data.cachedActiveProvider}`,
- ),
- })}`
- : ""}
-
-
- {t("settings.ai.providerChain.chainLine", { chain: chainLabels })}
-
-
- {t("settings.ai.providerChain.managementHint")}
-
+ {regenMsg && (
+
+ {regenMsg}
+
+ )}
);
}