"use client"; import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RefreshCw, Save, Sparkles, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { PasswordInput } from "@/components/settings/password-input"; import { useAuth } from "@/hooks/use-auth"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; interface InsightsSettings { codexStatus: string; codexConnectedAt: string | null; hasAdminKey: boolean; /** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this * instance. The UI hides the "Connect with ChatGPT" button when * false to avoid the v1.4.2 dead-end where the click bounced the * user to chatgpt.com without any OAuth flow. */ codexOauthConfigured?: boolean; privacyMode: string; lastInsightAt: string | null; } interface UserAIProvider { provider: string | null; model: string | null; baseUrl: string | null; hasAnthropicKey: boolean; anthropicKeyPreview: string | null; hasLocalKey: boolean; hasOpenaiKey: boolean; openaiKeyPreview: string | null; } /** * 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). */ const MODEL_PRESETS: Record> = { OPENAI: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5", "o3-mini"], 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 CUSTOM_MODEL_SENTINEL = "__custom__"; export function AiSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); return (

{t("settings.sections.ai.title")}

{t("settings.sections.ai.description")}

); } function InsightsSettingsCard({ 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 { data: settings } = useQuery({ queryKey: ["insights", "settings"], queryFn: async () => { const res = await fetch("/api/insights/settings"); if (!res.ok) return null; const json = await res.json(); return json.data as InsightsSettings; }, 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 json = await res.json(); if (!res.ok) throw new Error(json.error); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["insights"] }); }, }); // 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 [oauthOutcome] = useState< { kind: "connected" } | { kind: "error" } | null >(() => { if (typeof window === "undefined") return null; const params = new URLSearchParams(window.location.search); if (params.get("codex_connected") === "true") return { kind: "connected" }; if (params.get("codex_error")) return { kind: "error" }; return null; }); 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); 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); if (oauthOutcome.kind === "connected") { setMsg(t("settings.codexConnected")); setMsgType("success"); } else { setMsg(t("settings.codexConnectionFailed")); setMsgType("error"); } } const hasProvider = settings?.codexStatus === "connected" || settings?.hasAdminKey; function handleConnect() { window.location.href = "/api/auth/codex/authorize"; } async function handleDisconnect() { setDisconnecting(true); setMsg(null); try { const res = await fetch("/api/auth/codex/disconnect", { method: "DELETE", }); if (!res.ok) { const json = await res.json(); throw new Error(json.error); } setMsg(t("settings.codexDisconnected")); setMsgType("success"); queryClient.invalidateQueries({ queryKey: ["insights"] }); } catch (err) { setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); } finally { setDisconnecting(false); } } 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); } } return (

{t("settings.kiInsights")}

{settings?.codexStatus === "connected" && ( ChatGPT verbunden )} {settings?.codexStatus !== "connected" && settings?.hasAdminKey && ( Admin-KI aktiv )} {settings?.codexStatus === "expired" && ( Verbindung abgelaufen )} {settings?.lastInsightAt && ( {t("settings.lastGeneratedAt")}:{" "} {formatDateTime(settings.lastInsightAt)} )}

{t("settings.kiInsightsDescription")}

{settings?.codexStatus === "connected" ? (

ChatGPT verbunden

Insights werden über dein ChatGPT-Abo generiert — keine zusätzlichen Kosten. {settings.codexConnectedAt && ( <> {" "} Verbunden seit {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. */}
) : (

Mit ChatGPT verbinden

Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte Gesundheitsanalysen basierend auf aktuellen medizinischen Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.

{settings?.codexOauthConfigured ? ( ) : (

ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — nutze stattdessen einen eigenen API-Key unten.

)} {settings?.hasAdminKey && (

Alternativ nutzt HealthLog den vom Administrator konfigurierten KI-Anbieter.

)}
)}
{msg && (

{msg}

)} {hasProvider && (

{t("settings.rawData")}

{settings?.privacyMode === "raw" ? t("settings.rawDataOnDescription") : t("settings.rawDataOffDescription")}

{settings?.privacyMode === "raw" && (
{t("settings.rawDataWarning")}
)}
)} {hasProvider && ( )}
); } function UserAIProviderSubsection() { const queryClient = useQueryClient(); const [provider, setProvider] = 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 { 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(""); } 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, model: effectiveModel || null, baseUrl: baseUrl || null, }; if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim(); if (localKey.trim()) body.localKey = localKey.trim(); if (openaiKey.trim()) body.openaiKey = openaiKey.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 || "Save failed"); }, onSuccess: () => { setSaveMsg("Gespeichert"); setSaveOk(true); setAnthropicKey(""); setLocalKey(""); setOpenaiKey(""); queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); queryClient.invalidateQueries({ queryKey: ["insights"] }); }, onError: (e) => { setSaveMsg(e instanceof Error ? e.message : "Fehler"); setSaveOk(false); }, }); 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 : "Test fehlgeschlagen"); setTestOk(false); } finally { setTesting(false); } } if (isLoading) return null; return (

KI-Provider (persönlich)

Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button oben.

{modelChoice === CUSTOM_MODEL_SENTINEL && provider && (
setCustomModel(e.target.value)} placeholder={ provider === "ANTHROPIC" ? "claude-3-5-sonnet-latest" : provider === "LOCAL" ? "llama3:8b" : "gpt-4o-mini" } className="mt-1" />
)} {provider === "OPENAI" && (
setOpenaiKey(e.target.value)} placeholder="sk-..." className="mt-1" />

Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das Abo nutze den Verbinden-Button oben.

)} {provider === "ANTHROPIC" && (
setAnthropicKey(e.target.value)} placeholder="sk-ant-..." className="mt-1" />
)} {provider === "LOCAL" && ( <>
setBaseUrl(e.target.value)} placeholder="http://localhost:11434/v1" className="mt-1" />
setLocalKey(e.target.value)} placeholder={data?.hasLocalKey ? "(gespeichert)" : ""} className="mt-1" />
)}
{saveMsg && (

{saveMsg}

)} {testMsg && (

{testMsg}

)}
); }