mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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]'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1491,6 +1491,7 @@ export interface PresetsResponse {
|
||||
presets: ModelPresetResult[];
|
||||
recommended_tier: string;
|
||||
current_tier: string;
|
||||
selected_tier?: string | null;
|
||||
device: DeviceProfileResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user