"use client"; import { useEffect, useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, Loader2, PlusCircle, RefreshCw, RotateCcw, Save, Sparkles, Trash2, X, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; 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 { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { PasswordInput } from "@/components/ui/password-input"; import { useAuth } from "@/hooks/use-auth"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; 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; } /** * 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 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; enabled: boolean; 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(); return (

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

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

); } /** * 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 the maintainer'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 router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const { data: insightsSettings } = useQuery({ queryKey: queryKeys.insightsSettings(), 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 { data: userProvider } = useQuery({ queryKey: queryKeys.userAiProvider(), 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; }, enabled: isAuthenticated, }); const { data: chainData } = useQuery({ queryKey: queryKeys.insightsProviderChain(), 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 (
{/* v1.4.33 IW7 — the dedicated H2 ("KI-Insights") used to repeat the parent section title ("KI-Auswertungen"). Both are gone now: the section renders "Auswertungen" once at the top, and this card carries only the Sparkles icon + the live provider-status badges, mirroring the description that lives on the section header. The configuration description is moved one level up into the section subtitle so the visual hierarchy stays two-level (section -> form) instead of three-level (section -> card title -> form). */}

{t("settings.kiInsightsDescription")}

queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }) } onPrivacyChanged={() => queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }) } />
); } 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 the * shared `` primitive so the SSR-only settings test * renders deterministically without a portal-based Radix tree, and so * the visual contract stays uniform with the other settings pickers * that landed behind the same primitive in MB7 / CF-52. Mobile: * full-width, height matched to the shared 36 px input contract used * everywhere else in Settings; tap target stays comfortable thanks to * the full-width chevron region. */ function ActiveProviderSelect({ value, onChange, }: { value: ProviderType; onChange: (next: ProviderType) => void; }) { const { t } = useTranslations(); return (

{t("settings.ai.activeProviderHeading")}

{t("settings.ai.activeProviderBody")}

{ const next = e.target.value; if (isProviderType(next)) onChange(next); }} className="mt-1 sm:max-w-md" > {PROVIDER_TYPES.map((p) => ( ))}
); } /** * Switch-rendered provider configuration. The component is the * "form below adapts dynamically" half of the maintainer'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 >(() => { 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; 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: queryKeys.insightsRoot() }); } }, [oauthOutcome, queryClient]); 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"); } } async function handleConnect() { setMsg(null); setDevicePolling(true); try { const res = await fetch("/api/auth/codex/device-start", { method: "POST", }); const json = await res.json(); if (!res.ok) throw new Error(json.error || t("settings.savingError")); setDeviceCode({ userCode: json.data.userCode, verificationUrl: json.data.verificationUrl, intervalSeconds: json.data.intervalSeconds, }); } catch (err) { setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); setDevicePolling(false); } } useEffect(() => { if (!deviceCode) return; let cancelled = false; const intervalMs = Math.max(deviceCode.intervalSeconds, 3) * 1000; async function tick() { try { const res = await fetch("/api/auth/codex/device-poll", { method: "POST", }); const json = await res.json(); if (cancelled) return; if (!res.ok) { throw new Error(json.error || t("settings.savingError")); } if (json.data?.status === "connected") { setDeviceCode(null); setDevicePolling(false); setMsg(t("settings.codexConnected")); setMsgType("success"); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); return; } if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); setDeviceCode(null); setDevicePolling(false); } } const handle = setTimeout(tick, intervalMs); return () => { cancelled = true; clearTimeout(handle); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [deviceCode]); function handleCancelDevice() { setDeviceCode(null); setDevicePolling(false); } 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: queryKeys.insightsRoot() }); } catch (err) { setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); } finally { setDisconnecting(false); } } const status = settings?.codexStatus; const isConnected = status === "connected"; return (
{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.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?.codexOauthConfigured === false ? (

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

) : isConnected ? ( ) : deviceCode ? (

{t("settings.ai.deviceCodeHeading")}

  1. {t("settings.ai.deviceCodeStep1")}{" "} {deviceCode.verificationUrl}
  2. {t("settings.ai.deviceCodeStep2")}
    {deviceCode.userCode}
  3. {t("settings.ai.deviceCodeStep3")}
{devicePolling && ( {t("settings.ai.deviceCodeWaiting")} )}
) : ( )} {msg && (

{msg}

)}
); } /* ──────────────────────────────────────────────────────────────── * 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 [apiKey, setApiKey] = useState(""); const [modelChoice, setModelChoice] = useState(""); const [customModel, setCustomModel] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); 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 === "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 ?? ""); } } const effectiveModel = modelChoice === CUSTOM_MODEL_SENTINEL ? customModel.trim() : modelChoice.trim(); const saveMutation = useMutation({ mutationFn: async () => { const body: Record = { provider: uiToLegacyProviderEnum("openai"), model: effectiveModel || null, baseUrl: baseUrl.trim() || null, }; if (apiKey.trim()) body.openaiKey = 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: queryKeys.userAiProvider() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); }, }); return (
setApiKey(e.target.value)} placeholder={t("settings.ai.openai.apiKeyPlaceholder")} className="mt-1" />
setModelChoice(e.target.value)} className="mt-1" > {OPENAI_MODEL_PRESETS.map((m) => ( ))}
{modelChoice === CUSTOM_MODEL_SENTINEL && (
setCustomModel(e.target.value)} placeholder={t("settings.ai.openai.modelCustomPlaceholder")} className="mt-1" />
)}
{showAdvanced && (
setBaseUrl(e.target.value)} placeholder={t("settings.ai.openai.baseUrlPlaceholder")} />

{t("settings.ai.openai.baseUrlHelp")}

)}
{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: queryKeys.userAiProvider() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); }, }); return (
setApiKey(e.target.value)} placeholder="sk-ant-…" className="mt-1" />
setModelChoice(e.target.value)} className="mt-1" > {ANTHROPIC_MODEL_PRESETS.map((m) => ( ))}
{modelChoice === CUSTOM_MODEL_SENTINEL && (
setCustomModel(e.target.value)} placeholder="claude-3-5-sonnet-latest" className="mt-1" />
)}
{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: queryKeys.userAiProvider() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); }, }); return (
setBaseUrl(e.target.value)} placeholder="http://localhost:11434/v1" className="mt-1" />
setApiKey(e.target.value)} placeholder={ userProvider?.hasLocalKey ? t("settings.ai.savedShort") : "" } className="mt-1" />
setModelChoice(e.target.value)} className="mt-1" > {LOCAL_MODEL_PRESETS.map((m) => ( ))}
{modelChoice === CUSTOM_MODEL_SENTINEL && (
setCustomModel(e.target.value)} placeholder="llama3:8b" className="mt-1" />
)}
{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; enabled: boolean; 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". // v1.4.16 phase D reconcile (code-review H2) — `enabled` from the // wire is now the canonical state. The GET endpoint surfaces the // raw persisted chain so a disabled entry survives the round-trip. const seededKey = chain .map((c) => `${c.providerType}:${c.enabled ? 1 : 0}`) .join(","); const [seeded, setSeeded] = useState(null); const [entries, setEntries] = useState(() => chain.map((c) => ({ providerType: c.providerType, enabled: c.enabled })), ); if (seededKey !== seeded) { setSeeded(seededKey); setEntries( chain.map((c) => ({ providerType: c.providerType, enabled: c.enabled, })), ); } 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: queryKeys.insightsRoot() }); }, 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}. toggle(idx)} aria-label={t( `settings.ai.providerChain.types.${entry.providerType}`, )} />
  • ))}
{addable.length > 0 ? ( ) : (

{t("settings.ai.providerChain.addNoneAvailable")}

)} {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="w-auto" > {addable.map((p) => ( ))}
); } /* ──────────────────────────────────────────────────────────────── * 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 (
{canRegenerate && ( )} {lastInsightLine && ( {lastInsightLine} )}
{canRegenerate && (

{t("settings.rawData")}

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

{privacyMode === "raw" && (
{t("settings.rawDataWarning")}
)}
)} {testMsg && (

{testMsg}

)} {regenMsg && (

{regenMsg}

)}
); }