import { useEffect, useMemo, useState } from 'react'; import { triggerLocalAiAssetBootstrap } from '../../../utils/localAiBootstrap'; import { formatBytes, formatEta, progressFromDownloads, progressFromStatus, } from '../../../utils/localAiHelpers'; import { type ApplyPresetResult, type LlmBackend, type LocalAiDownloadsProgress, type LocalAiStatus, memoryTreeGetLlm, memoryTreeSetLlm, openhumanGetConfig, openhumanLocalAiApplyPreset, openhumanLocalAiDownload, openhumanLocalAiDownloadAllAssets, openhumanLocalAiDownloadsProgress, openhumanLocalAiPresets, openhumanLocalAiStatus, openhumanUpdateLocalAiSettings, type PresetsResponse, } from '../../../utils/tauriCommands'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import DeviceCapabilitySection from './local-model/DeviceCapabilitySection'; const formatRamGb = (bytes: number): string => { const gb = bytes / (1024 * 1024 * 1024); return gb >= 10 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`; }; const LocalModelPanel = () => { const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); const [status, setStatus] = useState(null); const [downloads, setDownloads] = useState(null); const [statusError, setStatusError] = useState(''); const [isTriggeringDownload, setIsTriggeringDownload] = useState(false); const [bootstrapMessage, setBootstrapMessage] = useState(''); const [presetsData, setPresetsData] = useState(null); const [presetsLoading, setPresetsLoading] = useState(true); const [presetError, setPresetError] = useState(''); const [presetSuccess, setPresetSuccess] = useState(null); const [usageFlags, setUsageFlags] = useState<{ runtime_enabled: boolean; usage_embeddings: boolean; usage_heartbeat: boolean; usage_learning_reflection: boolean; usage_subconscious: boolean; }>({ runtime_enabled: false, usage_embeddings: false, usage_heartbeat: false, usage_learning_reflection: false, usage_subconscious: false, }); const [usageError, setUsageError] = useState(''); const [usageSaving, setUsageSaving] = useState(false); // Memory summarizer backend lives in `memory_tree.llm_backend`, which is // outside the `local_ai.usage.*` surface — so it has its own RPC pair // (`memoryTreeGetLlm` / `memoryTreeSetLlm`). This is now the only UI // surface for the cloud/local toggle (the Intelligence Memory tab's // BackendChooser was removed in this PR to eliminate the duplicate // control surface). The tab's local-only Ollama model picker reads // the same field at mount to decide visibility. const [summarizerBackend, setSummarizerBackend] = useState('cloud'); const [summarizerSaving, setSummarizerSaving] = useState(false); const progress = useMemo(() => { const downloadProgress = progressFromDownloads(downloads); if (downloadProgress != null) return downloadProgress; return progressFromStatus(status); }, [downloads, status]); const currentState = downloads?.state ?? status?.state; const runtimeEnabled = usageFlags.runtime_enabled; const isInstalling = currentState === 'installing'; const isIndeterminateDownload = isInstalling || (currentState === 'downloading' && typeof downloads?.progress !== 'number' && typeof status?.download_progress !== 'number'); const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes; const totalBytes = downloads?.total_bytes ?? status?.total_bytes; const speedBps = downloads?.speed_bps ?? status?.download_speed_bps; const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds; const loadStatus = async () => { try { const [statusResponse, downloadsResponse] = await Promise.all([ openhumanLocalAiStatus(), openhumanLocalAiDownloadsProgress(), ]); setStatus(statusResponse.result); setDownloads(downloadsResponse.result); setStatusError(''); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to read local model status'; setStatusError(message); setStatus(null); setDownloads(null); } }; const loadPresets = async () => { setPresetsLoading(true); try { const data = await openhumanLocalAiPresets(); setPresetsData(data); setPresetError(''); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to load presets'; setPresetError(msg); } finally { setPresetsLoading(false); } }; const loadUsage = async () => { try { const snap = await openhumanGetConfig(); const localAi = (snap.result?.config?.local_ai ?? {}) as Record; const usage = (localAi.usage ?? {}) as Record; setUsageFlags({ runtime_enabled: Boolean(localAi.runtime_enabled), usage_embeddings: Boolean(usage.embeddings), usage_heartbeat: Boolean(usage.heartbeat), usage_learning_reflection: Boolean(usage.learning_reflection), usage_subconscious: Boolean(usage.subconscious), }); setUsageError(''); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to load local AI flags'; setUsageError(msg); } }; const updateUsage = async (patch: Partial) => { const next = { ...usageFlags, ...patch }; setUsageFlags(next); setUsageSaving(true); setUsageError(''); try { await openhumanUpdateLocalAiSettings(patch); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to save local AI flags'; setUsageError(msg); void loadUsage(); } finally { setUsageSaving(false); } }; const loadSummarizerBackend = async () => { try { const resp = await memoryTreeGetLlm(); if (resp?.current === 'local' || resp?.current === 'cloud') { setSummarizerBackend(resp.current); } } catch (err) { // Non-fatal — the row stays at its default (cloud) and the user // can still flip it; the next save attempt will surface the error. console.warn('[local-model-panel] memoryTreeGetLlm failed', err); } }; const updateSummarizerBackend = async (next: LlmBackend) => { // Belt-and-braces: the checkbox is already `disabled` when the // master runtime is off or a save is in flight, but the rendered // disabled attribute only stops real-browser click events — JSDOM // / fireEvent ignore it. Mirror the gate here so programmatic // dispatch can't sneak past the master switch either. if (!usageFlags.runtime_enabled || summarizerSaving) return; const prev = summarizerBackend; setSummarizerBackend(next); setSummarizerSaving(true); setUsageError(''); try { await memoryTreeSetLlm({ backend: next }); } catch (err) { // Roll back the optimistic toggle and surface the error. setSummarizerBackend(prev); const msg = err instanceof Error ? err.message : 'Failed to save memory summarizer backend'; setUsageError(msg); } finally { setSummarizerSaving(false); } }; useEffect(() => { const initialLoad = window.setTimeout(() => { void loadStatus(); void loadPresets(); void loadUsage(); void loadSummarizerBackend(); }, 0); const timer = window.setInterval(() => { void loadStatus(); }, 1500); return () => { window.clearTimeout(initialLoad); window.clearInterval(timer); }; }, []); const triggerDownload = async (force: boolean) => { if (!runtimeEnabled) return; setIsTriggeringDownload(true); setStatusError(''); setBootstrapMessage(''); try { await openhumanLocalAiDownload(force); await openhumanLocalAiDownloadAllAssets(force); const freshStatus = await openhumanLocalAiStatus(); setStatus(freshStatus.result); if (freshStatus.result?.state === 'ready') { setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Models verified'); } setTimeout(() => setBootstrapMessage(''), 3000); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to trigger local model bootstrap'; setStatusError(message); } finally { setIsTriggeringDownload(false); } }; /** * Install-Ollama entry point used by the locked tier picker and the * runtime-status install CTA. * * Three preconditions need to be flipped before `download_all_models` * will actually run on the core side: * - `runtime_enabled = true` (cloud-fallback override off) * - `selected_tier` (anything other than "disabled") * - `opt_in_confirmed = true` (so bootstrap doesn't hard-override * to disabled in `config_with_recommended_tier_if_unselected`) * * `apply_preset()` sets all three in one save. We call it * **unconditionally** here rather than going through * `ensureRecommendedLocalAiPresetIfNeeded`, because that helper * short-circuits when `selected_tier` is already set — which is exactly * the case for users who previously picked "Disabled (cloud fallback)" * and now want to switch on local AI. Without the explicit apply, * runtime_enabled stays false, `download_all_models` returns * `local ai is disabled`, the task marks the service degraded, and the * UI silently bounces back to idle. */ const triggerInstallWithRecommendedTier = async () => { setIsTriggeringDownload(true); setStatusError(''); setBootstrapMessage(''); try { const presetsResult = presetsData ?? (await openhumanLocalAiPresets()); const tier = presetsResult.recommended_tier || 'ram_2_4gb'; if (tier === 'disabled') { throw new Error('Cannot install Ollama for the "disabled" tier — pick a local tier first.'); } await openhumanLocalAiApplyPreset(tier); await triggerLocalAiAssetBootstrap(true); await loadPresets(); const freshStatus = await openhumanLocalAiStatus(); setStatus(freshStatus.result); setBootstrapMessage('Install started — Ollama and models are downloading'); setTimeout(() => setBootstrapMessage(''), 4000); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start Ollama install'; setStatusError(message); } finally { setIsTriggeringDownload(false); } }; return (
void triggerInstallWithRecommendedTier()} isTriggeringInstall={isTriggeringDownload} installState={status?.state} installWarning={status?.warning} installError={status?.error_detail} onPresetApplied={result => { setPresetSuccess(result); void loadPresets(); void loadStatus(); }} /> {/* Simplified Model Status — only meaningful AFTER Ollama is on disk. Before that, every readout here ("idle"/"missing", Re-bootstrap button, refresh) is noise: there's no runtime to inspect and the right call-to-action is "Install Ollama" up top in the tier-picker banner. Hide the whole section to keep the UI progressive: 1) Ollama missing → only the install CTA (above) 2) Ollama installing → CTA flips to blue progress (above) 3) Ollama installed onward → this section appears with model state */} {(downloads?.ollama_available ?? true) && (

Model Status

State:{' '} {currentState ?? 'unknown'}
{(currentState === 'downloading' || isInstalling) && (
{isIndeterminateDownload ? (
) : (
)}
{typeof downloadedBytes === 'number' ? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}` : ''} {typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : ''} {etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''}
)} {bootstrapMessage &&
{bootstrapMessage}
}
{statusError && (
{statusError}
)}
)}

Usage

Choose which subsystems run on the local model. Anything off uses the cloud.

{/* Memory summarizer is special: it writes `memory_tree.llm_backend`, not `local_ai.usage.*`. This is now the sole UI surface for that field (the Intelligence Memory tab's BackendChooser was removed); the tab's Ollama model picker still reads it at mount to gate visibility. */} {( [ { key: 'usage_embeddings' as const, label: 'Embeddings', hint: 'Generate memory embeddings locally instead of in the cloud.', }, { key: 'usage_heartbeat' as const, label: 'Heartbeat', hint: 'Run heartbeat reasoning locally.', }, { key: 'usage_learning_reflection' as const, label: 'Learning / reflection', hint: 'Run learning and reflection passes locally.', }, { key: 'usage_subconscious' as const, label: 'Subconscious', hint: 'Run subconscious evaluation locally.', }, ] as const ).map(({ key, label, hint }) => ( ))}
{usageError && (
{usageError}
)}
); }; export default LocalModelPanel;