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.
This commit is contained in:
YellowSnnowmann
2026-04-03 20:29:38 +05:30
committed by GitHub
parent cc55323025
commit 15ee9220a8
16 changed files with 820 additions and 357 deletions
+29 -13
View File
@@ -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;
@@ -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(<LocalAIDownloadSnackbar />);
await vi.waitFor(() => {
expect(screen.getByText('Loading model...')).toBeInTheDocument();
expect(screen.getByText('512 MB / 1.0 GB')).toBeInTheDocument();
});
vi.mocked(tauriCommands.isTauri).mockReturnValue(false);
});
});
@@ -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<LocalAiStatus | null>(null);
+96 -71
View File
@@ -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<LocalAiStatus | null>(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 = () => {
<div className="mt-2 flex items-center gap-2">
<button
onClick={async () => {
setDownloadBusy(true);
try {
await openhumanLocalAiDownload(false);
const status = await openhumanLocalAiStatus();
setLocalAiStatus(status.result);
} finally {
setDownloadBusy(false);
}
}}
onClick={() => void runManualBootstrap(false)}
disabled={downloadBusy}
className="rounded-md bg-blue-600 px-2.5 py-1.5 text-[11px] font-medium text-white hover:bg-blue-700 disabled:opacity-60">
{downloadBusy ? 'Working...' : 'Bootstrap'}
</button>
<button
onClick={async () => {
setDownloadBusy(true);
try {
await openhumanLocalAiDownload(true);
const status = await openhumanLocalAiStatus();
setLocalAiStatus(status.result);
} finally {
setDownloadBusy(false);
}
}}
onClick={() => void runManualBootstrap(true)}
disabled={downloadBusy}
className="rounded-md border border-stone-600 px-2.5 py-1.5 text-[11px] font-medium text-stone-200 hover:border-stone-500 disabled:opacity-60">
Re-bootstrap
+134
View File
@@ -0,0 +1,134 @@
import { waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Home from '../Home';
vi.mock('../../components/ConnectionIndicator', () => ({
default: () => <div>Connection Indicator</div>,
}));
vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Shrey' } }) }));
vi.mock('../../utils/localAiBootstrap', () => ({
bootstrapLocalAiWithRecommendedPreset: vi.fn(),
ensureRecommendedLocalAiPresetIfNeeded: vi.fn(),
triggerLocalAiAssetBootstrap: vi.fn(),
}));
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
}));
describe('Home local AI bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('auto-applies the recommended preset and starts bootstrap on first-run idle state', async () => {
const bootstrapUtils = await import('../../utils/localAiBootstrap');
const tauriCommands = await import('../../utils/tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: null,
hadSelectedTier: false,
appliedTier: 'high',
});
vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap).mockResolvedValue({
result: { state: 'downloading', progress: 0 } as never,
logs: [],
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith(
'[Home first-run]'
);
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledWith(
false,
'[Home first-run]'
);
});
});
it('does not auto-bootstrap when a tier is already selected', async () => {
const bootstrapUtils = await import('../../utils/localAiBootstrap');
const tauriCommands = await import('../../utils/tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:12b-it-q4_K_M' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith(
'[Home first-run]'
);
});
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).not.toHaveBeenCalled();
});
it('retries the first-run bootstrap trigger after preset application if the first trigger attempt fails', async () => {
const bootstrapUtils = await import('../../utils/localAiBootstrap');
const tauriCommands = await import('../../utils/tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded)
.mockResolvedValueOnce({
presets: {} as never,
recommendedTier: 'high',
selectedTier: null,
hadSelectedTier: false,
appliedTier: 'high',
})
.mockResolvedValueOnce({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap)
.mockRejectedValueOnce(new Error('transient failure'))
.mockResolvedValueOnce({ result: { state: 'downloading', progress: 0 } as never, logs: [] });
renderWithProviders(<Home />);
await waitFor(
() => {
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledTimes(2);
},
{ timeout: 3000 }
);
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith(
1,
false,
'[Home first-run]'
);
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith(
2,
false,
'[Home first-run]'
);
});
});
+9 -18
View File
@@ -6,11 +6,8 @@ import { useUser } from '../../hooks/useUser';
import { userApi } from '../../services/api/userApi';
import { setOnboardedForUser, setOnboardingTasksForUser } from '../../store/authSlice';
import { useAppDispatch } from '../../store/hooks';
import {
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
setOnboardingCompleted,
} from '../../utils/tauriCommands';
import { bootstrapLocalAiWithRecommendedPreset } from '../../utils/localAiBootstrap';
import { setOnboardingCompleted } from '../../utils/tauriCommands';
import LocalAIStep from './steps/LocalAIStep';
import ScreenPermissionsStep from './steps/ScreenPermissionsStep';
import SkillsStep from './steps/SkillsStep';
@@ -67,20 +64,14 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
retryInFlightRef.current = true;
console.debug('[Onboarding] User retrying Local AI download');
setDownloadError(null);
let errorReported = false;
const reportError = (source: string, err: unknown) => {
console.warn(`[Onboarding] Retry download failed (${source}):`, err);
if (!errorReported) {
errorReported = true;
void bootstrapLocalAiWithRecommendedPreset(false, '[Onboarding retry]')
.catch((err: unknown) => {
console.warn('[Onboarding] Retry download failed:', err);
setDownloadError('Local AI setup encountered an issue');
}
};
void Promise.allSettled([
openhumanLocalAiDownload(false).catch((err: unknown) => reportError('ollama', err)),
openhumanLocalAiDownloadAllAssets(false).catch((err: unknown) => reportError('assets', err)),
]).finally(() => {
retryInFlightRef.current = false;
});
})
.finally(() => {
retryInFlightRef.current = false;
});
}, []);
const handleNext = () => {
+7 -21
View File
@@ -1,9 +1,6 @@
import { useCallback, useRef } from 'react';
import {
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
} from '../../../utils/tauriCommands';
import { bootstrapLocalAiWithRecommendedPreset } from '../../../utils/localAiBootstrap';
import OnboardingNextButton from '../components/OnboardingNextButton';
/* ---------- component ---------- */
@@ -16,28 +13,17 @@ interface LocalAIStepProps {
const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepProps) => {
const downloadStartedRef = useRef(false);
// Tracks whether onDownloadError has already been called for this download attempt,
// so that two concurrent failures don't fire the callback twice.
const errorReportedRef = useRef(false);
const handleConsent = useCallback(() => {
if (downloadStartedRef.current) return;
downloadStartedRef.current = true;
errorReportedRef.current = false;
console.debug('[LocalAIStep] starting background Local AI bootstrap after consent');
const reportError = (source: string, err: unknown) => {
console.warn(`[LocalAIStep] Download failed (${source}):`, err);
if (!errorReportedRef.current) {
errorReportedRef.current = true;
onDownloadError?.('Local AI setup encountered an issue');
}
};
// Fire-and-forget: start downloads in the background — the global snackbar tracks progress
void openhumanLocalAiDownload(false).catch((err: unknown) => reportError('ollama', err));
void openhumanLocalAiDownloadAllAssets(false).catch((err: unknown) =>
reportError('assets', err)
);
// Fire-and-forget: start bootstrap in the background — the global snackbar tracks progress.
void bootstrapLocalAiWithRecommendedPreset(false, '[LocalAIStep]').catch((err: unknown) => {
console.warn('[LocalAIStep] Local AI bootstrap failed:', err);
onDownloadError?.('Local AI setup encountered an issue');
});
// Advance to next step immediately
onNext({ consentGiven: true, downloadStarted: true });
@@ -1,15 +1,18 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import LocalAIStep from '../LocalAIStep';
vi.mock('../../../../utils/tauriCommands', () => ({
openhumanLocalAiDownload: vi.fn().mockResolvedValue({} as never),
openhumanLocalAiDownloadAllAssets: vi.fn().mockResolvedValue({} as never),
vi.mock('../../../../utils/localAiBootstrap', () => ({
bootstrapLocalAiWithRecommendedPreset: vi.fn().mockResolvedValue({} as never),
}));
describe('LocalAIStep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('happy path: advances immediately and calls onNext with correct payload', async () => {
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
@@ -20,9 +23,12 @@ describe('LocalAIStep', () => {
expect(onNext).toHaveBeenCalledWith({ consentGiven: true, downloadStarted: true });
});
it('error path: calls onDownloadError once when openhumanLocalAiDownload rejects', async () => {
const { openhumanLocalAiDownload } = await import('../../../../utils/tauriCommands');
vi.mocked(openhumanLocalAiDownload).mockRejectedValueOnce(new Error('network error'));
it('error path: calls onDownloadError once when bootstrap fails', async () => {
const { bootstrapLocalAiWithRecommendedPreset } =
await import('../../../../utils/localAiBootstrap');
vi.mocked(bootstrapLocalAiWithRecommendedPreset).mockRejectedValueOnce(
new Error('network error')
);
const onNext = vi.fn();
const onDownloadError = vi.fn();
@@ -40,28 +46,23 @@ describe('LocalAIStep', () => {
expect(onDownloadError).toHaveBeenCalledWith('Local AI setup encountered an issue');
});
it('error path: calls onDownloadError only once even if both downloads fail', async () => {
const { openhumanLocalAiDownload, openhumanLocalAiDownloadAllAssets } =
await import('../../../../utils/tauriCommands');
vi.mocked(openhumanLocalAiDownload).mockRejectedValueOnce(new Error('fail 1'));
vi.mocked(openhumanLocalAiDownloadAllAssets).mockRejectedValueOnce(new Error('fail 2'));
it('starts the recommended-preset bootstrap flow once', async () => {
const { bootstrapLocalAiWithRecommendedPreset } =
await import('../../../../utils/localAiBootstrap');
const onNext = vi.fn();
const onDownloadError = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} onDownloadError={onDownloadError} />);
renderWithProviders(<LocalAIStep onNext={onNext} />);
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
await waitFor(() => {
expect(onDownloadError).toHaveBeenCalledOnce();
});
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledOnce();
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledWith(false, '[LocalAIStep]');
});
it('double-click guard: download functions called only once', async () => {
const { openhumanLocalAiDownload, openhumanLocalAiDownloadAllAssets } =
await import('../../../../utils/tauriCommands');
vi.mocked(openhumanLocalAiDownload).mockResolvedValue({} as never);
vi.mocked(openhumanLocalAiDownloadAllAssets).mockResolvedValue({} as never);
const { bootstrapLocalAiWithRecommendedPreset } =
await import('../../../../utils/localAiBootstrap');
vi.mocked(bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({} as never);
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
@@ -71,7 +72,6 @@ describe('LocalAIStep', () => {
fireEvent.click(button);
expect(onNext).toHaveBeenCalledOnce();
expect(openhumanLocalAiDownload).toHaveBeenCalledOnce();
expect(openhumanLocalAiDownloadAllAssets).toHaveBeenCalledOnce();
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
bootstrapLocalAiWithRecommendedPreset,
ensureRecommendedLocalAiPresetIfNeeded,
} from '../localAiBootstrap';
vi.mock('../tauriCommands', () => ({
openhumanLocalAiApplyPreset: vi.fn(),
openhumanLocalAiDownloadAllAssets: vi.fn(),
openhumanLocalAiPresets: vi.fn(),
}));
describe('localAiBootstrap', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('applies the recommended preset before starting background downloads when no tier is selected', async () => {
const tauriCommands = await import('../tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiPresets).mockResolvedValue({
presets: [],
recommended_tier: 'high',
current_tier: 'medium',
selected_tier: null,
device: {
total_ram_bytes: 32 * 1024 * 1024 * 1024,
cpu_count: 8,
cpu_brand: 'Test CPU',
os_name: 'macOS',
os_version: '15',
has_gpu: true,
gpu_description: 'Test GPU',
},
});
vi.mocked(tauriCommands.openhumanLocalAiApplyPreset).mockResolvedValue({
applied_tier: 'high',
chat_model_id: 'gemma3:12b-it-q4_K_M',
vision_model_id: 'gemma3:12b-it-q4_K_M',
embedding_model_id: 'nomic-embed-text:latest',
quantization: 'q4_K_M',
});
vi.mocked(tauriCommands.openhumanLocalAiDownloadAllAssets).mockResolvedValue({
result: { state: 'downloading', progress: 0 } as never,
logs: [],
});
const result = await bootstrapLocalAiWithRecommendedPreset(false, '[test]');
expect(tauriCommands.openhumanLocalAiPresets).toHaveBeenCalledOnce();
expect(tauriCommands.openhumanLocalAiApplyPreset).toHaveBeenCalledWith('high');
expect(tauriCommands.openhumanLocalAiDownloadAllAssets).toHaveBeenCalledWith(false);
expect(
vi.mocked(tauriCommands.openhumanLocalAiApplyPreset).mock.invocationCallOrder[0]
).toBeLessThan(
vi.mocked(tauriCommands.openhumanLocalAiDownloadAllAssets).mock.invocationCallOrder[0]
);
expect(result.preset.hadSelectedTier).toBe(false);
expect(result.preset.appliedTier).toBe('high');
});
it('skips preset application when a tier is already selected', async () => {
const tauriCommands = await import('../tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiPresets).mockResolvedValue({
presets: [],
recommended_tier: 'medium',
current_tier: 'high',
selected_tier: 'high',
device: {
total_ram_bytes: 32 * 1024 * 1024 * 1024,
cpu_count: 8,
cpu_brand: 'Test CPU',
os_name: 'macOS',
os_version: '15',
has_gpu: true,
gpu_description: 'Test GPU',
},
});
const result = await ensureRecommendedLocalAiPresetIfNeeded('[test]');
expect(tauriCommands.openhumanLocalAiApplyPreset).not.toHaveBeenCalled();
expect(result.hadSelectedTier).toBe(true);
expect(result.selectedTier).toBe('high');
});
});
+111
View File
@@ -0,0 +1,111 @@
import {
openhumanLocalAiApplyPreset,
openhumanLocalAiDownloadAllAssets,
openhumanLocalAiPresets,
type PresetsResponse,
} from './tauriCommands';
const MAX_RETRIES = 5;
const RETRY_BASE_DELAY_MS = 250;
const wait = (ms: number) =>
new Promise<void>(resolve => {
setTimeout(resolve, ms);
});
const normalizeSelectedTier = (tier: string | null | undefined): string | null => {
if (typeof tier !== 'string') return null;
const normalized = tier.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
};
const retryLocalAiCommand = async <T>(
label: string,
run: () => Promise<T>,
logPrefix: string
): Promise<T> => {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) {
try {
return await run();
} catch (error) {
lastError = error;
if (attempt === MAX_RETRIES) {
break;
}
console.debug(
`${logPrefix} ${label} failed on attempt ${attempt}/${MAX_RETRIES}; retrying after core warm-up`,
error
);
await wait(RETRY_BASE_DELAY_MS * attempt);
}
}
throw lastError instanceof Error ? lastError : new Error(`Failed to ${label}`);
};
export interface LocalAiPresetResolution {
presets: PresetsResponse;
recommendedTier: string;
selectedTier: string | null;
hadSelectedTier: boolean;
appliedTier: string | null;
}
export const ensureRecommendedLocalAiPresetIfNeeded = async (
logPrefix = '[local-ai-bootstrap]'
): Promise<LocalAiPresetResolution> => {
const presets = await retryLocalAiCommand(
'load local AI presets',
() => openhumanLocalAiPresets(),
logPrefix
);
const selectedTier = normalizeSelectedTier(presets.selected_tier);
const recommendedTier = presets.recommended_tier;
if (selectedTier) {
console.debug(
`${logPrefix} keeping existing local AI preset`,
JSON.stringify({ selectedTier, currentTier: presets.current_tier, recommendedTier })
);
return { presets, recommendedTier, selectedTier, hadSelectedTier: true, appliedTier: null };
}
console.debug(
`${logPrefix} applying recommended local AI preset`,
JSON.stringify({ recommendedTier })
);
await retryLocalAiCommand(
'apply recommended local AI preset',
() => openhumanLocalAiApplyPreset(recommendedTier),
logPrefix
);
return {
presets: { ...presets, current_tier: recommendedTier, selected_tier: recommendedTier },
recommendedTier,
selectedTier: null,
hadSelectedTier: false,
appliedTier: recommendedTier,
};
};
export const triggerLocalAiAssetBootstrap = async (
force = false,
logPrefix = '[local-ai-bootstrap]'
) => {
console.debug(`${logPrefix} triggering local AI background bootstrap`, JSON.stringify({ force }));
return await retryLocalAiCommand(
force ? 're-bootstrap local AI assets' : 'bootstrap local AI assets',
() => openhumanLocalAiDownloadAllAssets(force),
logPrefix
);
};
export const bootstrapLocalAiWithRecommendedPreset = async (
force = false,
logPrefix = '[local-ai-bootstrap]'
) => {
const preset = await ensureRecommendedLocalAiPresetIfNeeded(logPrefix);
const download = await triggerLocalAiAssetBootstrap(force, logPrefix);
return { preset, download };
};
+1
View File
@@ -1491,6 +1491,7 @@ export interface PresetsResponse {
presets: ModelPresetResult[];
recommended_tier: string;
current_tier: string;
selected_tier?: string | null;
device: DeviceProfileResult;
}
+8
View File
@@ -768,10 +768,17 @@ fn handle_local_ai_presets(_params: Map<String, Value>) -> ControllerFuture {
let recommended = crate::openhuman::local_ai::presets::recommend_tier(&device);
let current =
crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai);
let selected_tier = config
.local_ai
.selected_tier
.as_ref()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty());
let presets = crate::openhuman::local_ai::presets::all_presets();
tracing::debug!(
?recommended,
?current,
selected_tier = ?selected_tier,
preset_count = presets.len(),
"[local_ai] presets: returning"
);
@@ -779,6 +786,7 @@ fn handle_local_ai_presets(_params: Map<String, Value>) -> ControllerFuture {
"presets": presets,
"recommended_tier": recommended,
"current_tier": current,
"selected_tier": selected_tier,
"device": device,
});
Ok(value)
+3
View File
@@ -378,6 +378,9 @@ impl LocalAiService {
let response = self
.http
.get(url)
// Large model assets (STT/TTS) can take minutes on slower links.
// Avoid inheriting the short default client timeout for these streams.
.timeout(std::time::Duration::from_secs(30 * 60))
.send()
.await
.map_err(|e| format!("failed to start {label} download: {e}"))?;
+104 -22
View File
@@ -1,4 +1,5 @@
use crate::openhuman::config::Config;
use crate::openhuman::local_ai::device::DeviceProfile;
use crate::openhuman::local_ai::model_ids;
use crate::openhuman::local_ai::types::LocalAiStatus;
@@ -97,8 +98,11 @@ impl LocalAiService {
pub async fn bootstrap(&self, config: &Config) {
let _guard = self.bootstrap_lock.lock().await;
if !config.local_ai.enabled {
*self.status.lock() = LocalAiStatus::disabled(config);
let device = crate::openhuman::local_ai::device::detect_device_profile();
let effective_config = config_with_recommended_tier_if_unselected(config, &device);
if !effective_config.local_ai.enabled {
*self.status.lock() = LocalAiStatus::disabled(&effective_config);
return;
}
@@ -112,13 +116,13 @@ impl LocalAiService {
{
let mut status = self.status.lock();
status.model_id = model_ids::effective_chat_model_id(config);
status.chat_model_id = model_ids::effective_chat_model_id(config);
status.vision_model_id = model_ids::effective_vision_model_id(config);
status.embedding_model_id = model_ids::effective_embedding_model_id(config);
status.stt_model_id = model_ids::effective_stt_model_id(config);
status.tts_voice_id = model_ids::effective_tts_voice_id(config);
status.quantization = model_ids::effective_quantization(config);
status.model_id = model_ids::effective_chat_model_id(&effective_config);
status.chat_model_id = model_ids::effective_chat_model_id(&effective_config);
status.vision_model_id = model_ids::effective_vision_model_id(&effective_config);
status.embedding_model_id = model_ids::effective_embedding_model_id(&effective_config);
status.stt_model_id = model_ids::effective_stt_model_id(&effective_config);
status.tts_voice_id = model_ids::effective_tts_voice_id(&effective_config);
status.quantization = model_ids::effective_quantization(&effective_config);
status.state = "loading".to_string();
status.warning = Some("Connecting to local Ollama runtime".to_string());
status.download_progress = None;
@@ -132,11 +136,11 @@ impl LocalAiService {
status.backend_reason = Some("Inference delegated to Ollama runtime".to_string());
status.model_path = Some(format!(
"ollama://{}",
model_ids::effective_chat_model_id(config)
model_ids::effective_chat_model_id(&effective_config)
));
}
if let Err(first_err) = self.ensure_ollama_server(config).await {
if let Err(first_err) = self.ensure_ollama_server(&effective_config).await {
log::warn!(
"[local_ai] ensure_ollama_server failed, retrying with fresh install: {first_err}"
);
@@ -148,7 +152,7 @@ impl LocalAiService {
status.error_detail = None;
status.error_category = None;
}
if let Err(err) = self.ensure_ollama_server_fresh(config).await {
if let Err(err) = self.ensure_ollama_server_fresh(&effective_config).await {
let mut status = self.status.lock();
status.state = "degraded".to_string();
let is_install_error = status.error_category.as_deref() == Some("install");
@@ -156,24 +160,24 @@ impl LocalAiService {
status.warning = Some(err);
} else {
status.error_category = Some("server".to_string());
status.warning = Some(format_degraded_warning(&err, config));
status.warning = Some(format_degraded_warning(&err, &effective_config));
}
return;
}
}
if let Err(err) = self.ensure_models_available(config).await {
if let Err(err) = self.ensure_models_available(&effective_config).await {
let mut status = self.status.lock();
status.state = "degraded".to_string();
status.error_category = Some("download".to_string());
status.warning = Some(format_degraded_warning(&err, config));
status.warning = Some(format_degraded_warning(&err, &effective_config));
return;
}
// Attempt to load whisper model in-process if configured (blocking I/O).
if config.local_ai.whisper_in_process {
if effective_config.local_ai.whisper_in_process {
if let Ok(model_path) =
crate::openhuman::local_ai::paths::resolve_stt_model_path(config)
crate::openhuman::local_ai::paths::resolve_stt_model_path(&effective_config)
{
let model = std::path::PathBuf::from(&model_path);
let handle = self.whisper.clone();
@@ -201,20 +205,20 @@ impl LocalAiService {
let mut status = self.status.lock();
status.state = "ready".to_string();
status.vision_state = if config.local_ai.preload_vision_model {
status.vision_state = if effective_config.local_ai.preload_vision_model {
"ready".to_string()
} else {
"idle".to_string()
};
status.embedding_state = if config.local_ai.preload_embedding_model {
status.embedding_state = if effective_config.local_ai.preload_embedding_model {
"ready".to_string()
} else {
"idle".to_string()
};
if !config.local_ai.preload_stt_model {
if !effective_config.local_ai.preload_stt_model {
status.stt_state = "idle".to_string();
}
if !config.local_ai.preload_tts_voice {
if !effective_config.local_ai.preload_tts_voice {
status.tts_state = "idle".to_string();
}
status.warning = None;
@@ -227,7 +231,7 @@ impl LocalAiService {
status.eta_seconds = None;
status.model_path = Some(format!(
"ollama://{}",
model_ids::effective_chat_model_id(config)
model_ids::effective_chat_model_id(&effective_config)
));
}
@@ -249,6 +253,39 @@ impl LocalAiService {
}
}
fn config_with_recommended_tier_if_unselected(config: &Config, device: &DeviceProfile) -> Config {
let selected_tier = config
.local_ai
.selected_tier
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let current_tier =
crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai);
if selected_tier.is_some()
|| matches!(
current_tier,
crate::openhuman::local_ai::presets::ModelTier::Low
| crate::openhuman::local_ai::presets::ModelTier::Medium
| crate::openhuman::local_ai::presets::ModelTier::High
)
{
return config.clone();
}
let recommended = crate::openhuman::local_ai::presets::recommend_tier(device);
let mut effective_config = config.clone();
crate::openhuman::local_ai::presets::apply_preset_to_config(
&mut effective_config.local_ai,
recommended,
);
tracing::debug!(
?recommended,
"[local_ai] bootstrap: no tier selected, using recommended preset"
);
effective_config
}
/// Append a tier step-down hint when the current tier is Medium or High.
fn format_degraded_warning(err: &str, config: &Config) -> String {
let current = crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai);
@@ -282,4 +319,49 @@ mod tests {
assert!(service.should_run_memory_autosummary(&config));
assert!(!service.should_run_memory_autosummary(&config));
}
#[test]
fn bootstrap_uses_recommended_tier_when_selection_missing() {
let config = Config::default();
let device = DeviceProfile {
total_ram_bytes: 4 * 1024 * 1024 * 1024,
cpu_count: 4,
cpu_brand: String::new(),
os_name: String::new(),
os_version: String::new(),
has_gpu: false,
gpu_description: None,
};
let effective = config_with_recommended_tier_if_unselected(&config, &device);
// If config already matches a built-in preset, preserve user defaults
// and keep selected_tier unset.
assert!(effective.local_ai.selected_tier.is_none());
assert_eq!(
effective.local_ai.chat_model_id,
config.local_ai.chat_model_id
);
}
#[test]
fn bootstrap_keeps_existing_selected_tier() {
let mut config = Config::default();
config.local_ai.selected_tier = Some("high".to_string());
let original_chat_model = config.local_ai.chat_model_id.clone();
let device = DeviceProfile {
total_ram_bytes: 4 * 1024 * 1024 * 1024,
cpu_count: 4,
cpu_brand: String::new(),
os_name: String::new(),
os_version: String::new(),
has_gpu: false,
gpu_description: None,
};
let effective = config_with_recommended_tier_if_unselected(&config, &device);
assert_eq!(effective.local_ai.selected_tier.as_deref(), Some("high"));
assert_eq!(effective.local_ai.chat_model_id, original_chat_model);
}
}
+140 -70
View File
@@ -303,85 +303,155 @@ impl LocalAiService {
status.eta_seconds = None;
}
let started_at = std::time::Instant::now();
let response = self
.http
.post(format!("{OLLAMA_BASE_URL}/api/pull"))
.json(&OllamaPullRequest {
name: model_id.to_string(),
stream: true,
})
.send()
.await
.map_err(|e| format!("ollama pull request failed: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
return Err(format!(
"ollama pull failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
const MAX_PULL_RETRIES: usize = 3;
const PULL_RETRY_BACKOFF_MS: u64 = 1_500;
let mut last_error: Option<String> = None;
let mut stream = response.bytes_stream();
let mut pending = String::new();
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| format!("ollama pull stream error: {e}"))?;
pending.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = pending.find('\n') {
let line = pending[..pos].trim().to_string();
pending = pending[pos + 1..].to_string();
if line.is_empty() {
for attempt in 1..=MAX_PULL_RETRIES {
if attempt > 1 {
let retry_msg = format!(
"Ollama pull stream interrupted. Retrying {}/{}...",
attempt, MAX_PULL_RETRIES
);
{
let mut status = self.status.lock();
status.state = "downloading".to_string();
status.warning = Some(retry_msg.clone());
}
log::warn!(
"[local_ai] pull retry {}/{} for model `{}` after interruption",
attempt,
MAX_PULL_RETRIES,
model_id
);
tokio::time::sleep(std::time::Duration::from_millis(
PULL_RETRY_BACKOFF_MS * attempt as u64,
))
.await;
}
let response = match self
.http
.post(format!("{OLLAMA_BASE_URL}/api/pull"))
.json(&OllamaPullRequest {
name: model_id.to_string(),
stream: true,
})
// Model pulls are long-running streaming responses; the default 30s
// client timeout can interrupt healthy downloads mid-stream.
.timeout(std::time::Duration::from_secs(30 * 60))
.send()
.await
{
Ok(response) => response,
Err(e) => {
let err = format!("ollama pull request failed: {e}");
last_error = Some(err.clone());
if attempt < MAX_PULL_RETRIES {
continue;
}
return Err(format!("{err} after {MAX_PULL_RETRIES} attempts"));
}
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
return Err(format!(
"ollama pull failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
let mut stream = response.bytes_stream();
let mut pending = String::new();
let mut stream_error: Option<String> = None;
let started_at = std::time::Instant::now();
while let Some(item) = stream.next().await {
let chunk = match item {
Ok(value) => value,
Err(e) => {
stream_error = Some(format!("ollama pull stream error: {e}"));
break;
}
};
pending.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = pending.find('\n') {
let line = pending[..pos].trim().to_string();
pending = pending[pos + 1..].to_string();
if line.is_empty() {
continue;
}
let event: OllamaPullEvent = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(err) = event.error {
return Err(format!("ollama pull error: {err}"));
}
let completed = event.completed.unwrap_or(0);
let total = event.total;
let elapsed = started_at.elapsed().as_secs_f64().max(0.001);
let speed_bps = (completed as f64 / elapsed).round().max(0.0) as u64;
let eta_seconds = total.and_then(|t| {
if completed >= t || speed_bps == 0 {
None
} else {
Some((t.saturating_sub(completed)) / speed_bps.max(1))
}
});
let mut status = self.status.lock();
if let Some(status_text) = event.status.as_deref() {
status.warning = Some(format!("Ollama pull: {status_text}"));
if status_text.eq_ignore_ascii_case("success") {
status.download_progress = Some(1.0);
}
}
status.downloaded_bytes = Some(completed);
status.total_bytes = total;
status.download_speed_bps = Some(speed_bps);
status.eta_seconds = eta_seconds;
status.download_progress = total
.map(|t| (completed as f32 / t as f32).clamp(0.0, 1.0))
.or(Some(0.0));
}
}
if let Some(err) = stream_error {
last_error = Some(err.clone());
if attempt < MAX_PULL_RETRIES {
continue;
}
let event: OllamaPullEvent = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(err) = event.error {
return Err(format!("ollama pull error: {err}"));
}
return Err(format!("{err} after {MAX_PULL_RETRIES} attempts"));
}
let completed = event.completed.unwrap_or(0);
let total = event.total;
let elapsed = started_at.elapsed().as_secs_f64().max(0.001);
let speed_bps = (completed as f64 / elapsed).round().max(0.0) as u64;
let eta_seconds = total.and_then(|t| {
if completed >= t || speed_bps == 0 {
None
} else {
Some((t.saturating_sub(completed)) / speed_bps.max(1))
}
});
if self.has_model(model_id).await? {
break;
}
let mut status = self.status.lock();
if let Some(status_text) = event.status.as_deref() {
status.warning = Some(format!("Ollama pull: {status_text}"));
if status_text.eq_ignore_ascii_case("success") {
status.download_progress = Some(1.0);
}
}
status.downloaded_bytes = Some(completed);
status.total_bytes = total;
status.download_speed_bps = Some(speed_bps);
status.eta_seconds = eta_seconds;
status.download_progress = total
.map(|t| (completed as f32 / t as f32).clamp(0.0, 1.0))
.or(Some(0.0));
last_error = Some(format!(
"ollama pull finished but model `{}` was not found",
model_id
));
if attempt < MAX_PULL_RETRIES {
continue;
}
}
if !self.has_model(model_id).await? {
return Err(format!(
"ollama pull finished but model `{}` was not found",
model_id
));
return Err(last_error.unwrap_or_else(|| {
format!(
"ollama pull finished but model `{}` was not found",
model_id
)
}));
}
match label {
+34 -48
View File
@@ -657,8 +657,6 @@ mod tests {
#[tokio::test]
async fn test_skill_install_creates_files() {
use axum::{routing::get, Router};
let manifest = serde_json::json!({
"id": "test-skill",
"name": "Test Skill",
@@ -666,28 +664,21 @@ mod tests {
"runtime": "quickjs",
"entry": "index.js"
});
// let js_content = b"function init() { console.log('hello'); }";
let app = Router::new()
.route(
"/skills/test-skill/manifest.json",
get({
let m = manifest.clone();
move || async move { serde_json::to_string(&m).unwrap() }
}),
)
.route(
"/skills/test-skill/index.js",
get(|| async { "function init() { console.log('hello'); }" }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://127.0.0.1:{port}");
let src = tempfile::TempDir::new().unwrap();
let manifest_source_path = src.path().join("manifest.json");
let js_source_path = src.path().join("index.js");
let manifest_source_url = reqwest::Url::from_file_path(&manifest_source_path)
.expect("manifest source path must convert to file:// URL")
.to_string();
let js_source_url = reqwest::Url::from_file_path(&js_source_path)
.expect("js source path must convert to file:// URL")
.to_string();
std::fs::write(
&manifest_source_path,
serde_json::to_string(&manifest).unwrap(),
)
.unwrap();
std::fs::write(&js_source_path, "function init() { console.log('hello'); }").unwrap();
// Build a registry pointing at our mock server
let registry = RemoteSkillRegistry {
@@ -705,8 +696,8 @@ mod tests {
platforms: None,
setup: None,
ignore_in_production: false,
download_url: format!("{base}/skills/test-skill/index.js"),
manifest_url: format!("{base}/skills/test-skill/manifest.json"),
download_url: js_source_url.clone(),
manifest_url: manifest_source_url.clone(),
checksum_sha256: None,
author: None,
repository: None,
@@ -737,30 +728,25 @@ mod tests {
#[tokio::test]
async fn test_skill_install_checksum_verification() {
use axum::{routing::get, Router};
let js_content = "function init() { return 42; }";
let mut hasher = Sha256::new();
hasher.update(js_content.as_bytes());
let correct_checksum = format!("{:x}", hasher.finalize());
let app = Router::new()
.route(
"/skills/cs-skill/manifest.json",
get(|| async { r#"{"id":"cs-skill","name":"CS Skill","version":"1.0.0"}"# }),
)
.route(
"/skills/cs-skill/index.js",
get(move || async move { js_content }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://127.0.0.1:{port}");
let src = tempfile::TempDir::new().unwrap();
let manifest_source_path = src.path().join("manifest.json");
let js_source_path = src.path().join("index.js");
let manifest_source_url = reqwest::Url::from_file_path(&manifest_source_path)
.expect("manifest source path must convert to file:// URL")
.to_string();
let js_source_url = reqwest::Url::from_file_path(&js_source_path)
.expect("js source path must convert to file:// URL")
.to_string();
std::fs::write(
&manifest_source_path,
r#"{"id":"cs-skill","name":"CS Skill","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(&js_source_path, js_content).unwrap();
// Test with correct checksum
let registry = RemoteSkillRegistry {
@@ -778,8 +764,8 @@ mod tests {
platforms: None,
setup: None,
ignore_in_production: false,
download_url: format!("{base}/skills/cs-skill/index.js"),
manifest_url: format!("{base}/skills/cs-skill/manifest.json"),
download_url: js_source_url.clone(),
manifest_url: manifest_source_url.clone(),
checksum_sha256: Some(correct_checksum.clone()),
author: None,
repository: None,