fix(easy-setup): streamline wizard + robust model recommendations

Step 1: remove the "Additional Tools" (Notes/Data Tools) section from
onboarding and point users to Supply Depot, the browsable app catalog,
for everything beyond the three core capabilities.

Step 2: add a note that individual countries and a full global map can
be installed any time from the Maps Manager.

Step 4: default the KB auto-index policy to "Ask me first" (Manual)
instead of "Yes, always" — auto-indexing has cost/resource implications
a non-technical user won't anticipate from the toggle alone.

ollama_service: the recommended-models fallback only fired on a null
result, so a successful-but-empty upstream response (models: []) showed
"No recommended AI models available" and poisoned the 24h cache. Now the
fallback fires on empty too, empty results are never cached, empty caches
are ignored, and the upstream request has a 10s timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-07-16 11:34:30 -07:00
parent 6a4f02dd46
commit 5d429594aa
2 changed files with 72 additions and 67 deletions

View File

@ -764,7 +764,7 @@ export class OllamaService {
): Promise<{ models: NomadOllamaModel[]; hasMore: boolean } | null> {
try {
const models = await this.retrieveAndRefreshModels(sort, force)
if (!models) {
if (!models || models.length === 0) {
logger.warn(
'[OllamaService] Returning fallback recommended models due to failure in fetching available models'
)
@ -819,7 +819,10 @@ export class OllamaService {
try {
if (!force) {
const cachedModels = await this.readModelsFromCache()
if (cachedModels) {
// An empty cached array (e.g. written from a transient empty upstream
// response) must not be treated as valid data — fall through to a
// fresh fetch and, failing that, the fallback list.
if (cachedModels && cachedModels.length > 0) {
logger.info('[OllamaService] Using cached available models data')
return this.sortModels(cachedModels, sort)
}
@ -832,7 +835,7 @@ export class OllamaService {
const baseUrl = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
const fullUrl = new URL(NOMAD_MODELS_API_PATH, baseUrl).toString()
const response = await axios.get(fullUrl)
const response = await axios.get(fullUrl, { timeout: 10000 })
if (!response.data || !Array.isArray(response.data.models)) {
logger.warn(
`[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}`
@ -849,6 +852,16 @@ export class OllamaService {
}))
.filter((model) => model.tags.length > 0)
// A successful-but-empty upstream response (0 models, or all filtered out
// as cloud-only) is a soft failure: return null so the caller serves the
// fallback list, and don't poison the 24h cache with an empty array.
if (noCloud.length === 0) {
logger.warn(
'[OllamaService] Nomad API returned no usable (non-cloud) models; using fallback'
)
return null
}
await this.writeModelsToCache(noCloud)
return this.sortModels(noCloud, sort)
} catch (error) {

View File

@ -11,7 +11,7 @@ import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector'
import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert'
import { IconCheck, IconChevronDown, IconChevronUp, IconCpu, IconBooks } from '@tabler/icons-react'
import { IconCheck, IconCpu, IconBooks } from '@tabler/icons-react'
import StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
@ -81,30 +81,10 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] {
]
}
const ADDITIONAL_TOOLS: Capability[] = [
{
id: 'notes',
name: 'Notes',
technicalName: 'FlatNotes',
description: 'Simple note-taking app with local storage',
features: ['Markdown support', 'All notes stored locally', 'No account required'],
services: [SERVICE_NAMES.FLATNOTES],
icon: 'IconNotes',
},
{
id: 'datatools',
name: 'Data Tools',
technicalName: 'CyberChef',
description: 'Swiss Army knife for data encoding, encryption, and analysis',
features: [
'Encode/decode data (Base64, hex, etc.)',
'Encryption and hashing tools',
'Data format conversion',
],
services: [SERVICE_NAMES.CYBERCHEF],
icon: 'IconChefHat',
},
]
// Additional tools (Notes, Data Tools, and the rest of the catalog) are no
// longer surfaced in onboarding — they live in Supply Depot, where the full
// app catalog is browsable any time. Step 1 keeps the focus on the three core
// capabilities and points users to Supply Depot for everything else.
type WizardStep = 1 | 2 | 3 | 4 | 5
@ -123,14 +103,14 @@ export default function EasySetupWizard(props: {
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
// Auto-index policy for the AI Assistant Knowledge Base. Defaults to
// 'Always' so a new user who keeps the default behavior gets the "just
// works" experience — downloads become searchable automatically. Persisted
// to KVStore['rag.defaultIngestPolicy'] on wizard submit (same key #894's
// KB modal toggle reads/writes) so the JIT prompt at first chat sees a
// decided policy and doesn't ask again.
const [ingestPolicy, setIngestPolicy] = useState<'Always' | 'Manual'>('Always')
// 'Manual' ("Ask me first"): auto-indexing has real cost and resource
// implications a non-technical user won't anticipate from the toggle alone,
// so we default to the safe choice and let them opt in. Persisted to
// KVStore['rag.defaultIngestPolicy'] on wizard submit (same key #894's KB
// modal toggle reads/writes) so the JIT prompt at first chat sees a decided
// policy and doesn't ask again.
const [ingestPolicy, setIngestPolicy] = useState<'Always' | 'Manual'>('Manual')
const [isProcessing, setIsProcessing] = useState(false)
const [showAdditionalTools, setShowAdditionalTools] = useState(false)
const [remoteOllamaEnabled, setRemoteOllamaEnabled] = useState(
() => !!props.system.remoteOllamaUrl
)
@ -591,7 +571,7 @@ export default function EasySetupWizard(props: {
if (capability.id === 'ai' && isSelected) {
const hasAiSelections =
selectedAiModels.length > 0 ||
ingestPolicy !== 'Always' ||
ingestPolicy !== 'Manual' ||
remoteOllamaEnabled
if (hasAiSelections) {
const confirmed = window.confirm(
@ -600,7 +580,7 @@ export default function EasySetupWizard(props: {
if (!confirmed) return
}
setSelectedAiModels([])
setIngestPolicy('Always')
setIngestPolicy('Manual')
setRemoteOllamaEnabled(false)
setRemoteOllamaUrl('')
setRemoteOllamaUrlError(null)
@ -723,13 +703,11 @@ export default function EasySetupWizard(props: {
const renderStep1 = () => {
// Show all capabilities that exist in the system (including installed ones)
const existingCoreCapabilities = CORE_CAPABILITIES.filter(capabilityExists)
const existingAdditionalTools = ADDITIONAL_TOOLS.filter(capabilityExists)
// Check if ALL capabilities are already installed (nothing left to install)
const allCoreInstalled = existingCoreCapabilities.every(isCapabilityInstalled)
const allAdditionalInstalled = existingAdditionalTools.every(isCapabilityInstalled)
// Check if ALL core capabilities are already installed (nothing left to install)
const allInstalled =
allCoreInstalled && allAdditionalInstalled && existingCoreCapabilities.length > 0
existingCoreCapabilities.length > 0 &&
existingCoreCapabilities.every(isCapabilityInstalled)
return (
<div className="space-y-8">
@ -811,29 +789,29 @@ export default function EasySetupWizard(props: {
</div>
)}
{/* Additional Tools - Collapsible */}
{existingAdditionalTools.length > 0 && (
<div className="border-t border-desert-stone-light pt-6">
<button
onClick={() => setShowAdditionalTools(!showAdditionalTools)}
className="flex items-center justify-between w-full text-left"
{/* Everything beyond the core capabilities lives in Supply Depot,
the browsable app catalog. Keep onboarding focused and point
users there for Notes, Data Tools, and the rest. */}
<div className="border-t border-desert-stone-light pt-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 rounded-lg bg-surface-secondary p-4">
<div>
<h3 className="text-md font-medium text-text-primary mb-1">
Looking for more apps?
</h3>
<p className="text-sm text-text-secondary">
Notes, data tools, and the full catalog of add-on apps are available any time in
Supply Depot.
</p>
</div>
<StyledButton
variant="secondary"
onClick={() => router.visit('/supply-depot')}
className="flex-shrink-0"
>
<h3 className="text-md font-medium text-text-muted">Additional Tools</h3>
{showAdditionalTools ? (
<IconChevronUp size={20} className="text-text-muted" />
) : (
<IconChevronDown size={20} className="text-text-muted" />
)}
</button>
{showAdditionalTools && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{existingAdditionalTools.map((capability) =>
renderCapabilityCard(capability, false)
)}
</div>
)}
Open Supply Depot
</StyledButton>
</div>
)}
</div>
</>
)}
</div>
@ -849,6 +827,20 @@ export default function EasySetupWizard(props: {
regions later.
</p>
</div>
<div className="mx-auto max-w-2xl rounded-lg border border-border-subtle bg-surface-secondary p-3 text-center">
<p className="text-sm text-text-secondary">
Only need a specific country, or want the whole world? Individual countries and a full
global map can be installed any time from the{' '}
<button
type="button"
onClick={() => router.visit('/settings/maps')}
className="font-medium text-desert-green underline"
>
Maps Manager
</button>
.
</p>
</div>
{isLoadingMaps ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
@ -1161,9 +1153,9 @@ export default function EasySetupWizard(props: {
Capabilities to Install
</h3>
<ul className="space-y-2">
{[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS]
.filter((cap) => cap.services.some((s) => selectedServices.includes(s)))
.map((capability) => (
{CORE_CAPABILITIES.filter((cap) =>
cap.services.some((s) => selectedServices.includes(s))
).map((capability) => (
<li key={capability.id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-text-primary">
@ -1353,7 +1345,7 @@ export default function EasySetupWizard(props: {
<p className="text-sm text-text-secondary">
{(() => {
const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>
const count = CORE_CAPABILITIES.filter((cap) =>
cap.services.some((s) => selectedServices.includes(s))
).length
return `${count} ${count === 1 ? 'capability' : 'capabilities'}`