From 15ee9220a8a269d3d237e56475968a39abbf8dbd Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:29:38 +0530 Subject: [PATCH] Local AI: unified preset bootstrap, first-run Home flow, and resilient model pulls (#304) * feat(local-ai): unify preset bootstrap, first-run Home flow, and pull retries - Add localAiBootstrap helpers (preset apply + asset download with retries) - Expose selected_tier from local_ai.presets; apply recommended tier in core bootstrap when unset - Extend Ollama pull and large-asset HTTP timeouts; retry interrupted pulls - Wire Home/onboarding to unified bootstrap; improve download snackbar vs status - Add Vitest/Rust tests for bootstrap and UI flows * fix: synchronize Local AI progress UI and add error logging for manual bootstrap attempts * refactor(tests): streamline skill installation tests by removing HTTP server setup - Simplified test_skill_install_creates_files and test_skill_install_checksum_verification by replacing the HTTP server setup with direct file creation using tempfile. - Updated download and manifest URLs to point to local file paths instead of server endpoints, enhancing test reliability and performance. * feat(home): implement retry logic for local AI bootstrap attempts - Added a mechanism to track and limit the number of bootstrap attempts for local AI initialization in the Home component. - Introduced a constant for maximum attempts and updated error handling to log failures after exceeding the limit, improving robustness during the first-run process. --- .../components/LocalAIDownloadSnackbar.tsx | 42 ++-- .../LocalAIDownloadSnackbar.test.tsx | 28 +++ .../settings/panels/LocalModelPanel.tsx | 78 +------ app/src/pages/Home.tsx | 167 ++++++++------ app/src/pages/__tests__/Home.test.tsx | 134 +++++++++++ app/src/pages/onboarding/Onboarding.tsx | 27 +-- .../pages/onboarding/steps/LocalAIStep.tsx | 28 +-- .../steps/__tests__/LocalAIStep.test.tsx | 46 ++-- .../utils/__tests__/localAiBootstrap.test.ts | 86 +++++++ app/src/utils/localAiBootstrap.ts | 111 +++++++++ app/src/utils/tauriCommands.ts | 1 + src/openhuman/local_ai/schemas.rs | 8 + src/openhuman/local_ai/service/assets.rs | 3 + src/openhuman/local_ai/service/bootstrap.rs | 126 +++++++++-- .../local_ai/service/ollama_admin.rs | 210 ++++++++++++------ src/openhuman/skills/registry_ops.rs | 82 +++---- 16 files changed, 820 insertions(+), 357 deletions(-) create mode 100644 app/src/pages/__tests__/Home.test.tsx create mode 100644 app/src/utils/__tests__/localAiBootstrap.test.ts create mode 100644 app/src/utils/localAiBootstrap.ts diff --git a/app/src/components/LocalAIDownloadSnackbar.tsx b/app/src/components/LocalAIDownloadSnackbar.tsx index 4f7abbe17..7733d663c 100644 --- a/app/src/components/LocalAIDownloadSnackbar.tsx +++ b/app/src/components/LocalAIDownloadSnackbar.tsx @@ -5,6 +5,7 @@ import { formatBytes, formatEta, progressFromDownloads, + progressFromStatus, statusLabel, } from '../utils/localAiHelpers'; import { @@ -60,32 +61,47 @@ const LocalAIDownloadSnackbar = () => { return () => clearInterval(timerRef.current); }, [tauriAvailable]); + const downloadState = downloads?.state; + const currentState = + downloadState === 'loading' || downloadState === 'downloading' || downloadState === 'installing' + ? downloadState + : (status?.state ?? downloadState ?? 'idle'); const isDownloading = - status?.state === 'downloading' || - status?.state === 'installing' || - downloads?.state === 'downloading' || + currentState === 'loading' || + currentState === 'downloading' || + currentState === 'installing' || (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1); // Auto-show when a new download starts: track prior state in a ref and // reset dismissed on the transition edge (not-downloading → downloading). const wasDownloadingRef = useRef(false); - if (isDownloading && !wasDownloadingRef.current && dismissed) { - setDismissed(false); - } - wasDownloadingRef.current = !!isDownloading; + useEffect(() => { + if (isDownloading && !wasDownloadingRef.current && dismissed) { + setDismissed(false); + } + wasDownloadingRef.current = !!isDownloading; + }, [dismissed, isDownloading]); const handleDismiss = useCallback(() => setDismissed(true), []); const handleToggleCollapse = useCallback(() => setCollapsed(prev => !prev), []); if (!tauriAvailable || !isDownloading || dismissed) return null; - const progress = progressFromDownloads(downloads); + // Use currentState as the source of truth for the fallback sentinel so the + // label (derived from currentState) and the progress bar stay in sync. + // We still forward download_progress from status so a real numeric value + // isn't lost when the downloads object has no progress field. + // When status is absent, progressFromStatus(null) returns 0, which is the + // correct baseline while data hasn't arrived yet. + const statusForProgress: LocalAiStatus | null = status + ? { ...status, state: currentState } + : null; + const progress = progressFromDownloads(downloads) ?? progressFromStatus(statusForProgress); const percent = progress != null ? Math.round(progress * 100) : null; - const speed = downloads?.speed_bps; - const eta = downloads?.eta_seconds; - const downloaded = downloads?.downloaded_bytes; - const total = downloads?.total_bytes; - const currentState = downloads?.state ?? status?.state ?? 'downloading'; + const speed = downloads?.speed_bps ?? status?.download_speed_bps; + const eta = downloads?.eta_seconds ?? status?.eta_seconds; + const downloaded = downloads?.downloaded_bytes ?? status?.downloaded_bytes; + const total = downloads?.total_bytes ?? status?.total_bytes; const label = statusLabel(currentState); const isInstallingPhase = currentState === 'installing'; const phaseDetail = downloads?.warning ?? status?.warning; diff --git a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx index 3c397b096..8caf2bde8 100644 --- a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx +++ b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx @@ -41,4 +41,32 @@ describe('LocalAIDownloadSnackbar', () => { // Reset mock vi.mocked(tauriCommands.isTauri).mockReturnValue(false); }); + + it('renders immediately when status reports bootstrap activity before downloads progress catches up', async () => { + const tauriCommands = await import('../../utils/tauriCommands'); + vi.mocked(tauriCommands.isTauri).mockReturnValue(true); + vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ + result: { + state: 'loading', + download_progress: 0.42, + downloaded_bytes: 512 * 1024 * 1024, + total_bytes: 1024 * 1024 * 1024, + warning: 'Connecting to local Ollama runtime', + } as never, + logs: [], + }); + vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockResolvedValue({ + result: { state: 'idle', progress: null } as never, + logs: [], + }); + + renderWithProviders(); + + await vi.waitFor(() => { + expect(screen.getByText('Loading model...')).toBeInTheDocument(); + expect(screen.getByText('512 MB / 1.0 GB')).toBeInTheDocument(); + }); + + vi.mocked(tauriCommands.isTauri).mockReturnValue(false); + }); }); diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx index 3cb412820..8ad8dfbd3 100644 --- a/app/src/components/settings/panels/LocalModelPanel.tsx +++ b/app/src/components/settings/panels/LocalModelPanel.tsx @@ -1,5 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; +import { + formatBytes, + formatEta, + progressFromDownloads, + progressFromStatus, + statusLabel, +} from '../../../utils/localAiHelpers'; import { type ApplyPresetResult, type LocalAiAssetsStatus, @@ -32,27 +39,6 @@ import { import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -const statusLabel = (state: string): string => { - switch (state) { - case 'ready': - return 'Ready'; - case 'downloading': - return 'Downloading'; - case 'installing': - return 'Installing Runtime'; - case 'loading': - return 'Loading'; - case 'degraded': - return 'Needs Attention'; - case 'disabled': - return 'Disabled'; - case 'idle': - return 'Idle'; - default: - return state; - } -}; - const statusTone = (state: string): string => { switch (state) { case 'ready': @@ -70,56 +56,6 @@ const statusTone = (state: string): string => { } }; -const progressFromStatus = (status: LocalAiStatus | null): number => { - if (!status) return 0; - if (typeof status.download_progress === 'number') { - return Math.max(0, Math.min(1, status.download_progress)); - } - switch (status.state) { - case 'ready': - return 1; - case 'loading': - return 0.92; - case 'downloading': - return 0.25; - case 'installing': - return 0.1; - case 'idle': - return 0; - default: - return 0; - } -}; - -const progressFromDownloads = (downloads: LocalAiDownloadsProgress | null): number | null => { - if (!downloads) return null; - if (typeof downloads.progress !== 'number') return null; - return Math.max(0, Math.min(1, downloads.progress)); -}; - -const formatBytes = (bytes?: number | null): string => { - if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return '0 B'; - if (bytes < 1024) return `${Math.round(bytes)} B`; - const units = ['KB', 'MB', 'GB', 'TB']; - let value = bytes / 1024; - let unit = units[0]; - for (let i = 1; i < units.length && value >= 1024; i += 1) { - value /= 1024; - unit = units[i]; - } - return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`; -}; - -const formatEta = (etaSeconds?: number | null): string => { - if (typeof etaSeconds !== 'number' || !Number.isFinite(etaSeconds) || etaSeconds <= 0) { - return ''; - } - const mins = Math.floor(etaSeconds / 60); - const secs = etaSeconds % 60; - if (mins <= 0) return `${secs}s`; - return `${mins}m ${secs.toString().padStart(2, '0')}s`; -}; - const LocalModelPanel = () => { const { navigateBack } = useSettingsNavigation(); const [status, setStatus] = useState(null); diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 0b66e2c08..2c6c4ae0b 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -4,55 +4,12 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; import { useUser } from '../hooks/useUser'; import { - isTauri, - type LocalAiStatus, - openhumanLocalAiDownload, - openhumanLocalAiStatus, -} from '../utils/tauriCommands'; - -const progressFromStatus = (status: LocalAiStatus | null): number => { - if (!status) return 0; - if (typeof status.download_progress === 'number') { - return Math.max(0, Math.min(1, status.download_progress)); - } - switch (status.state) { - case 'ready': - return 1; - case 'loading': - return 0.92; - case 'downloading': - return 0.25; - case 'installing': - return 0.1; - case 'idle': - return 0; - default: - return 0; - } -}; - -const formatBytes = (bytes?: number | null): string => { - if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return '0 B'; - if (bytes < 1024) return `${Math.round(bytes)} B`; - const units = ['KB', 'MB', 'GB', 'TB']; - let value = bytes / 1024; - let unit = units[0]; - for (let i = 1; i < units.length && value >= 1024; i += 1) { - value /= 1024; - unit = units[i]; - } - return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`; -}; - -const formatEta = (etaSeconds?: number | null): string => { - if (typeof etaSeconds !== 'number' || !Number.isFinite(etaSeconds) || etaSeconds <= 0) { - return ''; - } - const mins = Math.floor(etaSeconds / 60); - const secs = etaSeconds % 60; - if (mins <= 0) return `${secs}s`; - return `${mins}m ${secs.toString().padStart(2, '0')}s`; -}; + bootstrapLocalAiWithRecommendedPreset, + ensureRecommendedLocalAiPresetIfNeeded, + triggerLocalAiAssetBootstrap, +} from '../utils/localAiBootstrap'; +import { formatBytes, formatEta, progressFromStatus } from '../utils/localAiHelpers'; +import { isTauri, type LocalAiStatus, openhumanLocalAiStatus } from '../utils/tauriCommands'; const Home = () => { const { user } = useUser(); @@ -61,6 +18,10 @@ const Home = () => { const [localAiStatus, setLocalAiStatus] = useState(null); const [downloadBusy, setDownloadBusy] = useState(false); const autoRetryDoneRef = useRef(false); + const initialBootstrapHandledRef = useRef(false); + const initialBootstrapInFlightRef = useRef(false); + const initialBootstrapPendingDownloadRef = useRef(false); + const initialBootstrapAttemptsRef = useRef(0); // Get greeting based on time const getGreeting = () => { @@ -75,21 +36,103 @@ const Home = () => { navigate('/conversations'); }; + const refreshLocalAiStatus = async () => { + const status = await openhumanLocalAiStatus(); + setLocalAiStatus(status.result); + return status.result; + }; + + const runManualBootstrap = async (force: boolean) => { + setDownloadBusy(true); + try { + await bootstrapLocalAiWithRecommendedPreset( + force, + force ? '[Home re-bootstrap]' : '[Home manual bootstrap]' + ); + await refreshLocalAiStatus(); + } catch (error) { + console.warn('[Home] manual Local AI bootstrap failed:', error); + } finally { + setDownloadBusy(false); + } + }; + useEffect(() => { if (!isTauri()) return; + const MAX_INITIAL_BOOTSTRAP_ATTEMPTS = 3; let mounted = true; const load = async () => { try { const status = await openhumanLocalAiStatus(); if (mounted) { setLocalAiStatus(status.result); + // Auto-retry bootstrap once if Ollama is degraded (install/server issue). if (status.result?.state === 'degraded' && !autoRetryDoneRef.current) { autoRetryDoneRef.current = true; - void openhumanLocalAiDownload(true).catch(() => {}); + console.debug('[Home] local AI is degraded; scheduling a one-time re-bootstrap'); + void bootstrapLocalAiWithRecommendedPreset(true, '[Home degraded auto-retry]').catch( + error => { + autoRetryDoneRef.current = false; + console.warn('[Home] degraded local AI re-bootstrap failed:', error); + } + ); + } + + if ( + status.result?.state === 'idle' && + !initialBootstrapHandledRef.current && + !initialBootstrapInFlightRef.current + ) { + initialBootstrapInFlightRef.current = true; + console.debug('[Home] local AI is idle; checking first-run preset selection'); + void ensureRecommendedLocalAiPresetIfNeeded('[Home first-run]') + .then(async preset => { + const shouldTriggerBootstrap = + !preset.hadSelectedTier || initialBootstrapPendingDownloadRef.current; + + if (!shouldTriggerBootstrap) { + console.debug( + '[Home] skipping automatic first-run bootstrap because a tier is already selected' + ); + initialBootstrapHandledRef.current = true; + return; + } + + initialBootstrapPendingDownloadRef.current = true; + console.debug( + '[Home] selected recommended preset for first-run bootstrap', + JSON.stringify({ + recommendedTier: preset.recommendedTier, + hadSelectedTier: preset.hadSelectedTier, + }) + ); + await triggerLocalAiAssetBootstrap(false, '[Home first-run]'); + initialBootstrapPendingDownloadRef.current = false; + initialBootstrapHandledRef.current = true; + initialBootstrapAttemptsRef.current = 0; + }) + .catch(error => { + initialBootstrapAttemptsRef.current += 1; + const attempts = initialBootstrapAttemptsRef.current; + if (attempts >= MAX_INITIAL_BOOTSTRAP_ATTEMPTS) { + initialBootstrapPendingDownloadRef.current = false; + initialBootstrapHandledRef.current = true; + console.warn( + '[Home] first-run local AI bootstrap failed permanently; stopping retries', + { attempts, error } + ); + return; + } + console.warn('[Home] first-run local AI bootstrap failed:', error); + }) + .finally(() => { + initialBootstrapInFlightRef.current = false; + }); } } - } catch { + } catch (error) { + console.warn('[Home] failed to load local AI status:', error); if (mounted) setLocalAiStatus(null); } }; @@ -234,31 +277,13 @@ const Home = () => {