From 0617d5476216814424bb9b0ab61cb5d7815e7e7a Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Sun, 17 May 2026 11:22:17 -0700 Subject: [PATCH] feat(easy-setup): split AI into its own conditional step (issue #905) Easy Setup wizard previously bundled AI model selection + the new ingest-policy radio into Step 3 alongside Wikipedia/ZIM tiers and curated content. Three problems with that: 1. Predicate divergence: "is AI selected?" was answered three different ways across Step 3 radio, Step 4 review card, and handleFinish persistence. Surfaced in @jakeaturner's review of PR #900. The three predicates disagree in real cases (e.g. Ollama already installed but user didn't re-select any models -- handleFinish writes the ingest KV while the review hides the AI summary). 2. Step 3 was overloaded -- ZIM tiers + curated content + AI models + ingest policy in one screen. 3. No way to opt out of seeing the AI policy radio when AI isn't part of the user's setup. This restructure makes step 4 a dedicated, conditional AI step: Step 1 (Apps) -- unchanged (services + remote Ollama toggle/URL) Step 2 (Maps) -- unchanged Step 3 (Content) -- Wikipedia + curated tiers only Step 4 (AI) -- NEW, conditional: model picker (or remote notice) + auto-index policy radio. Skipped entirely when AI isn't in the setup. Step 5 (Review) -- summary, reads back step 4's output via the same canonical predicate Decisions per issue #905 discussion: - Canonical predicate `isAiInSetup` as a useMemo. Single source consumed by step indicator, nav skip logic, review summary, and handleFinish. Both prior divergence cases collapse. - Step indicator renders dynamically: 4 dots when AI is off (positional display numbers 1..4), 5 dots when AI is on. WizardStep semantic values (1=Apps, 2=Maps, 3=Content, 4=AI, 5=Review) stay stable so nav handlers don't have to translate; the dot's `displayNumber` is decoupled from its `step` so users see sequential 1..N with no gap. - handleNext / handleBack are symmetric: 3 -> 5 forward, 5 -> 3 back, when !isAiInSetup. Same predicate gate. - Toggling AI capability off in Step 1 after AI step selections were made fires a confirm dialog ("Turning off AI will discard your AI model picks, indexing policy, and remote Ollama configuration") and clears selectedAiModels / ingestPolicy / remoteOllamaEnabled on confirm. Silent clear when nothing was set. - Remote Ollama toggle stays in Step 1 alongside the capability card. Don't fragment "am I using remote AI?" across two steps. The bundled review summary (renderStep5, was renderStep4) now uses `isAiInSetup` for the auto-index card visibility instead of the divergent `(selectedAiModels.length > 0 || remoteOllamaEnabled)`. Inertia tsconfig clean for this file (the only outstanding errors are the 3 KnowledgeBaseModal ones from issue tracked in PR #907 and the ~64 pre-existing errors elsewhere). Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/inertia/pages/easy-setup/index.tsx | 447 +++++++++++++---------- 1 file changed, 251 insertions(+), 196 deletions(-) diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index f310db8..c1fa42e 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -106,7 +106,7 @@ const ADDITIONAL_TOOLS: Capability[] = [ }, ] -type WizardStep = 1 | 2 | 3 | 4 +type WizardStep = 1 | 2 | 3 | 4 | 5 const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections' const CURATED_CATEGORIES_KEY = 'curated-categories' @@ -199,6 +199,19 @@ export default function EasySetupWizard(props: { // Services that are already installed const installedServices = props.system.services.filter((service) => service.installed) + // Canonical "is AI part of this user's setup?" predicate (RFC #883 / issue #905). + // Single source consumed by step-indicator render, navigation skip logic, the + // review summary, and handleFinish. The AI step renders if and only if this + // is true; if false, the wizard collapses to 4 steps and the AI step is + // skipped on both forward and back nav. + const isAiInSetup = useMemo( + () => + selectedServices.includes(SERVICE_NAMES.OLLAMA) || + installedServices.some((s) => s.service_name === SERVICE_NAMES.OLLAMA) || + remoteOllamaEnabled, + [selectedServices, installedServices, remoteOllamaEnabled] + ) + const toggleMapCollection = (slug: string) => { setSelectedMapCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] @@ -313,24 +326,29 @@ export default function EasySetupWizard(props: { // Get primary disk/filesystem info for storage projection const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize) + // Final step number (4 when AI is off, 5 when AI is on). Centralizing this + // here so canProceedToNextStep / handleNext / handleBack / the bottom-bar + // Next-vs-Finish switch all read the same value. + const finalStep: WizardStep = isAiInSetup ? 5 : 4 + const canProceedToNextStep = () => { if (!isOnline) return false // Must be online to proceed - if (currentStep === 1) return true // Can skip app installation - if (currentStep === 2) return true // Can skip map downloads - if (currentStep === 3) return true // Can skip ZIM downloads - return false + // Every step before the review is skippable; the review step shows Finish, not Next. + return currentStep < finalStep } const handleNext = () => { - if (currentStep < 4) { - setCurrentStep((prev) => (prev + 1) as WizardStep) - } + if (currentStep >= finalStep) return + // Skip the AI step (4) on forward nav when isAiInSetup is false. + const next = currentStep === 3 && !isAiInSetup ? 5 : currentStep + 1 + setCurrentStep(next as WizardStep) } const handleBack = () => { - if (currentStep > 1) { - setCurrentStep((prev) => (prev - 1) as WizardStep) - } + if (currentStep <= 1) return + // Skip the AI step (4) on back nav when isAiInSetup is false. + const prev = currentStep === 5 && !isAiInSetup ? 3 : currentStep - 1 + setCurrentStep(prev as WizardStep) } const handleFinish = async () => { @@ -347,13 +365,11 @@ export default function EasySetupWizard(props: { try { // Persist the auto-index policy choice before kicking off downloads so // any content that finishes during this same wizard run sees the right - // policy. Skipped when the user did not select the AI capability — the - // KV stays null and the first-chat JIT prompt (#899) handles the - // decision later if/when the user enables AI. - const aiSelected = - selectedServices.includes(SERVICE_NAMES.OLLAMA) || - installedServices.some((s) => s.service_name === SERVICE_NAMES.OLLAMA) - if (aiSelected) { + // policy. Skipped when AI is not in the user's setup; the KV stays null + // and the first-chat JIT prompt (#899) handles the decision later if/when + // the user enables AI. Uses the canonical isAiInSetup predicate so step + // 3 / step 4 / step 5 / handleFinish never disagree (issue #905). + if (isAiInSetup) { try { await api.updateSetting('rag.defaultIngestPolicy', ingestPolicy) } catch (err) { @@ -454,12 +470,25 @@ export default function EasySetupWizard(props: { }, []) const renderStepIndicator = () => { - const steps = [ - { number: 1, label: 'Apps' }, - { number: 2, label: 'Maps' }, - { number: 3, label: 'Content' }, - { number: 4, label: 'Review' }, - ] + // `step` is the stable WizardStep value (1=Apps, 2=Maps, 3=Content, + // 4=AI, 5=Review). `displayNumber` is the sequential position shown in + // the dot (always 1..N) so users see "1 2 3 4" when AI is off and + // "1 2 3 4 5" when AI is on, with no gap. + const baseSteps: Array<{ step: WizardStep; label: string }> = isAiInSetup + ? [ + { step: 1, label: 'Apps' }, + { step: 2, label: 'Maps' }, + { step: 3, label: 'Content' }, + { step: 4, label: 'AI' }, + { step: 5, label: 'Review' }, + ] + : [ + { step: 1, label: 'Apps' }, + { step: 2, label: 'Maps' }, + { step: 3, label: 'Content' }, + { step: 5, label: 'Review' }, + ] + const steps = baseSteps.map((s, idx) => ({ ...s, displayNumber: idx + 1 })) return (