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 = () => {