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 + * ` { + 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) => ( + + ))} + + + ); +} + +/** + * 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. */} - -
+ {settings?.codexOauthConfigured === false ? ( +

+ {t("settings.ai.oauthNotConfigured")} +

+ ) : isConnected ? ( + -
- -
  • {t("settings.ai.deviceCodeStep3")}
  • - -
    - {devicePolling && ( - - - {t("settings.ai.deviceCodeWaiting")} - - )} - -
    -
    - ) : ( - - ) - ) : ( -

    - {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")} +
      +
    1. + {t("settings.ai.deviceCodeStep1")}{" "} + + {deviceCode.verificationUrl} + +
    2. +
    3. + {t("settings.ai.deviceCodeStep2")} +
      + {deviceCode.userCode} +
      +
    4. +
    5. {t("settings.ai.deviceCodeStep3")}
    6. +
    +
    + {devicePolling && ( + + + {t("settings.ai.deviceCodeWaiting")} + )} +
    - )} +
    + ) : ( + + )} - {hasProvider && ( - - )} - - - - -
    + {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")} -

    +
    +
    + + setApiKey(e.target.value)} + placeholder={t("settings.ai.openai.apiKeyPlaceholder")} + className="mt-1" + />
    -
    +
    + + +
    + + {modelChoice === CUSTOM_MODEL_SENTINEL && (
    -
    + )} -
    - - -
    - - {modelChoice === CUSTOM_MODEL_SENTINEL && provider && ( -
    -