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 ` 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")} +

+ + +
+ ); +} + +/** + * 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 && ( -
    -