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> { ): Promise<{ models: NomadOllamaModel[]; hasMore: boolean } | null> {
try { try {
const models = await this.retrieveAndRefreshModels(sort, force) const models = await this.retrieveAndRefreshModels(sort, force)
if (!models) { if (!models || models.length === 0) {
logger.warn( logger.warn(
'[OllamaService] Returning fallback recommended models due to failure in fetching available models' '[OllamaService] Returning fallback recommended models due to failure in fetching available models'
) )
@ -819,7 +819,10 @@ export class OllamaService {
try { try {
if (!force) { if (!force) {
const cachedModels = await this.readModelsFromCache() 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') logger.info('[OllamaService] Using cached available models data')
return this.sortModels(cachedModels, sort) 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 baseUrl = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
const fullUrl = new URL(NOMAD_MODELS_API_PATH, baseUrl).toString() 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)) { if (!response.data || !Array.isArray(response.data.models)) {
logger.warn( logger.warn(
`[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}` `[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) .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) await this.writeModelsToCache(noCloud)
return this.sortModels(noCloud, sort) return this.sortModels(noCloud, sort)
} catch (error) { } catch (error) {

View File

@ -11,7 +11,7 @@ import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector' import WikipediaSelector from '~/components/WikipediaSelector'
import LoadingSpinner from '~/components/LoadingSpinner' import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert' 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 StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext' import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus' import useInternetStatus from '~/hooks/useInternetStatus'
@ -81,30 +81,10 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] {
] ]
} }
const ADDITIONAL_TOOLS: Capability[] = [ // 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
id: 'notes', // app catalog is browsable any time. Step 1 keeps the focus on the three core
name: 'Notes', // capabilities and points users to Supply Depot for everything else.
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',
},
]
type WizardStep = 1 | 2 | 3 | 4 | 5 type WizardStep = 1 | 2 | 3 | 4 | 5
@ -123,14 +103,14 @@ export default function EasySetupWizard(props: {
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([]) const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([]) const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
// Auto-index policy for the AI Assistant Knowledge Base. Defaults to // Auto-index policy for the AI Assistant Knowledge Base. Defaults to
// 'Always' so a new user who keeps the default behavior gets the "just // 'Manual' ("Ask me first"): auto-indexing has real cost and resource
// works" experience — downloads become searchable automatically. Persisted // implications a non-technical user won't anticipate from the toggle alone,
// to KVStore['rag.defaultIngestPolicy'] on wizard submit (same key #894's // so we default to the safe choice and let them opt in. Persisted to
// KB modal toggle reads/writes) so the JIT prompt at first chat sees a // KVStore['rag.defaultIngestPolicy'] on wizard submit (same key #894's KB
// decided policy and doesn't ask again. // modal toggle reads/writes) so the JIT prompt at first chat sees a decided
const [ingestPolicy, setIngestPolicy] = useState<'Always' | 'Manual'>('Always') // policy and doesn't ask again.
const [ingestPolicy, setIngestPolicy] = useState<'Always' | 'Manual'>('Manual')
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [showAdditionalTools, setShowAdditionalTools] = useState(false)
const [remoteOllamaEnabled, setRemoteOllamaEnabled] = useState( const [remoteOllamaEnabled, setRemoteOllamaEnabled] = useState(
() => !!props.system.remoteOllamaUrl () => !!props.system.remoteOllamaUrl
) )
@ -591,7 +571,7 @@ export default function EasySetupWizard(props: {
if (capability.id === 'ai' && isSelected) { if (capability.id === 'ai' && isSelected) {
const hasAiSelections = const hasAiSelections =
selectedAiModels.length > 0 || selectedAiModels.length > 0 ||
ingestPolicy !== 'Always' || ingestPolicy !== 'Manual' ||
remoteOllamaEnabled remoteOllamaEnabled
if (hasAiSelections) { if (hasAiSelections) {
const confirmed = window.confirm( const confirmed = window.confirm(
@ -600,7 +580,7 @@ export default function EasySetupWizard(props: {
if (!confirmed) return if (!confirmed) return
} }
setSelectedAiModels([]) setSelectedAiModels([])
setIngestPolicy('Always') setIngestPolicy('Manual')
setRemoteOllamaEnabled(false) setRemoteOllamaEnabled(false)
setRemoteOllamaUrl('') setRemoteOllamaUrl('')
setRemoteOllamaUrlError(null) setRemoteOllamaUrlError(null)
@ -723,13 +703,11 @@ export default function EasySetupWizard(props: {
const renderStep1 = () => { const renderStep1 = () => {
// Show all capabilities that exist in the system (including installed ones) // Show all capabilities that exist in the system (including installed ones)
const existingCoreCapabilities = CORE_CAPABILITIES.filter(capabilityExists) const existingCoreCapabilities = CORE_CAPABILITIES.filter(capabilityExists)
const existingAdditionalTools = ADDITIONAL_TOOLS.filter(capabilityExists)
// Check if ALL capabilities are already installed (nothing left to install) // Check if ALL core capabilities are already installed (nothing left to install)
const allCoreInstalled = existingCoreCapabilities.every(isCapabilityInstalled)
const allAdditionalInstalled = existingAdditionalTools.every(isCapabilityInstalled)
const allInstalled = const allInstalled =
allCoreInstalled && allAdditionalInstalled && existingCoreCapabilities.length > 0 existingCoreCapabilities.length > 0 &&
existingCoreCapabilities.every(isCapabilityInstalled)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -811,29 +789,29 @@ export default function EasySetupWizard(props: {
</div> </div>
)} )}
{/* Additional Tools - Collapsible */} {/* Everything beyond the core capabilities lives in Supply Depot,
{existingAdditionalTools.length > 0 && ( the browsable app catalog. Keep onboarding focused and point
<div className="border-t border-desert-stone-light pt-6"> users there for Notes, Data Tools, and the rest. */}
<button <div className="border-t border-desert-stone-light pt-6">
onClick={() => setShowAdditionalTools(!showAdditionalTools)} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 rounded-lg bg-surface-secondary p-4">
className="flex items-center justify-between w-full text-left" <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> Open Supply Depot
{showAdditionalTools ? ( </StyledButton>
<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>
)}
</div> </div>
)} </div>
</> </>
)} )}
</div> </div>
@ -849,6 +827,20 @@ export default function EasySetupWizard(props: {
regions later. regions later.
</p> </p>
</div> </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 ? ( {isLoadingMaps ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<LoadingSpinner /> <LoadingSpinner />
@ -1161,9 +1153,9 @@ export default function EasySetupWizard(props: {
Capabilities to Install Capabilities to Install
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
{[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS] {CORE_CAPABILITIES.filter((cap) =>
.filter((cap) => cap.services.some((s) => selectedServices.includes(s))) cap.services.some((s) => selectedServices.includes(s))
.map((capability) => ( ).map((capability) => (
<li key={capability.id} className="flex items-center"> <li key={capability.id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" /> <IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-text-primary"> <span className="text-text-primary">
@ -1353,7 +1345,7 @@ export default function EasySetupWizard(props: {
<p className="text-sm text-text-secondary"> <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)) cap.services.some((s) => selectedServices.includes(s))
).length ).length
return `${count} ${count === 1 ? 'capability' : 'capabilities'}` return `${count} ${count === 1 ? 'capability' : 'capabilities'}`