diff --git a/app/components/SettingsView.tsx b/app/components/SettingsView.tsx index e07b78f..4074cdd 100644 --- a/app/components/SettingsView.tsx +++ b/app/components/SettingsView.tsx @@ -3,11 +3,12 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useDateFormat } from "../../lib/date-format-hook"; import { useFlag } from "../../lib/feature-flags-hooks"; import { scrollPulse } from "../../lib/scroll-pulse"; import { TOAST_HOLD_MS } from "../../lib/toast-timing"; +import { useAiSettings } from "../../lib/use-ai-settings"; import { useImportHistory } from "../../lib/use-import-history"; import { useModalFocus } from "../../lib/use-modal-focus"; import { useRovingRadioGroup } from "../../lib/use-roving-radiogroup"; @@ -18,27 +19,34 @@ import SettingsAutoSaveToast, { pushSettingsToast, } from "./SettingsAutoSaveToast"; import SettingsSidebar from "./SettingsSidebar"; +import AiSummariesSection from "./settings/AiSummariesSection"; import BackupSection from "./settings/BackupSection"; +import DeleteImportModal from "./settings/DeleteImportModal"; import DeploymentDiagnosticsSection from "./settings/DeploymentDiagnosticsSection"; import DeveloperSection from "./settings/DeveloperSection"; import ExportDataSection from "./settings/ExportDataSection"; -import { fmtDuration, fmtShortDate } from "./settings/format"; import ImportHistoryLinkCard from "./settings/ImportHistoryLinkCard"; import ImportHistorySection from "./settings/ImportHistorySection"; import LanguageSection from "./settings/LanguageSection"; import PolicyAlertsSection from "./settings/PolicyAlertsSection"; import PrivacyPoliciesBulkSection from "./settings/PrivacyPoliciesBulkSection"; import RegionSection from "./settings/RegionSection"; +import RemoveItemModal from "./settings/RemoveItemModal"; +import ResetAppModal from "./settings/ResetAppModal"; import ResetSection from "./settings/ResetSection"; +import RestoreBackupModal from "./settings/RestoreBackupModal"; import SyncScheduleSection from "./settings/SyncScheduleSection"; import SyncStatusSection from "./settings/SyncStatusSection"; import type { AiDebugLogRow, + BackupRestorePreview, DeploymentDiagnostics, Schedule, + StoredAiSettings, SyncStatus, } from "./settings/types"; import WaybackImportSection from "./settings/WaybackImportSection"; +import WaybackRemoveModal from "./settings/WaybackRemoveModal"; import { useTaskCenter } from "./TaskCenter"; import Toast from "./Toast"; @@ -57,12 +65,7 @@ import { sanitizeA11yProfile, } from "../../lib/accessibility-profile"; import { - AI_PROVIDER_OPTIONS, - type AIProvider, - getAiModelOptions, normalizeAiProvider, - providerRequiresApiKey, - providerSupportsApiKey, resolveDefaultBaseUrl, resolveDefaultModel, } from "../../lib/ai-config"; @@ -74,11 +77,6 @@ import { resolvePrefs as resolveNotificationPrefs, sanitizePrefs as sanitizeNotificationPrefs, } from "../../lib/notification-prefs"; -import type { - PolicyLensKey, - PolicyRating, - PolicySummary, -} from "../../lib/policy-summary-meta"; import { DEFAULT_PROFILE, type PrivacyProfile, @@ -105,40 +103,7 @@ type WaybackRunStatus = | "cancel_requested" | "stale"; -interface StoredAiSettings { - apiKey: string; - baseUrl: string; - debugLogging: boolean; - model: string; - provider: AIProvider; - summarizeOnImport: boolean; - timeoutChunkMs: string; - // Per-phase AI request timeouts, persisted as strings so the input can - // hold "" while the user is mid-edit. Empty string = server default. - timeoutDirectMs: string; - timeoutMergeMs: string; -} - /** One row persisted in the `ai_debug_log` table (see lib/privacy-policy.ts). */ -interface AiSamplePolicyResult { - durationMs: number; - mode: "direct" | "chunked"; - model: string; - ok: true; - provider: string; - sample: { - appName: string; - developer: string; - policyUrl: string; - policyText: string; - scenario: string; - wordCount: number; - reviewChecklist: string[]; - expectedSignals: string[]; - }; - summary: PolicySummary; -} - interface BackupSnapshotSettings { enabled: boolean; intervalHours: number; @@ -305,7 +270,6 @@ export default function SettingsView({ const tSections = useTranslations("settings.sections"); const tBulkPhase = useTranslations("settings.bulk_phase"); const tBulkStream = useTranslations("settings.bulk_stream"); - const tAiOptions = useTranslations("ai_options"); // Per-section subtitle copy + the App Store Region card's controls // + the accessibility-labels card's checkbox copy. Pulled out of the // root `tSettings` so the call-sites in the JSX read short. @@ -319,27 +283,15 @@ export default function SettingsView({ const tBackupCard = useTranslations("settings.backup_card"); const tNotifPrefsCard = useTranslations("settings.notification_prefs_card"); const tSchedule = useTranslations("settings.schedule"); - const tResetCard = useTranslations("settings.reset_app_card"); const tWayback = useTranslations("settings.wayback"); - const tWaybackRemove = useTranslations("settings.wayback.remove_modal"); - const tAi = useTranslations("settings.ai"); const tAiProvider = useTranslations("settings.ai.provider"); - const tAiConn = useTranslations("settings.ai.connection"); - const tAiModel = useTranslations("settings.ai.model"); const tAiSample = useTranslations("settings.ai.sample"); - const tAiBehavior = useTranslations("settings.ai.behavior"); - const tAiFooter = useTranslations("settings.ai.footer"); - const tLens = useTranslations("policy_lens"); - const tRating = useTranslations("policy_rating"); // Developer Options: the panels own their own namespaces now, but the // AI-timeout auto-save toasts are wired up here, so this one stays. const tDevAiTimeouts = useTranslations("settings.dev_options.ai_timeouts"); // Bottom-of-page modals — restore backup, delete import, remove app. // Their reset/wayback siblings already live under settings.* and so // do these for symmetry. - const tModalRestore = useTranslations("settings.modals.restore_backup"); - const tModalDelete = useTranslations("settings.modals.delete_import"); - const tModalRemoveApp = useTranslations("settings.modals.remove_app"); // Privacy & Accessibility profile cards — toggle hint, Save button, // saved-count summary, unsaved/empty hints. const tPrivProfile = useTranslations("settings.privacy_profile_card"); @@ -347,7 +299,6 @@ export default function SettingsView({ // Inline Ollama bootstrapping help under the AI provider picker. Only // shown when provider === 'custom'; uses rich() for the inline , // , tags scattered through the long-form copy. - const tOllamaHelp = useTranslations("settings.ai.ollama_help"); // Policy Change Alerts + Policy Scrape Throttle cards. const tPolicyAlerts = useTranslations("settings.policy_alerts"); const tPolicyThrottle = useTranslations("settings.policy_throttle"); @@ -356,7 +307,6 @@ export default function SettingsView({ // showToast() call sites through next-intl. const tToast = useTranslations("settings.toasts"); const tAria = useTranslations("settings.aria"); - const tPh = useTranslations("settings.placeholders"); // Per-row notification-preference labels + descriptions + example // hints. The seven preference keys map onto snake_case translation // keys via a regex inside the loop below. @@ -420,15 +370,6 @@ export default function SettingsView({ // value persists via app_settings.date_format_preference). const settingsDateFormatPrefOn = useFlag("flag.settings.date_format.user_preference") === "on"; - // Wave I: AI Settings sub-flags. These nest inside the AI Policy - // Summaries card (already gated by `flag.settings.ai.enabled` above) - // so flipping the parent off hides them all; flipping a child off - // surgically removes that one input while leaving the rest of the - // card visible. - const settingsAiProviderSelectorOn = - useFlag("flag.settings.ai.provider_selector") === "on"; - const settingsAiSummarizeOnImportOn = - useFlag("flag.settings.ai.summarize_on_import") === "on"; // Focus card on Settings — driven separately from the per-page focus // surface so admins can hide the picker without disabling the focus // system itself. @@ -482,31 +423,6 @@ export default function SettingsView({ const [languageSuggestion, setLanguageSuggestion] = useState< "zh" | "en" | null >(null); - const [storedAi, setStoredAi] = useState(null); - const [aiProvider, setAiProvider] = useState("disabled"); - const [aiApiKey, setAiApiKey] = useState(""); - const [aiBaseUrl, setAiBaseUrl] = useState(""); - const [aiModel, setAiModel] = useState(""); - // Focus plumbing for the "Custom model ID" field. We don't want autoFocus - // there, because if the user has a custom model saved the input renders on - // page load and the browser scrolls it into view — which breaks deep-links - // into other sections of the Settings page (e.g. #sync-schedule from the - // Task Center). Instead we only focus when the user *actively* picks - // "Custom model…" from the model dropdown. - const customModelInputRef = useRef(null); - const focusCustomModelOnNextRender = useRef(false); - // UI-only toggle for the custom provider's API key input. Local LLM - // endpoints (Ollama, llama.cpp) do not accept a key, so we hide the field - // by default and only reveal it when the user checks "My endpoint - // requires an API key". Hydrated from whether a key is currently stored. - const [customApiKeyEnabled, setCustomApiKeyEnabled] = useState(false); - const [summarizeOnImport, setSummarizeOnImport] = useState(false); - const [debugLogging, setDebugLogging] = useState(false); - // Per-phase AI timeouts. Strings so we can hold "" (= use default) mid-edit. - const [aiTimeoutDirectMs, setAiTimeoutDirectMs] = useState(""); - const [aiTimeoutChunkMs, setAiTimeoutChunkMs] = useState(""); - const [aiTimeoutMergeMs, setAiTimeoutMergeMs] = useState(""); - // Advanced accordion in Developer Options is collapsed by default so the // timeouts inputs stay out of the way. We auto-open it when the page is // opened with hash #ai-timeouts — that's what the bell notification deep // link uses when an AI call aborts mid-summary. @@ -723,13 +639,6 @@ export default function SettingsView({ // RESTORE and commits. Phase state lives in `restoreStage`; the parsed // payload is stashed in `pendingRestore` so we don't re-read the file. type BackupRestoreStage = "idle" | "previewing" | "confirm" | "applying"; - interface BackupRestorePreview { - exportedAt: number | null; - perTable: { name: string; rows: number }[]; - totalRows: number; - version: number; - warnings: string[]; - } const [exportingBackup, setExportingBackup] = useState(false); const [restoreStage, setRestoreStage] = useState("idle"); const [restorePreview, setRestorePreview] = @@ -751,27 +660,6 @@ export default function SettingsView({ // Saving flag now lives on `backupSnapshotsAutoSave.saving`. const [creatingBackupSnapshot, setCreatingBackupSnapshot] = useState(false); - // AI connection test - type AiTestStatus = "idle" | "testing" | "ok" | "fail"; - const [aiTestStatus, setAiTestStatus] = useState("idle"); - const [aiTestMessage, setAiTestMessage] = useState(""); - const [aiTestLatency, setAiTestLatency] = useState(null); - - type AiSampleStatus = "idle" | "testing" | "ok" | "fail"; - const [aiSampleStatus, setAiSampleStatus] = useState("idle"); - const [aiSampleMessage, setAiSampleMessage] = useState(""); - const [aiSampleResult, setAiSampleResult] = - useState(null); - - // Discovered models for the "Own Model" (custom) provider — populated by - // polling the endpoint's /models (OpenAI-compatible) or /api/tags (Ollama). - type ModelsStatus = "idle" | "loading" | "ok" | "error"; - const [discoveredModels, setDiscoveredModels] = useState< - { value: string; label: string }[] - >([]); - const [modelsStatus, setModelsStatus] = useState("idle"); - const [modelsError, setModelsError] = useState(""); - const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(""), TOAST_HOLD_MS); @@ -1026,21 +914,7 @@ export default function SettingsView({ timeoutMergeMs: String(data.ai_timeout_merge_ms ?? ""), }; - setStoredAi(nextAi); - setAiProvider(nextAi.provider); - setAiApiKey(nextAi.apiKey); - setAiBaseUrl(nextAi.baseUrl); - setAiModel(nextAi.model); - // Reveal the API-key field for the custom provider only when the server - // already has a key stored (round-trips as "__SET__"). Otherwise the - // field stays hidden — which is the right default for local LLM - // endpoints that don't use a key at all. - setCustomApiKeyEnabled(nextAi.provider === "custom" && !!nextAi.apiKey); - setSummarizeOnImport(nextAi.summarizeOnImport); - setDebugLogging(nextAi.debugLogging); - setAiTimeoutDirectMs(nextAi.timeoutDirectMs); - setAiTimeoutChunkMs(nextAi.timeoutChunkMs); - setAiTimeoutMergeMs(nextAi.timeoutMergeMs); + hydrate(nextAi); // Hydrate the policy-diff alert window. Server default is "90"; keep // the input in sync even when the API returns a different stored value. @@ -1096,6 +970,34 @@ export default function SettingsView({ setSavedQueueShowProgressBar(nextQueueBar); }; + // AI Summaries — provider form, connection test, model discovery, + // sample run, per-phase timeouts and the debounced blob auto-save. + // Renamed back on destructure so the JSX below is untouched by this + // commit; the markup moves next. + const ai = useAiSettings({ + loadSettings, + tAiProvider, + tAiSample, + tDevAiTimeouts, + taskCenter, + }); + const { + hydrate, + aiProvider, + debugLogging, + setDebugLogging, + aiTimeoutDirectMs, + setAiTimeoutDirectMs, + aiTimeoutChunkMs, + setAiTimeoutChunkMs, + aiTimeoutMergeMs, + setAiTimeoutMergeMs, + aiTimeoutDirectAutoSave, + aiTimeoutChunkAutoSave, + aiTimeoutMergeAutoSave, + saveAiSettings, + } = ai; + /** * Fetch the user's archetype from the welcome splash. Lives behind its own * endpoint so we don't pollute /api/settings with UI preferences. @@ -1661,42 +1563,6 @@ export default function SettingsView({ * One hook per timeout so a tabbed-out direct field doesn't accidentally * resave a half-edited merge value. All three save on blur. */ - const aiTimeoutDirectAutoSave = useSettingsAutoSave({ - endpoint: "/api/settings", - buildBody: (value) => ({ ai_timeout_direct_ms: value }), - successMessage: (value) => - value === "" - ? tDevAiTimeouts("toast_direct_reset") - : tDevAiTimeouts("toast_direct_set", { value }), - taskLabel: (value) => - tDevAiTimeouts("task_label_direct", { - value: value || tDevAiTimeouts("task_label_default_value"), - }), - }); - const aiTimeoutChunkAutoSave = useSettingsAutoSave({ - endpoint: "/api/settings", - buildBody: (value) => ({ ai_timeout_chunk_ms: value }), - successMessage: (value) => - value === "" - ? tDevAiTimeouts("toast_chunk_reset") - : tDevAiTimeouts("toast_chunk_set", { value }), - taskLabel: (value) => - tDevAiTimeouts("task_label_chunk", { - value: value || tDevAiTimeouts("task_label_default_value"), - }), - }); - const aiTimeoutMergeAutoSave = useSettingsAutoSave({ - endpoint: "/api/settings", - buildBody: (value) => ({ ai_timeout_merge_ms: value }), - successMessage: (value) => - value === "" - ? tDevAiTimeouts("toast_merge_reset") - : tDevAiTimeouts("toast_merge_set", { value }), - taskLabel: (value) => - tDevAiTimeouts("task_label_merge", { - value: value || tDevAiTimeouts("task_label_default_value"), - }), - }); // The legacy `saveUserIntent` writer has been removed alongside the // duplicate Your-Focus picker. Focus changes now happen via @@ -2303,304 +2169,6 @@ export default function SettingsView({ }, }); - // Reset the test indicator whenever the user edits any of the inputs the - // test actually uses — provider, api key, base url. - useEffect(() => { - setAiTestStatus("idle"); - setAiTestMessage(""); - setAiTestLatency(null); - }, [aiProvider, aiApiKey, aiBaseUrl]); - - useEffect(() => { - setAiSampleStatus("idle"); - setAiSampleMessage(""); - setAiSampleResult(null); - }, [aiProvider, aiApiKey, aiBaseUrl, aiModel]); - - const testAiConnection = async () => { - setAiTestStatus("testing"); - setAiTestMessage(""); - setAiTestLatency(null); - try { - const res = await fetch("/api/ai/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider: aiProvider, - apiKey: aiApiKey, - baseUrl: aiProvider === "disabled" ? "" : aiBaseUrl, - }), - }); - const data = (await res.json()) as { - ok?: boolean; - message?: string; - latencyMs?: number; - }; - setAiTestStatus(data.ok ? "ok" : "fail"); - setAiTestMessage(typeof data.message === "string" ? data.message : ""); - setAiTestLatency( - typeof data.latencyMs === "number" ? data.latencyMs : null - ); - } catch (error) { - console.error("[settings] AI connection test failed:", error); - setAiTestStatus("fail"); - setAiTestMessage(error instanceof Error ? error.message : String(error)); - setAiTestLatency(null); - } - }; - - const canDiscoverModels = - aiProvider !== "disabled" && - !!aiBaseUrl.trim() && - (!providerRequiresApiKey(aiProvider) || !!aiApiKey.trim()); - - const canRunSamplePolicyTest = - aiProvider !== "disabled" && - !!aiBaseUrl.trim() && - !!aiModel.trim() && - (!providerRequiresApiKey(aiProvider) || !!aiApiKey.trim()); - - const runAiSampleSummaryTest = async () => { - if (!canRunSamplePolicyTest || aiSampleStatus === "testing") { - return; - } - - setAiSampleStatus("testing"); - setAiSampleMessage(""); - setAiSampleResult(null); - - const controller = new AbortController(); - const handle = taskCenter.startTask({ - title: tAiSample("task_title"), - subtitle: tAiSample("task_subtitle"), - kind: "sync", - href: "/dashboard/settings#ai-summaries", - onCancel: () => controller.abort(), - }); - - try { - const res = await fetch("/api/ai/policy-sample", { - method: "POST", - headers: { "Content-Type": "application/json" }, - signal: controller.signal, - body: JSON.stringify({ - provider: aiProvider, - apiKey: aiApiKey, - baseUrl: aiBaseUrl, - model: aiModel, - }), - }); - const data = (await res.json()) as - | AiSamplePolicyResult - | { ok: false; error?: string; durationMs?: number }; - - if (!res.ok || data.ok !== true) { - const message = - "error" in data && typeof data.error === "string" - ? data.error - : tAiSample("error_fallback"); - setAiSampleStatus("fail"); - setAiSampleMessage(message); - handle.complete("error", message); - return; - } - - setAiSampleStatus("ok"); - setAiSampleResult(data); - const message = tAiSample("success", { - model: data.model, - duration: fmtDuration(data.durationMs), - }); - setAiSampleMessage(message); - handle.complete("done", message); - } catch (error) { - const aborted = (error as Error)?.name === "AbortError"; - const message = aborted - ? tAiSample("cancelled") - : error instanceof Error - ? error.message - : String(error); - setAiSampleStatus("fail"); - setAiSampleMessage(message); - handle.complete("error", message); - } - }; - - // Fetch the list of models the configured provider actually exposes. Hosted - // providers use their official models endpoints; custom providers try - // OpenAI-compatible /models first, then Ollama /api/tags server-side. - const refreshDiscoveredModels = useCallback(async () => { - if (aiProvider === "disabled") { - setDiscoveredModels([]); - setModelsStatus("idle"); - setModelsError(""); - return; - } - if (!canDiscoverModels) { - setDiscoveredModels([]); - setModelsStatus("idle"); - setModelsError(""); - return; - } - - setModelsStatus("loading"); - setModelsError(""); - try { - const res = await fetch("/api/ai/models", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider: aiProvider, - apiKey: aiApiKey, - baseUrl: aiBaseUrl, - }), - }); - const data = (await res.json()) as { - ok?: boolean; - message?: string; - models?: { id?: string; label?: string }[]; - }; - if (data.ok && Array.isArray(data.models)) { - const cleaned = data.models - .map((m) => ({ - value: typeof m.id === "string" ? m.id : "", - label: - typeof m.label === "string" && m.label - ? m.label - : typeof m.id === "string" - ? m.id - : "", - })) - .filter((m) => m.value); - setDiscoveredModels(cleaned); - if (cleaned.length === 0) { - setModelsStatus("error"); - setModelsError("No models returned from the endpoint."); - } else { - setModelsStatus("ok"); - } - } else { - setDiscoveredModels([]); - setModelsStatus("error"); - setModelsError( - typeof data.message === "string" - ? data.message - : "Could not list models." - ); - } - } catch (err) { - setDiscoveredModels([]); - setModelsStatus("error"); - setModelsError( - err instanceof Error ? err.message : "Could not list models." - ); - } - }, [aiProvider, aiApiKey, aiBaseUrl, canDiscoverModels]); - - // Auto-fetch with a debounce so typing in the Base URL doesn't spam requests. - useEffect(() => { - if (aiProvider === "disabled" || !canDiscoverModels) { - setDiscoveredModels([]); - setModelsStatus("idle"); - setModelsError(""); - return; - } - const timer = setTimeout(() => { - void refreshDiscoveredModels(); - }, 600); - return () => clearTimeout(timer); - }, [aiProvider, canDiscoverModels, refreshDiscoveredModels]); - - /** - * Auto-save hook for the AI Summaries card. Holds the full provider - * config — provider, key, model, baseUrl, behaviour toggles. Saved - * as a single POST so server-side validation sees a coherent blob - * (e.g. switching from openai to custom requires baseUrl to flip - * to localhost; sending them separately would fail the route's - * cross-field checks). - * - * Per-phase timeouts are NOT included here — they auto-save through - * their own hooks (see `aiTimeoutDirectAutoSave` etc.) since they're - * independent and safer to write granularly. - */ - interface AiSettingsBlob { - apiKey: string; - baseUrl: string; - debugLogging: boolean; - model: string; - provider: AIProvider; - summarizeOnImport: boolean; - } - - const aiSettingsAutoSave = useSettingsAutoSave({ - endpoint: "/api/settings", - buildBody: (v) => ({ - ai_provider: v.provider, - ai_api_key: v.apiKey, - ai_base_url: v.provider === "disabled" ? "" : v.baseUrl, - ai_model: v.provider === "disabled" ? "" : v.model, - ai_summarize_on_import: v.summarizeOnImport, - ai_debug_logging: v.debugLogging, - }), - successMessage: (v) => - v.provider === "disabled" - ? tAiProvider("toast_disabled") - : tAiProvider("toast_saved", { provider: v.provider }), - taskLabel: (v) => - v.provider === "disabled" - ? tAiProvider("toast_disabled") - : tAiProvider("task_label", { provider: v.provider }), - onSaved: () => { - // loadSettings re-pulls the canonical state including the masked - // apiKey marker, so subsequent saves don't accidentally re-send - // the placeholder. - void loadSettings(); - }, - }); - - /** - * Compose the current AI settings blob from React state with optional - * field overrides, then fire a save. Overrides exist because state - * setters are async — after `setAiProvider(next)` the local - * `aiProvider` is still the previous value within the same handler, - * so per-event handlers must thread the new value through explicitly. - * - * Skip rule: when the provider isn't disabled but a required field - * is missing (baseUrl, model, or apiKey for hosted providers), the - * save would 400 anyway. Silently skipping keeps the bottom toast - * quiet while the user is mid-edit. - */ - const saveAiSettings = useCallback( - (overrides: Partial = {}) => { - const blob: AiSettingsBlob = { - provider: overrides.provider ?? aiProvider, - apiKey: overrides.apiKey ?? aiApiKey, - baseUrl: overrides.baseUrl ?? aiBaseUrl, - model: overrides.model ?? aiModel, - summarizeOnImport: overrides.summarizeOnImport ?? summarizeOnImport, - debugLogging: overrides.debugLogging ?? debugLogging, - }; - if ( - blob.provider !== "disabled" && - (!(blob.baseUrl.trim() && blob.model.trim()) || - (providerRequiresApiKey(blob.provider) && !blob.apiKey.trim())) - ) { - // Required field missing — wait for the user to fill it in. - return; - } - void aiSettingsAutoSave.save(blob); - }, - [ - aiProvider, - aiApiKey, - aiBaseUrl, - aiModel, - summarizeOnImport, - debugLogging, - aiSettingsAutoSave, - ] - ); - const triggerSync = async () => { setSyncing(true); // The sync runs server-side regardless of page navigation, but we still @@ -3329,111 +2897,6 @@ export default function SettingsView({ } }; - const providerOptions = getAiModelOptions(aiProvider); - - // For the custom provider the model list is driven entirely by what the - // endpoint reports — we used to also render a curated "Suggestions" group - // (llama3.2, qwen2.5, etc.) but that confused users whose endpoint didn't - // actually have those pulled. If the endpoint returns nothing, the user - // can pick "Custom model…" and type whatever ID they want. - const mergedModelValues = useMemo(() => { - const set = new Set(); - if (aiProvider === "custom") { - for (const m of discoveredModels) { - set.add(m.value); - } - } else { - for (const m of discoveredModels) { - set.add(m.value); - } - for (const m of providerOptions) { - set.add(m.value); - } - } - return set; - }, [aiProvider, discoveredModels, providerOptions]); - - const selectedModelPreset = mergedModelValues.has(aiModel) - ? aiModel - : "__custom__"; - - // When the user picks "Custom model…" from the dropdown the input appears on - // the next render. We focus it *only* in that case — never on initial load, - // so deep-links like #sync-schedule aren't hijacked by the browser scrolling - // this field into view. - useEffect(() => { - if (selectedModelPreset !== "__custom__") { - return; - } - if (!focusCustomModelOnNextRender.current) { - return; - } - focusCustomModelOnNextRender.current = false; - customModelInputRef.current?.focus(); - }, [selectedModelPreset]); - const aiConfigChanged = storedAi - ? storedAi.provider !== aiProvider || - storedAi.apiKey !== aiApiKey || - storedAi.baseUrl !== (aiProvider === "disabled" ? "" : aiBaseUrl) || - storedAi.model !== (aiProvider === "disabled" ? "" : aiModel) || - storedAi.summarizeOnImport !== summarizeOnImport || - storedAi.debugLogging !== debugLogging || - storedAi.timeoutDirectMs !== aiTimeoutDirectMs.trim() || - storedAi.timeoutChunkMs !== aiTimeoutChunkMs.trim() || - storedAi.timeoutMergeMs !== aiTimeoutMergeMs.trim() - : false; - - const onProviderChange = (nextProvider: AIProvider) => { - setAiProvider(nextProvider); - - // When leaving the custom provider, collapse the API-key toggle back to - // a clean slate so re-entering custom starts with the field hidden - // again. Switching to a hosted provider doesn't need the toggle at all - // (the input is always shown because a key is required). - if (nextProvider !== "custom") { - setCustomApiKeyEnabled(false); - } - - if (nextProvider === "disabled") { - // Disabling is a single-field intent — fire the save immediately. - saveAiSettings({ provider: "disabled" }); - return; - } - - const previousDefaultModel = - aiProvider === "disabled" ? "" : resolveDefaultModel(aiProvider); - const previousDefaultBaseUrl = - aiProvider === "disabled" ? "" : resolveDefaultBaseUrl(aiProvider); - - // Compute the next model / baseUrl synchronously so we can thread - // them into the save payload below. setState is async, so reading - // `aiModel` after `setAiModel(...)` would still see the previous - // value within this handler. - let nextModel = aiModel; - if (!aiModel || aiModel === previousDefaultModel) { - nextModel = resolveDefaultModel(nextProvider); - setAiModel(nextModel); - } - - let nextBaseUrl = aiBaseUrl; - if (!aiBaseUrl || aiBaseUrl === previousDefaultBaseUrl) { - nextBaseUrl = resolveDefaultBaseUrl(nextProvider); - setAiBaseUrl(nextBaseUrl); - } - - // Save the full new triple at once so the route's cross-field - // validation sees a coherent (provider, baseUrl, model) tuple. - saveAiSettings({ - provider: nextProvider, - baseUrl: nextBaseUrl, - model: nextModel, - }); - }; - - // The legacy `aiSaveDisabled` flag is gone — there's no Save button - // anymore. The same gating now lives inline in `saveAiSettings`, - // which silently skips POSTs when required fields are missing. - // Focus management for every modal dialog rendered below: trap Tab, move // focus into the card on open, restore it on close, and close on Escape. // One hook per dialog (see useModalFocus). Each `onClose` mirrors the @@ -4063,799 +3526,7 @@ export default function SettingsView({ {tSettings("sidebar.group_policies_ai")} - {settingsAiEnabledOn && ( -
-
-
-

- {tSections("ai_summaries")} -

-

- {tSub("ai_summaries")} -

-
- - - {aiProvider === "disabled" - ? tAi("status_off") - : tAi("status_using", { - provider: (() => { - const opt = AI_PROVIDER_OPTIONS.find( - (o) => o.value === aiProvider - ); - if (!opt) { - return aiProvider; - } - return opt.labelKey - ? tAiOptions(opt.labelKey) - : opt.label; - })(), - })} - -
- - {/* 1. Provider */} - {settingsAiProviderSelectorOn && ( -
-
-

- {tAiProvider("title")} -

-

- {tAiProvider("desc")} -

-
- - - - {/* Ollama bootstrapping help, stashed behind a disclosure so it - doesn't clutter the section for users who already know what - they're doing. Only shown when the user has picked (or is on - their way to picking) the "Own Model" backend — hosted providers - like OpenAI and Anthropic don't need this guidance. */} - {aiProvider === "custom" && ( -
- - - {tOllamaHelp("summary_title")} - - - {tOllamaHelp("summary_copy")} - - -
-

- {tOllamaHelp.rich("intro", { - own: (chunks) => {chunks}, - })} -

-
-                              {`${tOllamaHelp("install_comment_1")}
-${tOllamaHelp("install_comment_2")}
-ollama pull gemma3n:e4b
-
-${tOllamaHelp("install_comment_3")}
-ollama serve`}
-                            
-

- - {tOllamaHelp("long_policies_lead")} - - {tOllamaHelp("long_policies_body")} -

-
-                              {"OLLAMA_CONTEXT_LENGTH=131072 ollama serve"}
-                            
- {/* System-spec guidance for the context windows we suggest. KV - cache grows roughly linearly with context, so memory is the - first thing to hit on consumer hardware. Numbers below are - safe ballparks for a ~7–8 B quantised model (Q4_K_M). */} -
- - {tOllamaHelp("memory_guide_lead")} - - {tOllamaHelp("memory_guide_body")} -
    -
  • - {tOllamaHelp.rich("memory_32k", { - c: (chunks) => {chunks}, - s: (chunks) => {chunks}, - })} -
  • -
  • - {tOllamaHelp.rich("memory_128k", { - c: (chunks) => {chunks}, - s: (chunks) => {chunks}, - })} -
  • -
- - {tOllamaHelp("memory_followup")} - -
-

- {tOllamaHelp.rich("verify", { - em: (chunks) => {chunks}, - })} -

-
-
- )} -
- )} - - {/* 2. Connection */} - {aiProvider !== "disabled" && ( -
-
-

- {tAiConn("title")} -

-

- {providerSupportsApiKey(aiProvider) - ? tAiConn("desc_with_key") - : tAiConn("desc_base")} - . -

-
- - - - {/* API-key disclosure, split by provider kind: - • Hosted providers (OpenAI, Anthropic) always show the input — a - key is required, so hiding it behind a toggle is only friction. - • Custom provider hides the input behind a checkbox because - Ollama / llama.cpp / LM Studio don't use a key. Toggling the - checkbox off clears whatever key was in state so the next save - persists an empty key. */} - {aiProvider !== "custom" && - providerSupportsApiKey(aiProvider) && ( - - )} - - {aiProvider === "custom" && ( -
- - - {customApiKeyEnabled && ( - - )} -
- )} - -
- - {(aiTestMessage || aiTestLatency !== null) && ( -
- {aiTestStatus === "ok" - ? "✓ " - : aiTestStatus === "fail" - ? "⚠ " - : ""} - {aiTestMessage || - (aiTestStatus === "ok" - ? tAiConn("test_reachable_fallback") - : "")} - {aiTestLatency !== null && ( - - {tAiConn("test_latency", { ms: aiTestLatency })} - - )} -
- )} -
-
- )} - - {/* 3. Model */} - {aiProvider !== "disabled" && ( -
-
-

- {tAiModel("title")} -

-

- {aiProvider === "custom" - ? tAiModel("desc_custom") - : tAiModel("desc_default")} -

-
- - - - {selectedModelPreset === "__custom__" && ( - - )} -
- )} - - {/* 4. Sample policy test */} - {aiProvider !== "disabled" && ( -
-
-

- {tAiSample("title")} -

-

- {tAiSample("desc")} -

-
- -
-
- - {tAiSample("policy_label")} - -

{tAiSample("policy_body")}

-
-
- - {tAiSample("judging_label")} - -

{tAiSample("judging_body")}

-
-
- - {tAiSample("look_for_label")} - -

{tAiSample("look_for_body")}

-
-
- -
- - {(aiSampleMessage || !canRunSamplePolicyTest) && ( -
- {aiSampleStatus === "ok" - ? "✓ " - : aiSampleStatus === "fail" - ? "⚠ " - : ""} - {aiSampleMessage || tAiSample("disabled_help")} -
- )} -
- - {aiSampleResult ? ( -
-
-
-
- {tAiSample("result_kicker")} -
-

- {aiSampleResult.sample.appName} -

-

- {aiSampleResult.sample.scenario} -

-
-
- {tAiSample("meta", { - model: aiSampleResult.model, - duration: fmtDuration( - aiSampleResult.durationMs - ), - words: aiSampleResult.sample.wordCount, - })} -
-
- -
- {tAiSample("review_note_label")} -
    - {aiSampleResult.sample.reviewChecklist.map( - (item, index) => ( -
  • {item}
  • - ) - )} -
-
- -
- {tAiSample("overview_title")} -
-

- {aiSampleResult.summary.overview} -

- -
-
-
{tAiSample("highlights_title")}
-
    - {aiSampleResult.summary.highlights.map( - (highlight, index) => ( -
  • - {highlight} -
  • - ) - )} -
-
-
-
{tAiSample("expected_title")}
-
    - {aiSampleResult.sample.expectedSignals.map( - (signal, index) => ( -
  • {signal}
  • - ) - )} -
-
-
- -
- {tAiSample("lenses_title")} -
-

- {tAiSample("lenses_help")} -

-
- {aiSampleResult.summary.lenses.map((entry) => ( -
-
- - {tLens(entry.key as PolicyLensKey)} - - - {tRating(entry.rating as PolicyRating)} - -
-

{entry.summary}

-
- ))} -
- -
- {tAiSample("policy_summary")} -
{aiSampleResult.sample.policyText}
-
-
- ) : ( -

- {tAiSample("empty_help")} -

- )} -
- )} - - {/* 5. Behavior */} -
-
-

- {aiProvider === "disabled" - ? tAiBehavior("title_disabled") - : tAiBehavior("title_full")} -

-

- {tAiBehavior("desc")} -

-
- - {settingsAiSummarizeOnImportOn && ( - - )} - - {/* AI request timeouts used to live here, but they're only useful - when you've hit a timeout and need to tune the merge budget. - They've been moved under Developer Options → "Advanced" - accordion below. The bell notification still deep-links via - #ai-timeouts and the accordion auto-opens when it sees that - hash on mount. */} -
- - {/* Save button removed — every field in this card auto-saves - via `saveAiSettings({ ... })` from its own onChange / onBlur. - The bottom-center toast is the success / failure indicator; - we keep the "saved" / "unsaved" pill for users who scroll - back through the form and want to confirm their state at a - glance before the toast has had a chance to render. */} -
-
- {storedAi && !aiConfigChanged ? ( - - {tAiFooter("saved")} - - ) : aiConfigChanged ? ( - - {tAiFooter("unsaved")} - - ) : null} - {aiSettingsAutoSave.saving && ( - - {tAiFooter("saving")} - - )} -
-
-
- )} + {settingsAiEnabledOn && } {/* Bulk "Privacy Policies" operations. Distinct from the App Store Sync @@ -5173,463 +3844,57 @@ ollama serve`} {toast} - {(restoreStage === "confirm" || restoreStage === "applying") && - restorePreview && ( -
{ - if (restoreStage !== "applying") { - resetRestoreFlow(); - } - }} - > -
event.stopPropagation()} - ref={restoreModalRef} - role="dialog" - tabIndex={-1} - > -
{tModalRestore("badge")}
-

- {tModalRestore("title")} -

-

- {pendingRestoreFilename ? ( - <> - {pendingRestoreFilename} - {restorePreview.exportedAt - ? tModalRestore("exported_suffix", { - date: fmtShortDate( - restorePreview.exportedAt, - dateMode - ), - }) - : null}{" "} - {tModalRestore("version_suffix", { - version: restorePreview.version, - })}{" "} - {tModalRestore("rows", { count: restorePreview.totalRows })} - - ) : ( - tModalRestore("no_filename", { - count: restorePreview.totalRows, - tables: restorePreview.perTable.length, - }) - )} -

- -
- {restorePreview.perTable - .filter((row) => row.rows > 0) - .map((row) => ( -
- {row.name} - - {row.rows.toLocaleString()} - -
- ))} -
- - {restorePreview.warnings.length > 0 && ( -
    - {restorePreview.warnings.map((warning, index) => ( -
  • ⚠ {warning}
  • - ))} -
- )} - -
- {tModalRestore("warning_lead")} - {tModalRestore("warning_body")} -
- -
-
- {tBackupCard("download_current_before_restore")} -
- -
- - - { - setRestoreConfirmText(event.target.value); - if (restoreError) { - setRestoreError(""); - } - }} - placeholder={tPh("restore_confirm")} - spellCheck={false} - type="text" - value={restoreConfirmText} - /> - - {restoreError && ( -

- {restoreError} -

- )} - -
- - -
-
-
- )} - - {deleteTarget && ( -
{ - if (!deleting) { - setDeleteTarget(null); - } - }} - > -
event.stopPropagation()} - ref={deleteImportModalRef} - role="dialog" - tabIndex={-1} - > -
{tModalDelete("badge")}
-

- {tModalDelete("title")} -

-

- {tModalDelete("meta", { - date: fmtShortDate(deleteTarget.importRow.createdAt, dateMode), - total: deleteTarget.importRow.total, - imported: deleteTarget.importRow.imported, - })} -

- -
- - - -
- -
- - -
-
-
- )} - - {waybackRemoveOpen && ( -
-
event.stopPropagation()} - ref={waybackRemoveModalRef} - role="dialog" - tabIndex={-1} - > -
{tWaybackRemove("badge")}
-

- {tWaybackRemove("title")} -

-

- {tWaybackRemove("body")} -

-
- - -
-
-
- )} - - {/* - Confirm modal for the inline "Remove from Apps" button on an - import-history row. Replaces the previous `window.confirm` so - the destructive UX matches the wayback-remove + reset-app - dialogs above. - */} - {pendingItemRemoval && - (() => { - const { item } = pendingItemRemoval; - const label = item.appName || item.editedQuery || item.query; - const closing = removingItemId !== null; - return ( -
{ - if (!closing) { - setPendingItemRemoval(null); - } - }} - > -
event.stopPropagation()} - ref={removeItemModalRef} - role="dialog" - tabIndex={-1} - > -
{tModalRemoveApp("badge")}
-

- {tModalRemoveApp("title", { name: label })} -

-

- {tModalRemoveApp("body")} -

-
- - -
-
-
- ); - })()} - - {resetStep > 0 && ( -
-
event.stopPropagation()} - ref={resetModalRef} - role="dialog" - tabIndex={-1} - > -
{tResetCard("modal_badge")}
-

- {resetStep === 1 - ? tResetCard("modal_title_step_1") - : tResetCard("modal_title_step_2")} -

-

- {resetStep === 1 - ? tResetCard("modal_body_step_1") - : tResetCard("modal_body_step_2")} -

- {resetStep === 2 && ( -
-
- {tBackupCard("download_before_reset")} -
- -
- )} -
- - - {resetStep === 1 ? ( - - ) : ( - - )} -
-
-
- )} + + + + + + + + + ); } diff --git a/app/components/settings/AiSummariesSection.tsx b/app/components/settings/AiSummariesSection.tsx new file mode 100644 index 0000000..558d974 --- /dev/null +++ b/app/components/settings/AiSummariesSection.tsx @@ -0,0 +1,815 @@ +"use client"; + +/** + * AI Policy Summaries — pick a provider, point it at an endpoint, test the + * connection, and optionally dry-run a real policy through it before + * trusting it with the whole library. + * + * The card is deliberately opt-in: the provider starts at `disabled`, and a + * stored `disabled` is honoured on load. Nothing here talks to a model + * until a user configures one. + * + * All of its state lives in `useAiSettings`, which SettingsView calls and + * passes down whole as `ai`. The hook cannot be called here — Developer + * Options needs the same blob for its debug-logging toggle, and the + * timeouts panel reads `aiProvider` — and two call sites would mean two + * independent copies of the state. One object prop beats thirty-five. + * + * The two sub-flags are resolved here rather than passed down: they decide + * what is *inside* this card, while SettingsView keeps the section-level + * `flag.settings.ai.enabled` gate. See ./README.md. + * + * Anchor id `ai-summaries` is a cross-page contract — /privacy-policy + * deep-links to it, and SettingsView pulses it on that hash. + */ + +import { useTranslations } from "next-intl"; +import { + AI_PROVIDER_OPTIONS, + type AIProvider, + providerRequiresApiKey, + providerSupportsApiKey, + resolveDefaultBaseUrl, + resolveDefaultModel, +} from "@/lib/ai-config"; +import { useFlag } from "@/lib/feature-flags-hooks"; +import type { PolicyLensKey, PolicyRating } from "@/lib/policy-summary-meta"; +import type { useAiSettings } from "@/lib/use-ai-settings"; +import { fmtDuration } from "./format"; + +export default function AiSummariesSection({ + ai, +}: { + /** The whole `useAiSettings` return value — see the note above on why + * this is one prop rather than thirty-five. */ + ai: ReturnType; +}) { + const settingsAiProviderSelectorOn = + useFlag("flag.settings.ai.provider_selector") === "on"; + const settingsAiSummarizeOnImportOn = + useFlag("flag.settings.ai.summarize_on_import") === "on"; + + const tSections = useTranslations("settings.sections"); + const tSub = useTranslations("settings.subtitles"); + const tAi = useTranslations("settings.ai"); + const tAiProvider = useTranslations("settings.ai.provider"); + const tAiConn = useTranslations("settings.ai.connection"); + const tAiModel = useTranslations("settings.ai.model"); + const tAiSample = useTranslations("settings.ai.sample"); + const tAiBehavior = useTranslations("settings.ai.behavior"); + const tAiFooter = useTranslations("settings.ai.footer"); + const tOllamaHelp = useTranslations("settings.ai.ollama_help"); + const tAiOptions = useTranslations("ai_options"); + const tLens = useTranslations("policy_lens"); + const tRating = useTranslations("policy_rating"); + const tPh = useTranslations("settings.placeholders"); + + const { + aiApiKey, + aiBaseUrl, + aiConfigChanged, + aiModel, + aiProvider, + aiSampleMessage, + aiSampleResult, + aiSampleStatus, + aiSettingsAutoSave, + aiTestLatency, + aiTestMessage, + aiTestStatus, + canDiscoverModels, + canRunSamplePolicyTest, + customApiKeyEnabled, + customModelInputRef, + discoveredModels, + focusCustomModelOnNextRender, + mergedModelValues, + modelsError, + modelsStatus, + onProviderChange, + providerOptions, + refreshDiscoveredModels, + runAiSampleSummaryTest, + saveAiSettings, + selectedModelPreset, + setAiApiKey, + setAiBaseUrl, + setAiModel, + setCustomApiKeyEnabled, + setSummarizeOnImport, + storedAi, + summarizeOnImport, + testAiConnection, + } = ai; + + return ( +
+
+
+

+ {tSections("ai_summaries")} +

+

+ {tSub("ai_summaries")} +

+
+ + + {aiProvider === "disabled" + ? tAi("status_off") + : tAi("status_using", { + provider: (() => { + const opt = AI_PROVIDER_OPTIONS.find( + (o) => o.value === aiProvider + ); + if (!opt) { + return aiProvider; + } + return opt.labelKey ? tAiOptions(opt.labelKey) : opt.label; + })(), + })} + +
+ + {/* 1. Provider */} + {settingsAiProviderSelectorOn && ( +
+
+

+ {tAiProvider("title")} +

+

{tAiProvider("desc")}

+
+ + + + {/* Ollama bootstrapping help, stashed behind a disclosure so it + doesn't clutter the section for users who already know what + they're doing. Only shown when the user has picked (or is on + their way to picking) the "Own Model" backend — hosted providers + like OpenAI and Anthropic don't need this guidance. */} + {aiProvider === "custom" && ( +
+ + + {tOllamaHelp("summary_title")} + + + {tOllamaHelp("summary_copy")} + + +
+

+ {tOllamaHelp.rich("intro", { + own: (chunks) => {chunks}, + })} +

+
+                  {`${tOllamaHelp("install_comment_1")}
+${tOllamaHelp("install_comment_2")}
+ollama pull gemma3n:e4b
+
+${tOllamaHelp("install_comment_3")}
+ollama serve`}
+                
+

+ {tOllamaHelp("long_policies_lead")} + {tOllamaHelp("long_policies_body")} +

+
+                  {"OLLAMA_CONTEXT_LENGTH=131072 ollama serve"}
+                
+ {/* System-spec guidance for the context windows we suggest. KV + cache grows roughly linearly with context, so memory is the + first thing to hit on consumer hardware. Numbers below are + safe ballparks for a ~7–8 B quantised model (Q4_K_M). */} +
+ {tOllamaHelp("memory_guide_lead")} + {tOllamaHelp("memory_guide_body")} +
    +
  • + {tOllamaHelp.rich("memory_32k", { + c: (chunks) => {chunks}, + s: (chunks) => {chunks}, + })} +
  • +
  • + {tOllamaHelp.rich("memory_128k", { + c: (chunks) => {chunks}, + s: (chunks) => {chunks}, + })} +
  • +
+ + {tOllamaHelp("memory_followup")} + +
+

+ {tOllamaHelp.rich("verify", { + em: (chunks) => {chunks}, + })} +

+
+
+ )} +
+ )} + + {/* 2. Connection */} + {aiProvider !== "disabled" && ( +
+
+

{tAiConn("title")}

+

+ {providerSupportsApiKey(aiProvider) + ? tAiConn("desc_with_key") + : tAiConn("desc_base")} + . +

+
+ + + + {/* API-key disclosure, split by provider kind: + • Hosted providers (OpenAI, Anthropic) always show the input — a + key is required, so hiding it behind a toggle is only friction. + • Custom provider hides the input behind a checkbox because + Ollama / llama.cpp / LM Studio don't use a key. Toggling the + checkbox off clears whatever key was in state so the next save + persists an empty key. */} + {aiProvider !== "custom" && providerSupportsApiKey(aiProvider) && ( + + )} + + {aiProvider === "custom" && ( +
+ + + {customApiKeyEnabled && ( + + )} +
+ )} + +
+ + {(aiTestMessage || aiTestLatency !== null) && ( +
+ {aiTestStatus === "ok" + ? "✓ " + : aiTestStatus === "fail" + ? "⚠ " + : ""} + {aiTestMessage || + (aiTestStatus === "ok" + ? tAiConn("test_reachable_fallback") + : "")} + {aiTestLatency !== null && ( + + {tAiConn("test_latency", { ms: aiTestLatency })} + + )} +
+ )} +
+
+ )} + + {/* 3. Model */} + {aiProvider !== "disabled" && ( +
+
+

{tAiModel("title")}

+

+ {aiProvider === "custom" + ? tAiModel("desc_custom") + : tAiModel("desc_default")} +

+
+ + + + {selectedModelPreset === "__custom__" && ( + + )} +
+ )} + + {/* 4. Sample policy test */} + {aiProvider !== "disabled" && ( +
+
+

{tAiSample("title")}

+

{tAiSample("desc")}

+
+ +
+
+ + {tAiSample("policy_label")} + +

{tAiSample("policy_body")}

+
+
+ + {tAiSample("judging_label")} + +

{tAiSample("judging_body")}

+
+
+ + {tAiSample("look_for_label")} + +

{tAiSample("look_for_body")}

+
+
+ +
+ + {(aiSampleMessage || !canRunSamplePolicyTest) && ( +
+ {aiSampleStatus === "ok" + ? "✓ " + : aiSampleStatus === "fail" + ? "⚠ " + : ""} + {aiSampleMessage || tAiSample("disabled_help")} +
+ )} +
+ + {aiSampleResult ? ( +
+
+
+
+ {tAiSample("result_kicker")} +
+

+ {aiSampleResult.sample.appName} +

+

+ {aiSampleResult.sample.scenario} +

+
+
+ {tAiSample("meta", { + model: aiSampleResult.model, + duration: fmtDuration(aiSampleResult.durationMs), + words: aiSampleResult.sample.wordCount, + })} +
+
+ +
+ {tAiSample("review_note_label")} +
    + {aiSampleResult.sample.reviewChecklist.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ +
+ {tAiSample("overview_title")} +
+

+ {aiSampleResult.summary.overview} +

+ +
+
+
{tAiSample("highlights_title")}
+
    + {aiSampleResult.summary.highlights.map( + (highlight, index) => ( +
  • {highlight}
  • + ) + )} +
+
+
+
{tAiSample("expected_title")}
+
    + {aiSampleResult.sample.expectedSignals.map( + (signal, index) => ( +
  • {signal}
  • + ) + )} +
+
+
+ +
+ {tAiSample("lenses_title")} +
+

+ {tAiSample("lenses_help")} +

+
+ {aiSampleResult.summary.lenses.map((entry) => ( +
+
+ {tLens(entry.key as PolicyLensKey)} + + {tRating(entry.rating as PolicyRating)} + +
+

{entry.summary}

+
+ ))} +
+ +
+ {tAiSample("policy_summary")} +
{aiSampleResult.sample.policyText}
+
+
+ ) : ( +

+ {tAiSample("empty_help")} +

+ )} +
+ )} + + {/* 5. Behavior */} +
+
+

+ {aiProvider === "disabled" + ? tAiBehavior("title_disabled") + : tAiBehavior("title_full")} +

+

{tAiBehavior("desc")}

+
+ + {settingsAiSummarizeOnImportOn && ( + + )} + + {/* AI request timeouts used to live here, but they're only useful + when you've hit a timeout and need to tune the merge budget. + They've been moved under Developer Options → "Advanced" + accordion below. The bell notification still deep-links via + #ai-timeouts and the accordion auto-opens when it sees that + hash on mount. */} +
+ + {/* Save button removed — every field in this card auto-saves + via `saveAiSettings({ ... })` from its own onChange / onBlur. + The bottom-center toast is the success / failure indicator; + we keep the "saved" / "unsaved" pill for users who scroll + back through the form and want to confirm their state at a + glance before the toast has had a chance to render. */} +
+
+ {storedAi && !aiConfigChanged ? ( + {tAiFooter("saved")} + ) : aiConfigChanged ? ( + + {tAiFooter("unsaved")} + + ) : null} + {aiSettingsAutoSave.saving && ( + + {tAiFooter("saving")} + + )} +
+
+
+ ); +} diff --git a/app/components/settings/DeleteImportModal.tsx b/app/components/settings/DeleteImportModal.tsx new file mode 100644 index 0000000..e0ef742 --- /dev/null +++ b/app/components/settings/DeleteImportModal.tsx @@ -0,0 +1,149 @@ +"use client"; + +/** + * Confirm dialog for deleting an import from history. + * + * Two modes, chosen in the dialog itself: drop just the history row, or + * also remove the apps it added. The second is the destructive one, which + * is why the choice lives here rather than being implied by which button + * was clicked. + */ + +import { useTranslations } from "next-intl"; +import type { DateFormatMode } from "@/lib/date-format"; +import type { DeleteTarget } from "@/lib/use-import-history"; +import type { useModalFocus } from "@/lib/use-modal-focus"; +import { fmtShortDate } from "./format"; + +export default function DeleteImportModal({ + deleteTarget, + setDeleteTarget, + deleting, + confirmDeleteImport, + deleteImportModalRef, + dateMode, +}: { + /** null when closed; carries the import row and the chosen mode. */ + deleteTarget: DeleteTarget | null; + setDeleteTarget: (next: DeleteTarget | null) => void; + deleting: boolean; + confirmDeleteImport: () => void; + deleteImportModalRef: ReturnType>; + dateMode: DateFormatMode; +}) { + const tModalDelete = useTranslations("settings.modals.delete_import"); + + return ( + <> + {deleteTarget && ( +
{ + if (!deleting) { + setDeleteTarget(null); + } + }} + > +
event.stopPropagation()} + ref={deleteImportModalRef} + role="dialog" + tabIndex={-1} + > +
{tModalDelete("badge")}
+

+ {tModalDelete("title")} +

+

+ {tModalDelete("meta", { + date: fmtShortDate(deleteTarget.importRow.createdAt, dateMode), + total: deleteTarget.importRow.total, + imported: deleteTarget.importRow.imported, + })} +

+ +
+ + + +
+ +
+ + +
+
+
+ )} + + ); +} diff --git a/app/components/settings/RemoveItemModal.tsx b/app/components/settings/RemoveItemModal.tsx new file mode 100644 index 0000000..bc36baa --- /dev/null +++ b/app/components/settings/RemoveItemModal.tsx @@ -0,0 +1,105 @@ +"use client"; + +/** + * Confirm dialog for the inline "Remove from Apps" button on an + * import-history row. + * + * Replaced a bare `window.confirm` so the destructive UX matches the + * wayback-remove and reset-app dialogs. The body is an IIFE because it + * derives a display label from the staged item before rendering. + */ + +import { useTranslations } from "next-intl"; +import type { PendingItemRemoval } from "@/lib/use-import-history"; +import type { useModalFocus } from "@/lib/use-modal-focus"; + +export default function RemoveItemModal({ + pendingItemRemoval, + setPendingItemRemoval, + removingItemId, + confirmRemoveItemFromDashboard, + removeItemModalRef, +}: { + /** null when closed; stages the import row, item and appId so the copy + * can name what is about to be removed. */ + pendingItemRemoval: PendingItemRemoval | null; + setPendingItemRemoval: (next: PendingItemRemoval | null) => void; + /** Non-null while a removal is in flight — doubles as the busy flag. */ + removingItemId: string | null; + confirmRemoveItemFromDashboard: () => void; + removeItemModalRef: ReturnType>; +}) { + const tModalRemoveApp = useTranslations("settings.modals.remove_app"); + + return ( + <> + {/* + Confirm modal for the inline "Remove from Apps" button on an + import-history row. Replaces the previous `window.confirm` so + the destructive UX matches the wayback-remove + reset-app + dialogs above. + */} + {pendingItemRemoval && + (() => { + const { item } = pendingItemRemoval; + const label = item.appName || item.editedQuery || item.query; + const closing = removingItemId !== null; + return ( +
{ + if (!closing) { + setPendingItemRemoval(null); + } + }} + > +
event.stopPropagation()} + ref={removeItemModalRef} + role="dialog" + tabIndex={-1} + > +
{tModalRemoveApp("badge")}
+

+ {tModalRemoveApp("title", { name: label })} +

+

+ {tModalRemoveApp("body")} +

+
+ + +
+
+
+ ); + })()} + + ); +} diff --git a/app/components/settings/ResetAppModal.tsx b/app/components/settings/ResetAppModal.tsx new file mode 100644 index 0000000..4364f55 --- /dev/null +++ b/app/components/settings/ResetAppModal.tsx @@ -0,0 +1,120 @@ +"use client"; + +/** + * Two-step confirm dialog for "Reset all data". + * + * Step 1 offers a backup export before anything is destroyed; step 2 is + * the actual confirmation. The export button is right there because the + * point at which someone is about to wipe the database is exactly when + * they should be offered a copy of it. + */ + +import { useTranslations } from "next-intl"; +import type { useModalFocus } from "@/lib/use-modal-focus"; + +export default function ResetAppModal({ + resetStep, + setResetStep, + closeResetModal, + resetAllData, + resetting, + resetModalRef, + exportingBackup, + handleExportBackup, +}: { + /** 0 = closed, 1 = offer a backup, 2 = final confirmation. */ + resetStep: 0 | 1 | 2; + setResetStep: (next: 0 | 1 | 2) => void; + closeResetModal: () => void; + resetAllData: () => void; + resetting: boolean; + resetModalRef: ReturnType>; + exportingBackup: boolean; + handleExportBackup: () => void; +}) { + const tBackupCard = useTranslations("settings.backup_card"); + const tResetCard = useTranslations("settings.reset_app_card"); + + return ( + <> + {resetStep > 0 && ( +
+
event.stopPropagation()} + ref={resetModalRef} + role="dialog" + tabIndex={-1} + > +
{tResetCard("modal_badge")}
+

+ {resetStep === 1 + ? tResetCard("modal_title_step_1") + : tResetCard("modal_title_step_2")} +

+

+ {resetStep === 1 + ? tResetCard("modal_body_step_1") + : tResetCard("modal_body_step_2")} +

+ {resetStep === 2 && ( +
+
+ {tBackupCard("download_before_reset")} +
+ +
+ )} +
+ + + {resetStep === 1 ? ( + + ) : ( + + )} +
+
+
+ )} + + ); +} diff --git a/app/components/settings/RestoreBackupModal.tsx b/app/components/settings/RestoreBackupModal.tsx new file mode 100644 index 0000000..93461bb --- /dev/null +++ b/app/components/settings/RestoreBackupModal.tsx @@ -0,0 +1,210 @@ +"use client"; + +/** + * Confirm dialog for restoring a backup file. + * + * Deliberately the heaviest of the five: restoring replaces the whole + * database, so the user sees what the server found in the file (app and + * snapshot counts, any warnings) and has to type the confirmation phrase + * before the button enables. `applying` disables every dismiss path so a + * half-applied restore can't be interrupted. + */ + +import { useTranslations } from "next-intl"; +import type { DateFormatMode } from "@/lib/date-format"; +import type { useModalFocus } from "@/lib/use-modal-focus"; +import { fmtShortDate } from "./format"; +import type { BackupRestorePreview, BackupRestoreStage } from "./types"; + +export default function RestoreBackupModal({ + restoreStage, + restorePreview, + restoreConfirmText, + setRestoreConfirmText, + restoreError, + setRestoreError, + pendingRestoreFilename, + handleRestoreConfirm, + resetRestoreFlow, + restoreModalRef, + exportingBackup, + handleExportBackup, + dateMode, +}: { + restoreStage: BackupRestoreStage; + restorePreview: BackupRestorePreview | null; + restoreConfirmText: string; + setRestoreConfirmText: (next: string) => void; + restoreError: string; + setRestoreError: (next: string) => void; + pendingRestoreFilename: string | null; + handleRestoreConfirm: () => void; + resetRestoreFlow: () => void; + restoreModalRef: ReturnType>; + exportingBackup: boolean; + handleExportBackup: () => void; + dateMode: DateFormatMode; +}) { + const tAria = useTranslations("settings.aria"); + const tPh = useTranslations("settings.placeholders"); + const tBackupCard = useTranslations("settings.backup_card"); + const tModalRestore = useTranslations("settings.modals.restore_backup"); + + return ( + <> + {(restoreStage === "confirm" || restoreStage === "applying") && + restorePreview && ( +
{ + if (restoreStage !== "applying") { + resetRestoreFlow(); + } + }} + > +
event.stopPropagation()} + ref={restoreModalRef} + role="dialog" + tabIndex={-1} + > +
{tModalRestore("badge")}
+

+ {tModalRestore("title")} +

+

+ {pendingRestoreFilename ? ( + <> + {pendingRestoreFilename} + {restorePreview.exportedAt + ? tModalRestore("exported_suffix", { + date: fmtShortDate( + restorePreview.exportedAt, + dateMode + ), + }) + : null}{" "} + {tModalRestore("version_suffix", { + version: restorePreview.version, + })}{" "} + {tModalRestore("rows", { count: restorePreview.totalRows })} + + ) : ( + tModalRestore("no_filename", { + count: restorePreview.totalRows, + tables: restorePreview.perTable.length, + }) + )} +

+ +
+ {restorePreview.perTable + .filter((row) => row.rows > 0) + .map((row) => ( +
+ {row.name} + + {row.rows.toLocaleString()} + +
+ ))} +
+ + {restorePreview.warnings.length > 0 && ( +
    + {restorePreview.warnings.map((warning, index) => ( +
  • ⚠ {warning}
  • + ))} +
+ )} + +
+ {tModalRestore("warning_lead")} + {tModalRestore("warning_body")} +
+ +
+
+ {tBackupCard("download_current_before_restore")} +
+ +
+ + + { + setRestoreConfirmText(event.target.value); + if (restoreError) { + setRestoreError(""); + } + }} + placeholder={tPh("restore_confirm")} + spellCheck={false} + type="text" + value={restoreConfirmText} + /> + + {restoreError && ( +

+ {restoreError} +

+ )} + +
+ + +
+
+
+ )} + + ); +} diff --git a/app/components/settings/WaybackRemoveModal.tsx b/app/components/settings/WaybackRemoveModal.tsx new file mode 100644 index 0000000..d96b427 --- /dev/null +++ b/app/components/settings/WaybackRemoveModal.tsx @@ -0,0 +1,79 @@ +"use client"; + +/** + * Confirm dialog for purging every Wayback-imported snapshot. + * + * Only touches rows with `source = 'wayback'` — live sync history is left + * alone, which is the distinction the copy has to make clear before the + * user commits. + */ + +import { useTranslations } from "next-intl"; +import type { useModalFocus } from "@/lib/use-modal-focus"; + +export default function WaybackRemoveModal({ + waybackRemoveOpen, + closeWaybackRemoveModal, + removeAllWaybackHistory, + waybackRemoving, + waybackRemoveModalRef, +}: { + waybackRemoveOpen: boolean; + closeWaybackRemoveModal: () => void; + removeAllWaybackHistory: () => void; + waybackRemoving: boolean; + waybackRemoveModalRef: ReturnType>; +}) { + const tWaybackRemove = useTranslations("settings.wayback.remove_modal"); + + return ( + <> + {waybackRemoveOpen && ( +
+
event.stopPropagation()} + ref={waybackRemoveModalRef} + role="dialog" + tabIndex={-1} + > +
{tWaybackRemove("badge")}
+

+ {tWaybackRemove("title")} +

+

+ {tWaybackRemove("body")} +

+
+ + +
+
+
+ )} + + ); +} diff --git a/app/components/settings/types.ts b/app/components/settings/types.ts index f0ccb59..14218e6 100644 --- a/app/components/settings/types.ts +++ b/app/components/settings/types.ts @@ -1,3 +1,6 @@ +import type { AIProvider } from "@/lib/ai-config"; +import type { PolicySummary } from "@/lib/policy-summary-meta"; + /** * Types shared between SettingsView and the extracted section components. * @@ -171,3 +174,49 @@ export interface BackupSnapshotsPayload { settings: BackupSnapshotSettings; snapshots: BackupSnapshotRow[]; } + +/** The AI settings blob as the server reports it back — the baseline the + * editor diffs against to decide whether anything changed. */ +export interface StoredAiSettings { + apiKey: string; + baseUrl: string; + debugLogging: boolean; + model: string; + provider: AIProvider; + summarizeOnImport: boolean; + timeoutChunkMs: string; + // Per-phase AI request timeouts, persisted as strings so the input can + // hold "" while the user is mid-edit. Empty string = server default. + timeoutDirectMs: string; + timeoutMergeMs: string; +} + +/** Result of the sample-policy dry run in the AI Summaries card. */ +export interface AiSamplePolicyResult { + durationMs: number; + mode: "direct" | "chunked"; + model: string; + ok: true; + provider: string; + sample: { + appName: string; + developer: string; + policyUrl: string; + policyText: string; + scenario: string; + wordCount: number; + reviewChecklist: string[]; + expectedSignals: string[]; + }; + summary: PolicySummary; +} + +/** What the server reports it found inside an uploaded backup file, + * shown for confirmation before anything is replaced. */ +export interface BackupRestorePreview { + exportedAt: number | null; + perTable: { name: string; rows: number }[]; + totalRows: number; + version: number; + warnings: string[]; +} diff --git a/lib/use-ai-settings.ts b/lib/use-ai-settings.ts new file mode 100644 index 0000000..c0f3fe6 --- /dev/null +++ b/lib/use-ai-settings.ts @@ -0,0 +1,629 @@ +"use client"; + +/** + * The AI Summaries card's entire state machine: provider/base-URL/model/key + * form state, the connection test, model discovery against a custom + * endpoint, the sample-policy dry run, per-phase request timeouts, and the + * debounced auto-save that writes the whole blob. + * + * This was ~480 lines spread across six regions of SettingsView. Almost all + * of it is read by one card; the exceptions leak deliberately and are + * returned rather than kept private: + * + * - `aiProvider` — the timeouts panel disables its inputs when AI is off. + * - `saveAiSettings` + `debugLogging` — the AI debug-log toggle in + * Developer Options round-trips through this same settings blob. + * - the three `aiTimeout*` values and their auto-savers, which the + * timeouts panel owns visually but which are part of this blob. + * + * `hydrate()` is the entry point for the shared settings loader: it reads + * every key at once, so it hands the AI slice over in one call rather than + * this hook mounting its own duplicate fetch. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fmtDuration } from "@/app/components/settings/format"; +import type { + AiSamplePolicyResult, + StoredAiSettings, +} from "@/app/components/settings/types"; +import type { useTaskCenter } from "@/app/components/TaskCenter"; +import { + type AIProvider, + getAiModelOptions, + providerRequiresApiKey, + resolveDefaultBaseUrl, + resolveDefaultModel, +} from "@/lib/ai-config"; +import { useSettingsAutoSave } from "@/lib/use-settings-auto-save"; + +type T = (key: string, values?: Record) => string; + +export function useAiSettings({ + tAiProvider, + tAiSample, + tDevAiTimeouts, + taskCenter, + loadSettings, +}: { + tAiProvider: T; + tAiSample: T; + tDevAiTimeouts: T; + taskCenter: ReturnType; + /** Re-pulls the canonical settings blob after a save that changes what + * the server reports back (e.g. the masked API key). */ + loadSettings: () => void; +}) { + const [storedAi, setStoredAi] = useState(null); + const [aiProvider, setAiProvider] = useState("disabled"); + const [aiApiKey, setAiApiKey] = useState(""); + const [aiBaseUrl, setAiBaseUrl] = useState(""); + const [aiModel, setAiModel] = useState(""); + // Focus plumbing for the "Custom model ID" field. We don't want autoFocus + // there, because if the user has a custom model saved the input renders on + // page load and the browser scrolls it into view — which breaks deep-links + // into other sections of the Settings page (e.g. #sync-schedule from the + // Task Center). Instead we only focus when the user *actively* picks + // "Custom model…" from the model dropdown. + const customModelInputRef = useRef(null); + const focusCustomModelOnNextRender = useRef(false); + // UI-only toggle for the custom provider's API key input. Local LLM + // endpoints (Ollama, llama.cpp) do not accept a key, so we hide the field + // by default and only reveal it when the user checks "My endpoint + // requires an API key". Hydrated from whether a key is currently stored. + const [customApiKeyEnabled, setCustomApiKeyEnabled] = useState(false); + const [summarizeOnImport, setSummarizeOnImport] = useState(false); + const [debugLogging, setDebugLogging] = useState(false); + // Per-phase AI timeouts. Strings so we can hold "" (= use default) mid-edit. + const [aiTimeoutDirectMs, setAiTimeoutDirectMs] = useState(""); + const [aiTimeoutChunkMs, setAiTimeoutChunkMs] = useState(""); + const [aiTimeoutMergeMs, setAiTimeoutMergeMs] = useState(""); + + // AI connection test + type AiTestStatus = "idle" | "testing" | "ok" | "fail"; + const [aiTestStatus, setAiTestStatus] = useState("idle"); + const [aiTestMessage, setAiTestMessage] = useState(""); + const [aiTestLatency, setAiTestLatency] = useState(null); + + type AiSampleStatus = "idle" | "testing" | "ok" | "fail"; + const [aiSampleStatus, setAiSampleStatus] = useState("idle"); + const [aiSampleMessage, setAiSampleMessage] = useState(""); + const [aiSampleResult, setAiSampleResult] = + useState(null); + + // Discovered models for the "Own Model" (custom) provider — populated by + // polling the endpoint's /models (OpenAI-compatible) or /api/tags (Ollama). + type ModelsStatus = "idle" | "loading" | "ok" | "error"; + const [discoveredModels, setDiscoveredModels] = useState< + { value: string; label: string }[] + >([]); + const [modelsStatus, setModelsStatus] = useState("idle"); + const [modelsError, setModelsError] = useState(""); + + const aiTimeoutDirectAutoSave = useSettingsAutoSave({ + endpoint: "/api/settings", + buildBody: (value) => ({ ai_timeout_direct_ms: value }), + successMessage: (value) => + value === "" + ? tDevAiTimeouts("toast_direct_reset") + : tDevAiTimeouts("toast_direct_set", { value }), + taskLabel: (value) => + tDevAiTimeouts("task_label_direct", { + value: value || tDevAiTimeouts("task_label_default_value"), + }), + }); + const aiTimeoutChunkAutoSave = useSettingsAutoSave({ + endpoint: "/api/settings", + buildBody: (value) => ({ ai_timeout_chunk_ms: value }), + successMessage: (value) => + value === "" + ? tDevAiTimeouts("toast_chunk_reset") + : tDevAiTimeouts("toast_chunk_set", { value }), + taskLabel: (value) => + tDevAiTimeouts("task_label_chunk", { + value: value || tDevAiTimeouts("task_label_default_value"), + }), + }); + const aiTimeoutMergeAutoSave = useSettingsAutoSave({ + endpoint: "/api/settings", + buildBody: (value) => ({ ai_timeout_merge_ms: value }), + successMessage: (value) => + value === "" + ? tDevAiTimeouts("toast_merge_reset") + : tDevAiTimeouts("toast_merge_set", { value }), + taskLabel: (value) => + tDevAiTimeouts("task_label_merge", { + value: value || tDevAiTimeouts("task_label_default_value"), + }), + }); + + // Reset the test indicator whenever the user edits any of the inputs the + // test actually uses — provider, api key, base url. + useEffect(() => { + setAiTestStatus("idle"); + setAiTestMessage(""); + setAiTestLatency(null); + }, [aiProvider, aiApiKey, aiBaseUrl]); + + useEffect(() => { + setAiSampleStatus("idle"); + setAiSampleMessage(""); + setAiSampleResult(null); + }, [aiProvider, aiApiKey, aiBaseUrl, aiModel]); + + const testAiConnection = async () => { + setAiTestStatus("testing"); + setAiTestMessage(""); + setAiTestLatency(null); + try { + const res = await fetch("/api/ai/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: aiProvider, + apiKey: aiApiKey, + baseUrl: aiProvider === "disabled" ? "" : aiBaseUrl, + }), + }); + const data = (await res.json()) as { + ok?: boolean; + message?: string; + latencyMs?: number; + }; + setAiTestStatus(data.ok ? "ok" : "fail"); + setAiTestMessage(typeof data.message === "string" ? data.message : ""); + setAiTestLatency( + typeof data.latencyMs === "number" ? data.latencyMs : null + ); + } catch (error) { + console.error("[settings] AI connection test failed:", error); + setAiTestStatus("fail"); + setAiTestMessage(error instanceof Error ? error.message : String(error)); + setAiTestLatency(null); + } + }; + + const canDiscoverModels = + aiProvider !== "disabled" && + !!aiBaseUrl.trim() && + (!providerRequiresApiKey(aiProvider) || !!aiApiKey.trim()); + + const canRunSamplePolicyTest = + aiProvider !== "disabled" && + !!aiBaseUrl.trim() && + !!aiModel.trim() && + (!providerRequiresApiKey(aiProvider) || !!aiApiKey.trim()); + + const runAiSampleSummaryTest = async () => { + if (!canRunSamplePolicyTest || aiSampleStatus === "testing") { + return; + } + + setAiSampleStatus("testing"); + setAiSampleMessage(""); + setAiSampleResult(null); + + const controller = new AbortController(); + const handle = taskCenter.startTask({ + title: tAiSample("task_title"), + subtitle: tAiSample("task_subtitle"), + kind: "sync", + href: "/dashboard/settings#ai-summaries", + onCancel: () => controller.abort(), + }); + + try { + const res = await fetch("/api/ai/policy-sample", { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + provider: aiProvider, + apiKey: aiApiKey, + baseUrl: aiBaseUrl, + model: aiModel, + }), + }); + const data = (await res.json()) as + | AiSamplePolicyResult + | { ok: false; error?: string; durationMs?: number }; + + if (!res.ok || data.ok !== true) { + const message = + "error" in data && typeof data.error === "string" + ? data.error + : tAiSample("error_fallback"); + setAiSampleStatus("fail"); + setAiSampleMessage(message); + handle.complete("error", message); + return; + } + + setAiSampleStatus("ok"); + setAiSampleResult(data); + const message = tAiSample("success", { + model: data.model, + duration: fmtDuration(data.durationMs), + }); + setAiSampleMessage(message); + handle.complete("done", message); + } catch (error) { + const aborted = (error as Error)?.name === "AbortError"; + const message = aborted + ? tAiSample("cancelled") + : error instanceof Error + ? error.message + : String(error); + setAiSampleStatus("fail"); + setAiSampleMessage(message); + handle.complete("error", message); + } + }; + + // Fetch the list of models the configured provider actually exposes. Hosted + // providers use their official models endpoints; custom providers try + // OpenAI-compatible /models first, then Ollama /api/tags server-side. + const refreshDiscoveredModels = useCallback(async () => { + if (aiProvider === "disabled") { + setDiscoveredModels([]); + setModelsStatus("idle"); + setModelsError(""); + return; + } + if (!canDiscoverModels) { + setDiscoveredModels([]); + setModelsStatus("idle"); + setModelsError(""); + return; + } + + setModelsStatus("loading"); + setModelsError(""); + try { + const res = await fetch("/api/ai/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: aiProvider, + apiKey: aiApiKey, + baseUrl: aiBaseUrl, + }), + }); + const data = (await res.json()) as { + ok?: boolean; + message?: string; + models?: { id?: string; label?: string }[]; + }; + if (data.ok && Array.isArray(data.models)) { + const cleaned = data.models + .map((m) => ({ + value: typeof m.id === "string" ? m.id : "", + label: + typeof m.label === "string" && m.label + ? m.label + : typeof m.id === "string" + ? m.id + : "", + })) + .filter((m) => m.value); + setDiscoveredModels(cleaned); + if (cleaned.length === 0) { + setModelsStatus("error"); + setModelsError("No models returned from the endpoint."); + } else { + setModelsStatus("ok"); + } + } else { + setDiscoveredModels([]); + setModelsStatus("error"); + setModelsError( + typeof data.message === "string" + ? data.message + : "Could not list models." + ); + } + } catch (err) { + setDiscoveredModels([]); + setModelsStatus("error"); + setModelsError( + err instanceof Error ? err.message : "Could not list models." + ); + } + }, [aiProvider, aiApiKey, aiBaseUrl, canDiscoverModels]); + + // Auto-fetch with a debounce so typing in the Base URL doesn't spam requests. + useEffect(() => { + if (aiProvider === "disabled" || !canDiscoverModels) { + setDiscoveredModels([]); + setModelsStatus("idle"); + setModelsError(""); + return; + } + const timer = setTimeout(() => { + void refreshDiscoveredModels(); + }, 600); + return () => clearTimeout(timer); + }, [aiProvider, canDiscoverModels, refreshDiscoveredModels]); + + /** + * Auto-save hook for the AI Summaries card. Holds the full provider + * config — provider, key, model, baseUrl, behaviour toggles. Saved + * as a single POST so server-side validation sees a coherent blob + * (e.g. switching from openai to custom requires baseUrl to flip + * to localhost; sending them separately would fail the route's + * cross-field checks). + * + * Per-phase timeouts are NOT included here — they auto-save through + * their own hooks (see `aiTimeoutDirectAutoSave` etc.) since they're + * independent and safer to write granularly. + */ + interface AiSettingsBlob { + apiKey: string; + baseUrl: string; + debugLogging: boolean; + model: string; + provider: AIProvider; + summarizeOnImport: boolean; + } + + const aiSettingsAutoSave = useSettingsAutoSave({ + endpoint: "/api/settings", + buildBody: (v) => ({ + ai_provider: v.provider, + ai_api_key: v.apiKey, + ai_base_url: v.provider === "disabled" ? "" : v.baseUrl, + ai_model: v.provider === "disabled" ? "" : v.model, + ai_summarize_on_import: v.summarizeOnImport, + ai_debug_logging: v.debugLogging, + }), + successMessage: (v) => + v.provider === "disabled" + ? tAiProvider("toast_disabled") + : tAiProvider("toast_saved", { provider: v.provider }), + taskLabel: (v) => + v.provider === "disabled" + ? tAiProvider("toast_disabled") + : tAiProvider("task_label", { provider: v.provider }), + onSaved: () => { + // loadSettings re-pulls the canonical state including the masked + // apiKey marker, so subsequent saves don't accidentally re-send + // the placeholder. + void loadSettings(); + }, + }); + + /** + * Compose the current AI settings blob from React state with optional + * field overrides, then fire a save. Overrides exist because state + * setters are async — after `setAiProvider(next)` the local + * `aiProvider` is still the previous value within the same handler, + * so per-event handlers must thread the new value through explicitly. + * + * Skip rule: when the provider isn't disabled but a required field + * is missing (baseUrl, model, or apiKey for hosted providers), the + * save would 400 anyway. Silently skipping keeps the bottom toast + * quiet while the user is mid-edit. + */ + const saveAiSettings = useCallback( + (overrides: Partial = {}) => { + const blob: AiSettingsBlob = { + provider: overrides.provider ?? aiProvider, + apiKey: overrides.apiKey ?? aiApiKey, + baseUrl: overrides.baseUrl ?? aiBaseUrl, + model: overrides.model ?? aiModel, + summarizeOnImport: overrides.summarizeOnImport ?? summarizeOnImport, + debugLogging: overrides.debugLogging ?? debugLogging, + }; + if ( + blob.provider !== "disabled" && + (!(blob.baseUrl.trim() && blob.model.trim()) || + (providerRequiresApiKey(blob.provider) && !blob.apiKey.trim())) + ) { + // Required field missing — wait for the user to fill it in. + return; + } + void aiSettingsAutoSave.save(blob); + }, + [ + aiProvider, + aiApiKey, + aiBaseUrl, + aiModel, + summarizeOnImport, + debugLogging, + aiSettingsAutoSave, + ] + ); + + const providerOptions = getAiModelOptions(aiProvider); + + // For the custom provider the model list is driven entirely by what the + // endpoint reports — we used to also render a curated "Suggestions" group + // (llama3.2, qwen2.5, etc.) but that confused users whose endpoint didn't + // actually have those pulled. If the endpoint returns nothing, the user + // can pick "Custom model…" and type whatever ID they want. + const mergedModelValues = useMemo(() => { + const set = new Set(); + if (aiProvider === "custom") { + for (const m of discoveredModels) { + set.add(m.value); + } + } else { + for (const m of discoveredModels) { + set.add(m.value); + } + for (const m of providerOptions) { + set.add(m.value); + } + } + return set; + }, [aiProvider, discoveredModels, providerOptions]); + + const selectedModelPreset = mergedModelValues.has(aiModel) + ? aiModel + : "__custom__"; + + // When the user picks "Custom model…" from the dropdown the input appears on + // the next render. We focus it *only* in that case — never on initial load, + // so deep-links like #sync-schedule aren't hijacked by the browser scrolling + // this field into view. + useEffect(() => { + if (selectedModelPreset !== "__custom__") { + return; + } + if (!focusCustomModelOnNextRender.current) { + return; + } + focusCustomModelOnNextRender.current = false; + customModelInputRef.current?.focus(); + }, [selectedModelPreset]); + const aiConfigChanged = storedAi + ? storedAi.provider !== aiProvider || + storedAi.apiKey !== aiApiKey || + storedAi.baseUrl !== (aiProvider === "disabled" ? "" : aiBaseUrl) || + storedAi.model !== (aiProvider === "disabled" ? "" : aiModel) || + storedAi.summarizeOnImport !== summarizeOnImport || + storedAi.debugLogging !== debugLogging || + storedAi.timeoutDirectMs !== aiTimeoutDirectMs.trim() || + storedAi.timeoutChunkMs !== aiTimeoutChunkMs.trim() || + storedAi.timeoutMergeMs !== aiTimeoutMergeMs.trim() + : false; + + const onProviderChange = (nextProvider: AIProvider) => { + setAiProvider(nextProvider); + + // When leaving the custom provider, collapse the API-key toggle back to + // a clean slate so re-entering custom starts with the field hidden + // again. Switching to a hosted provider doesn't need the toggle at all + // (the input is always shown because a key is required). + if (nextProvider !== "custom") { + setCustomApiKeyEnabled(false); + } + + if (nextProvider === "disabled") { + // Disabling is a single-field intent — fire the save immediately. + saveAiSettings({ provider: "disabled" }); + return; + } + + const previousDefaultModel = + aiProvider === "disabled" ? "" : resolveDefaultModel(aiProvider); + const previousDefaultBaseUrl = + aiProvider === "disabled" ? "" : resolveDefaultBaseUrl(aiProvider); + + // Compute the next model / baseUrl synchronously so we can thread + // them into the save payload below. setState is async, so reading + // `aiModel` after `setAiModel(...)` would still see the previous + // value within this handler. + let nextModel = aiModel; + if (!aiModel || aiModel === previousDefaultModel) { + nextModel = resolveDefaultModel(nextProvider); + setAiModel(nextModel); + } + + let nextBaseUrl = aiBaseUrl; + if (!aiBaseUrl || aiBaseUrl === previousDefaultBaseUrl) { + nextBaseUrl = resolveDefaultBaseUrl(nextProvider); + setAiBaseUrl(nextBaseUrl); + } + + // Save the full new triple at once so the route's cross-field + // validation sees a coherent (provider, baseUrl, model) tuple. + saveAiSettings({ + provider: nextProvider, + baseUrl: nextBaseUrl, + model: nextModel, + }); + }; + + // The legacy `aiSaveDisabled` flag is gone — there's no Save button + // anymore. The same gating now lives inline in `saveAiSettings`, + // which silently skips POSTs when required fields are missing. + + /** + * Apply a settings blob fetched by SettingsView's shared loader. + * + * That loader reads every settings key in one request, so it hands the AI + * slice over here rather than this hook mounting a duplicate fetch. Doing + * it through one entry point also keeps the eight setters it would + * otherwise need private to the hook. + */ + const hydrate = (nextAi: StoredAiSettings) => { + setStoredAi(nextAi); + setAiProvider(nextAi.provider); + setAiApiKey(nextAi.apiKey); + setAiBaseUrl(nextAi.baseUrl); + setAiModel(nextAi.model); + // Reveal the API-key field for the custom provider only when the server + // already has a key stored (round-trips as "__SET__"). Otherwise the + // field stays hidden — which is the right default for local LLM + // endpoints that don't use a key at all. + setCustomApiKeyEnabled(nextAi.provider === "custom" && !!nextAi.apiKey); + setSummarizeOnImport(nextAi.summarizeOnImport); + setDebugLogging(nextAi.debugLogging); + setAiTimeoutDirectMs(nextAi.timeoutDirectMs); + setAiTimeoutChunkMs(nextAi.timeoutChunkMs); + setAiTimeoutMergeMs(nextAi.timeoutMergeMs); + }; + + return { + hydrate, + storedAi, + setStoredAi, + aiProvider, + setAiProvider, + aiApiKey, + setAiApiKey, + aiBaseUrl, + setAiBaseUrl, + aiModel, + setAiModel, + customModelInputRef, + focusCustomModelOnNextRender, + customApiKeyEnabled, + setCustomApiKeyEnabled, + summarizeOnImport, + setSummarizeOnImport, + debugLogging, + setDebugLogging, + aiTimeoutDirectMs, + setAiTimeoutDirectMs, + aiTimeoutChunkMs, + setAiTimeoutChunkMs, + aiTimeoutMergeMs, + setAiTimeoutMergeMs, + aiTestStatus, + setAiTestStatus, + aiTestMessage, + setAiTestMessage, + aiTestLatency, + setAiTestLatency, + aiSampleStatus, + setAiSampleStatus, + aiSampleMessage, + setAiSampleMessage, + aiSampleResult, + setAiSampleResult, + discoveredModels, + setDiscoveredModels, + modelsStatus, + setModelsStatus, + modelsError, + setModelsError, + aiTimeoutDirectAutoSave, + aiTimeoutChunkAutoSave, + aiTimeoutMergeAutoSave, + testAiConnection, + canDiscoverModels, + canRunSamplePolicyTest, + runAiSampleSummaryTest, + refreshDiscoveredModels, + aiSettingsAutoSave, + saveAiSettings, + providerOptions, + mergedModelValues, + selectedModelPreset, + aiConfigChanged, + onProviderChange, + }; +} diff --git a/lib/use-import-history.ts b/lib/use-import-history.ts index 3a429d8..8e55f7d 100644 --- a/lib/use-import-history.ts +++ b/lib/use-import-history.ts @@ -152,6 +152,13 @@ export function itemMatchesFilter( return status === filter; } +/** Staged target for the "Remove from Apps" confirm dialog. */ +export interface PendingItemRemoval { + appId: string; + importRow: ImportRow; + item: ImportItemRow; +} + export function useImportHistory({ importQueue, router, @@ -229,11 +236,8 @@ export function useImportHistory({ * Mirrors the `.modal-overlay` / `.modal-card` pattern used elsewhere * in this view (wayback-remove, reset-app). */ - const [pendingItemRemoval, setPendingItemRemoval] = useState(null); + const [pendingItemRemoval, setPendingItemRemoval] = + useState(null); // Id of the import item currently re-scraping its existing App Store URL. // Drives the spinner on the "Retry import" button — separate from the // change-match apply state so a bare retry (no search UI) doesn't need to