feat(local_ai): Ollama precondition gate + install UX hardening (#1475) (#1569)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
sanil-23
2026-05-12 20:31:23 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 59bbf67e4e
commit 4be66bafd1
9 changed files with 701 additions and 76 deletions
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { triggerLocalAiAssetBootstrap } from '../../../utils/localAiBootstrap';
import {
formatBytes,
formatEta,
@@ -11,6 +12,7 @@ import {
type LocalAiDownloadsProgress,
type LocalAiStatus,
openhumanGetConfig,
openhumanLocalAiApplyPreset,
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
openhumanLocalAiDownloadsProgress,
@@ -179,6 +181,52 @@ const LocalModelPanel = () => {
}
};
/**
* 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(<real tier>)` 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 (
<div>
<SettingsHeader
@@ -195,6 +243,12 @@ const LocalModelPanel = () => {
presetError={presetError}
presetSuccess={presetSuccess}
formatRamGb={formatRamGb}
ollamaAvailable={downloads?.ollama_available ?? true}
onTriggerOllamaInstall={() => void triggerInstallWithRecommendedTier()}
isTriggeringInstall={isTriggeringDownload}
installState={status?.state}
installWarning={status?.warning}
installError={status?.error_detail}
onPresetApplied={result => {
setPresetSuccess(result);
void loadPresets();
@@ -202,76 +256,89 @@ const LocalModelPanel = () => {
}}
/>
{/* Simplified download status */}
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Model Status</h3>
{/*
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) && (
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Model Status</h3>
<div className="text-sm text-stone-600">
State:{' '}
<span
className={`font-medium ${
currentState === 'ready'
? 'text-green-600'
: currentState === 'downloading' || currentState === 'installing'
? 'text-primary-600'
: currentState === 'degraded'
? 'text-amber-700'
: 'text-stone-700'
}`}>
{currentState ?? 'unknown'}
</span>
</div>
{(currentState === 'downloading' || isInstalling) && (
<div className="space-y-2">
<div className="w-full h-2 rounded-full bg-stone-200 overflow-hidden">
{isIndeterminateDownload ? (
<div className="h-full bg-primary-500 animate-pulse rounded-full w-1/2" />
) : (
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${String(Math.min(progress ?? 0, 100))}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs text-stone-500">
<span>
{typeof downloadedBytes === 'number'
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: ''}
</span>
<span>
{typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : ''}
{etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''}
</span>
</div>
<div className="text-sm text-stone-600">
State:{' '}
<span
className={`font-medium ${
currentState === 'ready'
? 'text-green-600'
: currentState === 'downloading' || currentState === 'installing'
? 'text-primary-600'
: currentState === 'degraded'
? 'text-amber-700'
: 'text-stone-700'
}`}>
{currentState ?? 'unknown'}
</span>
</div>
)}
{bootstrapMessage && <div className="text-xs text-green-700">{bootstrapMessage}</div>}
{(currentState === 'downloading' || isInstalling) && (
<div className="space-y-2">
<div className="w-full h-2 rounded-full bg-stone-200 overflow-hidden">
{isIndeterminateDownload ? (
<div className="h-full bg-primary-500 animate-pulse rounded-full w-1/2" />
) : (
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${String(Math.min(progress ?? 0, 100))}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs text-stone-500">
<span>
{typeof downloadedBytes === 'number'
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: ''}
</span>
<span>
{typeof speedBps === 'number' && speedBps > 0
? `${formatBytes(speedBps)}/s`
: ''}
{etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''}
</span>
</div>
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void triggerDownload(false)}
disabled={!runtimeEnabled || isTriggeringDownload}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isTriggeringDownload ? 'Downloading…' : 'Download Models'}
</button>
<button
type="button"
onClick={() => void loadStatus()}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700">
Refresh
</button>
</div>
{bootstrapMessage && <div className="text-xs text-green-700">{bootstrapMessage}</div>}
{statusError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{statusError}
<div className="flex gap-2">
<button
type="button"
onClick={() => void triggerDownload(false)}
disabled={!runtimeEnabled || isTriggeringDownload}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isTriggeringDownload ? 'Downloading…' : 'Download Models'}
</button>
<button
type="button"
onClick={() => void loadStatus()}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700">
Refresh
</button>
</div>
)}
</section>
{statusError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{statusError}
</div>
)}
</section>
)}
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div>
@@ -13,6 +13,32 @@ interface DeviceCapabilitySectionProps {
presetSuccess: ApplyPresetResult | null;
formatRamGb: (bytes: number) => string;
onPresetApplied?: (result: ApplyPresetResult) => void;
/**
* When `false`, the Ollama runtime isn't installed yet. Local tiers
* require Ollama, so they're rendered disabled with a notice that
* lets the user install Ollama in place. The "Disabled (cloud
* fallback)" option stays enabled since it doesn't need Ollama.
*/
ollamaAvailable?: boolean;
/**
* Triggers the same install pipeline the Runtime Status section uses.
* Wired only when `ollamaAvailable === false` to surface an inline
* Install Ollama button next to the locked tiers.
*/
onTriggerOllamaInstall?: () => void;
/** True while an install pipeline is already running. */
isTriggeringInstall?: boolean;
/**
* Live state from `local_ai_status` so the notice can show real install
* progress: `installing`, `downloading`, `degraded`, etc. The button's
* own `isTriggeringInstall` only covers the RPC round-trip (~ms);
* `installState` covers the entire backend pipeline (~60s).
*/
installState?: string;
/** Latest `status.warning` text — shown under the progress label. */
installWarning?: string | null;
/** Latest `status.error_detail` — shown when state is `degraded`. */
installError?: string | null;
}
const DISABLED_TIER_ID = 'disabled';
@@ -24,7 +50,16 @@ const DeviceCapabilitySection = ({
presetSuccess,
formatRamGb,
onPresetApplied,
ollamaAvailable = true,
onTriggerOllamaInstall,
isTriggeringInstall = false,
installState,
installWarning,
installError,
}: DeviceCapabilitySectionProps) => {
const installInProgress =
installState === 'installing' || installState === 'downloading' || installState === 'loading';
const installFailed = installState === 'degraded';
const [applying, setApplying] = useState<string | null>(null);
const [applyError, setApplyError] = useState<string>('');
const [applySuccess, setApplySuccess] = useState<ApplyPresetResult | null>(null);
@@ -92,6 +127,93 @@ const DeviceCapabilitySection = ({
</div>
)}
{presetsData && !ollamaAvailable && (
<div
className={`rounded-lg border p-3 space-y-2 ${
installFailed
? 'border-red-300 bg-red-50'
: installInProgress
? 'border-blue-300 bg-blue-50'
: 'border-amber-300 bg-amber-50'
}`}>
{installInProgress ? (
<>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
<div className="text-sm font-semibold text-blue-900">
Installing Ollama
{installState === 'downloading' ? ' (downloading models)' : '…'}
</div>
</div>
<div className="text-xs text-blue-800">
{installWarning ??
'Downloading the OllamaSetup installer (~2 GB) and unpacking it. This can take a minute on first install.'}
</div>
<div className="h-1.5 rounded-full bg-blue-200 overflow-hidden">
<div className="h-full w-1/3 bg-blue-500 animate-pulse" />
</div>
</>
) : installFailed ? (
<>
<div className="text-sm font-semibold text-red-900">Ollama install failed</div>
<div className="text-xs text-red-800">
{installWarning ??
'The installer exited before Ollama was usable. Click retry to try again, or install manually from ollama.com.'}
</div>
{installError && (
<pre className="max-h-40 overflow-auto rounded bg-red-100 border border-red-200 p-2 text-[10px] text-red-700 leading-tight whitespace-pre-wrap break-words">
{installError}
</pre>
)}
<div className="flex items-center gap-2 pt-1">
{onTriggerOllamaInstall && (
<button
type="button"
onClick={onTriggerOllamaInstall}
disabled={isTriggeringInstall}
className="px-3 py-1.5 text-xs rounded-md bg-red-600 hover:bg-red-700 disabled:opacity-60 text-white font-medium">
{isTriggeringInstall ? 'Retrying…' : 'Retry install'}
</button>
)}
<a
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs rounded-md border border-red-300 hover:border-red-400 text-red-800">
Install manually
</a>
</div>
</>
) : (
<>
<div className="text-xs text-amber-800">
<span className="font-semibold text-amber-900">Install Ollama first.</span> Local
tiers run on the Ollama runtime, which isn&apos;t installed yet. The &ldquo;Disabled
(cloud fallback)&rdquo; option stays available either way.
</div>
<div className="flex items-center gap-2">
{onTriggerOllamaInstall && (
<button
type="button"
onClick={onTriggerOllamaInstall}
disabled={isTriggeringInstall}
className="px-3 py-1.5 text-xs rounded-md bg-amber-600 hover:bg-amber-700 disabled:opacity-60 text-white font-medium">
{isTriggeringInstall ? 'Starting…' : 'Install Ollama'}
</button>
)}
<a
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs rounded-md border border-amber-300 hover:border-amber-400 text-amber-800">
Install manually
</a>
</div>
</>
)}
</div>
)}
{presetsData && (
<div className="space-y-2">
{/* Disabled — Cloud fallback card (always available, recommended on low-RAM) */}
@@ -112,7 +234,7 @@ const DeviceCapabilitySection = ({
Active
</span>
)}
{presetsData.recommend_disabled && !isDisabledActive && (
{(presetsData.recommend_disabled || !ollamaAvailable) && !isDisabledActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 text-amber-700 uppercase tracking-wide">
Recommended
</span>
@@ -128,17 +250,25 @@ const DeviceCapabilitySection = ({
{presetsData.presets.map(preset => {
const isCurrent = !isDisabledActive && preset.tier === presetsData.current_tier;
const isApplying = applying === preset.tier;
const locked = !ollamaAvailable;
return (
<button
type="button"
key={preset.tier}
onClick={() => void handleApply(preset.tier)}
disabled={applying !== null}
disabled={applying !== null || locked}
title={locked ? 'Install Ollama first to use this tier' : undefined}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isCurrent
? 'border-primary-400 bg-primary-50'
: 'border-stone-200 bg-stone-50 hover:bg-stone-100'
} ${applying !== null && !isApplying ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
} ${
locked
? 'opacity-50 cursor-not-allowed hover:bg-stone-50'
: applying !== null && !isApplying
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900">{preset.label}</span>
@@ -152,6 +282,11 @@ const DeviceCapabilitySection = ({
Applying
</span>
)}
{locked && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 text-amber-700 uppercase tracking-wide">
Needs Ollama
</span>
)}
</div>
<span className="text-xs text-stone-500">
~{Number(preset.approx_download_gb).toFixed(1)} GB
@@ -78,6 +78,113 @@ const ModelStatusSection = ({
onRunDiagnostics,
onRepairAction,
}: ModelStatusSectionProps) => {
// Core reports `ollama_available: false` when no Ollama binary is
// discoverable on disk. The backend short-circuits all `has_model` HTTP
// probes in that state, so model rows below will all read "missing". Surface
// a clear install CTA up front so users don't have to interpret the empty
// model state on their own.
const showInstallOllamaCta = downloads?.ollama_available === false;
if (showInstallOllamaCta) {
// No Ollama on disk — the runtime-status card and diagnostics panels
// below would just read "n/a" / "missing" everywhere, which is more
// confusing than helpful. Render only the install CTA, with the binary
// path setter inline for users who installed Ollama in a non-standard
// location that auto-discovery can't find.
return (
<section className="rounded-lg border border-amber-300 bg-amber-50 p-4 space-y-3">
<div className="flex items-start gap-3">
<svg
className="h-5 w-5 flex-shrink-0 text-amber-600 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div className="flex-1 space-y-1">
<div className="text-sm font-semibold text-amber-900">Ollama is not installed</div>
<div className="text-xs text-amber-800">
Local AI features (chat, vision, embedding) need the Ollama runtime. Install it below
the installer runs silently and lands in your workspace; no console window will
appear.
</div>
</div>
</div>
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={() => onTriggerDownload(true)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md bg-amber-600 hover:bg-amber-700 disabled:opacity-60 text-white font-medium">
{isTriggeringDownload ? 'Installing...' : 'Install Ollama'}
</button>
<a
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs rounded-md border border-amber-300 hover:border-amber-400 text-amber-800">
Install manually
</a>
</div>
{isInstallError && status?.error_detail && (
<div className="space-y-1 pt-2 border-t border-amber-200">
<button
type="button"
onClick={onToggleErrorDetail}
className="text-xs text-red-700 hover:text-red-600 underline">
{showErrorDetail ? 'Hide error details' : 'Show install error details'}
</button>
{showErrorDetail && (
<pre className="max-h-40 overflow-auto rounded bg-red-50 border border-red-200 p-2 text-[10px] text-red-700 leading-tight whitespace-pre-wrap break-words">
{status.error_detail}
</pre>
)}
</div>
)}
<div className="pt-2 border-t border-amber-200 space-y-1">
<div className="text-amber-900 text-xs font-medium">
Already installed in a custom location?
</div>
<div className="text-[11px] text-amber-800">
Point us at the binary and we&apos;ll use it instead of running the installer.
</div>
<div className="flex items-center gap-2 pt-1">
<input
type="text"
value={ollamaPathInput}
onChange={e => onSetOllamaPathInput(e.target.value)}
placeholder="C:\Users\you\AppData\Local\Programs\Ollama\ollama.exe"
className="flex-1 rounded-md border border-amber-300 bg-white px-2 py-1.5 text-xs text-stone-900 placeholder:text-stone-400 focus:border-amber-500 focus:outline-none"
/>
<button
type="button"
onClick={onSetOllamaPath}
disabled={isSettingPath || !ollamaPathInput.trim()}
className="px-2 py-1.5 text-xs rounded-md bg-amber-600 hover:bg-amber-700 disabled:opacity-60 text-white whitespace-nowrap">
{isSettingPath ? 'Setting...' : 'Set Path'}
</button>
{ollamaPathInput && (
<button
type="button"
onClick={onClearOllamaPath}
disabled={isSettingPath}
className="px-2 py-1.5 text-xs rounded-md border border-amber-300 hover:border-amber-400 disabled:opacity-60 text-amber-800 whitespace-nowrap">
Clear
</button>
)}
</div>
</div>
</section>
);
}
return (
<>
<section className="space-y-3">
+9
View File
@@ -50,6 +50,13 @@ export interface LocalAiAssetsStatus {
stt: LocalAiAssetStatus;
tts: LocalAiAssetStatus;
quantization: string;
/**
* True when the core can find an Ollama binary on disk. When false the UI
* should render an "Install Ollama" CTA instead of model state — every
* Ollama-backed asset will be reported as `missing` and `/api/tags`
* probes are skipped entirely (no 30s timeout).
*/
ollama_available: boolean;
}
export interface LocalAiDownloadProgressItem {
@@ -78,6 +85,8 @@ export interface LocalAiDownloadsProgress {
embedding: LocalAiDownloadProgressItem;
stt: LocalAiDownloadProgressItem;
tts: LocalAiDownloadProgressItem;
/** Mirrors `LocalAiAssetsStatus.ollama_available` — see that field. */
ollama_available: boolean;
}
export interface LocalAiEmbeddingResult {
+98 -2
View File
@@ -2,6 +2,40 @@
use std::path::{Path, PathBuf};
/// Name of the Inno Setup installer process. On Windows the installer is
/// spawned via PowerShell's `Start-Process`, which creates a top-level
/// process — it survives the parent OpenHuman process dying. If OpenHuman
/// is killed mid-install (or the user closes the app and reopens it before
/// install completes) we need to detect the in-flight installer instead
/// of launching a second one that would race on the same install dir.
#[cfg(windows)]
const OLLAMA_INSTALLER_PROCESS_NAME: &str = "OllamaSetup.exe";
/// Returns `true` when a Windows OllamaSetup.exe process is currently
/// running anywhere on the machine. macOS / Linux installs are spawned
/// as `sh` children of the Rust core and cannot orphan past it; the
/// non-Windows branch is therefore a constant `false`.
#[cfg(windows)]
pub(crate) fn is_ollama_installer_running() -> bool {
use sysinfo::{ProcessesToUpdate, System};
let mut sys = System::new();
// refresh_processes is sufficient — we only need the process list, not
// CPU/memory/disk/network. refresh_all() adds dozens of milliseconds of
// blocking I/O on a loaded Windows machine and this function runs on the
// async executor inside download_and_install_ollama.
sys.refresh_processes(ProcessesToUpdate::All);
sys.processes().values().any(|p| {
p.name()
.to_string_lossy()
.eq_ignore_ascii_case(OLLAMA_INSTALLER_PROCESS_NAME)
})
}
#[cfg(not(windows))]
pub(crate) fn is_ollama_installer_running() -> bool {
false
}
/// Captured output from the Ollama install script.
pub(crate) struct InstallResult {
pub exit_status: std::process::ExitStatus,
@@ -33,10 +67,62 @@ pub(crate) async fn run_ollama_install_script(install_dir: &Path) -> Result<Inst
})
}
#[cfg(target_os = "windows")]
pub(crate) fn resolve_powershell_executable() -> std::ffi::OsString {
// `Command::new("powershell")` relies on PATH. When OpenHuman.exe is
// spawned by `cargo tauri dev` (or similar dev harnesses) the inherited
// PATH can be sanitized down to a subset that excludes the
// `WindowsPowerShell\v1.0` dir, and the spawn fails with `program not
// found`. Probe known absolute locations first and fall back to the
// bare name if none are present.
//
// %SystemRoot% defaults to `C:\Windows` and is always set on Windows
// sessions.
let system_root =
std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from("C:\\Windows"));
for relative in [
"System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe",
] {
let mut candidate = std::path::PathBuf::from(&system_root);
candidate.push(relative);
if candidate.is_file() {
return candidate.into_os_string();
}
}
// PowerShell 7 (`pwsh.exe`) is another viable substitute when present.
if let Ok(pf) = std::env::var("ProgramFiles") {
let p7 = std::path::PathBuf::from(pf)
.join("PowerShell")
.join("7")
.join("pwsh.exe");
if p7.is_file() {
return p7.into_os_string();
}
}
std::ffi::OsString::from("powershell")
}
fn build_install_command(install_dir: &Path) -> Result<tokio::process::Command, String> {
#[cfg(target_os = "windows")]
{
let mut cmd = tokio::process::Command::new("powershell");
let powershell_exe = resolve_powershell_executable();
log::debug!(
"[local_ai] resolved powershell for installer: {}",
powershell_exe.to_string_lossy()
);
let mut cmd = tokio::process::Command::new(&powershell_exe);
// Kill the PowerShell child if the spawning future is dropped — e.g.
// the in-process tokio runtime shuts down because the user closed
// OpenHuman mid-install. Without this, `cmd.output().await` keeps
// OpenHuman.exe alive (and port 7788 bound) for the full 60120s of
// the install download, producing zombie processes and
// "port in use" errors on the next launch. Note: OllamaSetup.exe is
// spawned by PowerShell as a TOP-LEVEL process (via `Start-Process`),
// so it survives PowerShell's death. That's intentional — the
// crash-resume detection in `is_ollama_installer_running` picks it
// up on the next OpenHuman launch and waits.
cmd.kill_on_drop(true);
crate::openhuman::local_ai::process_util::apply_no_window(&mut cmd);
cmd.env("OPENHUMAN_OLLAMA_INSTALL_DIR", install_dir);
cmd.args([
@@ -52,7 +138,11 @@ fn build_install_command(install_dir: &Path) -> Result<tokio::process::Command,
$installerUrl = "https://ollama.com/download/OllamaSetup.exe"
$tempInstaller = Join-Path $env:TEMP "OllamaSetup.exe"
Invoke-WebRequest -UseBasicParsing -Uri $installerUrl -OutFile $tempInstaller
$args = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /CURRENTUSER /DIR=""$installDir"""
# /SILENT (not /VERYSILENT) so Inno Setup's small progress dialog
# appears. The dialog is owned by the OS, not OpenHuman, so it
# survives the parent process crashing — giving the user a visible
# signal that an install is in flight even if OpenHuman dies.
$args = "/SILENT /NORESTART /SUPPRESSMSGBOXES /CURRENTUSER /DIR=""$installDir"""
$proc = Start-Process -FilePath $tempInstaller -ArgumentList $args -PassThru
$proc.WaitForExit()
if ($proc.ExitCode -ne 0) {
@@ -67,6 +157,11 @@ fn build_install_command(install_dir: &Path) -> Result<tokio::process::Command,
#[cfg(target_os = "macos")]
{
let mut cmd = tokio::process::Command::new("sh");
// Same rationale as the Windows branch: kill the child if the
// spawning task is dropped (e.g. app shutdown mid-install) so the
// tokio runtime can exit cleanly instead of waiting for curl to
// finish downloading the full Ollama.app bundle.
cmd.kill_on_drop(true);
cmd.env("OPENHUMAN_OLLAMA_INSTALL_DIR", install_dir);
cmd.arg("-lc")
.arg(
@@ -95,6 +190,7 @@ fn build_install_command(install_dir: &Path) -> Result<tokio::process::Command,
#[cfg(target_os = "linux")]
{
let mut cmd = tokio::process::Command::new("sh");
cmd.kill_on_drop(true); // see Windows-branch comment above
cmd.env("OPENHUMAN_OLLAMA_INSTALL_DIR", install_dir);
cmd.arg("-lc")
.arg(
+17 -3
View File
@@ -24,9 +24,21 @@ impl LocalAiService {
let stt_model = model_ids::effective_stt_model_id(config);
let tts_voice = model_ids::effective_tts_voice_id(config);
let chat_ready = self.has_model(&chat_model).await.unwrap_or(false);
let vision_ready = self.has_model(&vision_model).await.unwrap_or(false);
let embedding_ready = self.has_model(&embedding_model).await.unwrap_or(false);
// Pre-flight precondition: if no Ollama binary exists anywhere
// discoverable, every `has_model` call will fail (or time out). Skip
// the HTTP probes entirely and report a clean "missing" state with
// `ollama_available: false` so the UI can render an "Install Ollama"
// CTA instead of perpetually-empty model state.
let ollama_available = self.ollama_binary_present(config);
let (chat_ready, vision_ready, embedding_ready) = if ollama_available {
(
self.has_model(&chat_model).await.unwrap_or(false),
self.has_model(&vision_model).await.unwrap_or(false),
self.has_model(&embedding_model).await.unwrap_or(false),
)
} else {
(false, false, false)
};
let stt_resolve = resolve_stt_model_path(config);
let tts_resolve = resolve_tts_voice_path(config);
@@ -134,6 +146,7 @@ impl LocalAiService {
warning: tts_warning,
},
quantization: model_ids::effective_quantization(config),
ollama_available,
})
}
@@ -250,6 +263,7 @@ impl LocalAiService {
embedding,
stt,
tts,
ollama_available: assets.ollama_available,
})
}
+11 -1
View File
@@ -49,8 +49,17 @@ impl LocalAiService {
last_memory_summary_at: parking_lot::Mutex::new(None),
http: reqwest::Client::builder()
// Local models can take >30s on cold start and first-token generation.
// Keep this generous so inline autocomplete and local chat stay reliable.
// Keep the total timeout generous so inline autocomplete and local
// chat stay reliable.
.timeout(std::time::Duration::from_secs(120))
// ...but bound the *connect* phase tightly. When the Ollama server
// isn't running, the default connect timeout (long on Windows
// loopback) cascades through `has_model` × 3 in `assets_status`
// and blows past the 30s RPC envelope. 500ms is well under any
// realistic loopback connect latency; if the server is up,
// reqwest's per-request `.timeout()` still bounds the rest of
// the exchange.
.connect_timeout(std::time::Duration::from_millis(500))
.build()
.unwrap_or_else(|e| {
log::warn!("[local_ai] reqwest client build failed, falling back to default client: {e}");
@@ -98,6 +107,7 @@ impl LocalAiService {
}
pub fn mark_degraded(&self, warning: String) {
log::warn!("[local_ai] mark_degraded: {warning}");
let mut status = self.status.lock();
status.state = "degraded".to_string();
status.warning = Some(warning);
+181 -3
View File
@@ -77,14 +77,20 @@ impl LocalAiService {
serve_cmd
.arg("serve")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// Pipe stderr so we can detect specific failure modes — most
// importantly Windows Controlled Folder Access blocks, which
// surface as "Access is denied" / "operation was blocked" /
// 0x80070005 in Ollama's own stderr when CFA refuses writes
// to the model cache or even prevents the binary from running.
.stderr(std::process::Stdio::piped());
apply_no_window(&mut serve_cmd);
match serve_cmd.spawn() {
Ok(_) => {
let mut serve_child = match serve_cmd.spawn() {
Ok(child) => {
log::debug!(
"[local_ai] spawned `ollama serve` from {}",
ollama_cmd.display()
);
child
}
Err(err) => {
log::warn!(
@@ -96,6 +102,35 @@ impl LocalAiService {
ollama_cmd.display()
));
}
};
// Drain stderr into a bounded buffer in the background. We keep
// the last ~16KB so we can quote it back to the user / Sentry on
// failure but don't grow unbounded if Ollama logs heavily.
let stderr_buffer = std::sync::Arc::new(parking_lot::Mutex::new(String::new()));
if let Some(stderr) = serve_child.stderr.take() {
let buf = std::sync::Arc::clone(&stderr_buffer);
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut reader = BufReader::new(stderr);
let mut line = String::new();
while reader
.read_line(&mut line)
.await
.map(|n| n > 0)
.unwrap_or(false)
{
let mut b = buf.lock();
let new_len = b.len() + line.len();
if new_len > 16 * 1024 {
let drop_n = new_len - 16 * 1024;
let drop_n = std::cmp::min(drop_n, b.len());
b.drain(0..drop_n);
}
b.push_str(&line);
line.clear();
}
});
}
for _ in 0..20 {
@@ -105,6 +140,40 @@ impl LocalAiService {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
// Health probe timed out. The serve child is unhealthy and may be
// holding the Ollama port — kill it before returning so the next
// bootstrap attempt isn't blocked by a zombie listener.
if let Err(err) = serve_child.kill().await {
log::warn!("[local_ai] failed to kill unhealthy `ollama serve` child: {err}");
}
// Classify the failure from captured stderr.
let stderr_snapshot = stderr_buffer.lock().clone();
let lowered = stderr_snapshot.to_ascii_lowercase();
// Match only explicit Controlled Folder Access markers. Generic
// strings like "access is denied" or "is not recognized as a trusted"
// appear in many unrelated Windows errors and previously caused us
// to surface a misleading CFA remediation message.
let cfa_signatures = ["controlled folder access", "operation was blocked"];
let cfa_hit = cfa_signatures.iter().any(|sig| lowered.contains(sig));
if cfa_hit {
log::warn!(
"[local_ai] Ollama failed to start — Controlled Folder Access blocked it. \
stderr tail: {stderr_snapshot}"
);
self.status.lock().error_detail = Some(stderr_snapshot);
return Err(format!(
"Ollama was blocked by Windows Controlled Folder Access. \
Open Windows Security → Ransomware protection → Allow an app \
through Controlled folder access, and add `{}`.",
ollama_cmd.display()
));
}
// Non-CFA timeout — surface the stderr tail anyway for diagnosis.
if !stderr_snapshot.is_empty() {
log::warn!("[local_ai] Ollama not reachable. stderr tail: {stderr_snapshot}");
self.status.lock().error_detail = Some(stderr_snapshot);
}
Err("Ollama runtime is not reachable after fresh install. Start `ollama serve` manually and retry.".to_string())
}
@@ -186,6 +255,71 @@ impl LocalAiService {
.await
.map_err(|e| format!("failed to create Ollama install directory: {e}"))?;
// Crash-resume guard: Inno Setup's installer is spawned via
// PowerShell's `Start-Process`, which creates a top-level process.
// It outlives OpenHuman crashing, the user closing the app, or
// the bootstrap task being cancelled. If a prior launch left an
// OllamaSetup.exe running, wait for it instead of starting a
// second one — two concurrent installers race on the same dir
// and corrupt the install.
if crate::openhuman::local_ai::install::is_ollama_installer_running() {
log::info!(
"[local_ai] detected in-flight OllamaSetup.exe — \
waiting for it to finish before deciding whether to install"
);
{
let mut status = self.status.lock();
status.state = "installing".to_string();
status.warning = Some("Resuming Ollama install from a previous launch".to_string());
status.error_detail = None;
status.error_category = None;
}
// Bounded wait: a stuck OllamaSetup.exe (e.g. Inno Setup dialog
// waiting on user input) must not block app startup forever. Five
// minutes covers a slow download + UAC prompt; past that we mark
// the install as failed-but-recoverable and let the caller decide.
let wait_start = std::time::Instant::now();
const INSTALLER_WAIT_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(5 * 60);
let mut timed_out = false;
while crate::openhuman::local_ai::install::is_ollama_installer_running() {
if wait_start.elapsed() >= INSTALLER_WAIT_TIMEOUT {
timed_out = true;
break;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if timed_out {
log::warn!(
"[local_ai] OllamaSetup.exe still running after {}s — giving up the wait",
INSTALLER_WAIT_TIMEOUT.as_secs()
);
let mut status = self.status.lock();
status.state = "install_failed".to_string();
status.warning = None;
status.error_category = Some("install_stuck".to_string());
status.error_detail = Some(format!(
"Previous OllamaSetup.exe install was still running after {}s. \
Cancel the installer (System tray / Task Manager) and retry.",
INSTALLER_WAIT_TIMEOUT.as_secs()
));
return Err("Previous Ollama installer is stuck. Cancel it and retry.".to_string());
}
// The prior installer is gone. If it succeeded, our regular
// discovery paths will find the binary and we can short-circuit
// the install entirely. If it failed, fall through and run a
// fresh install below.
if find_workspace_ollama_binary(config).is_some()
|| find_system_ollama_binary().is_some()
{
log::info!("[local_ai] resumed prior install completed successfully");
return Ok(());
}
log::warn!(
"[local_ai] prior installer exited but binary not found — running fresh install"
);
}
{
let mut status = self.status.lock();
status.state = "installing".to_string();
@@ -273,6 +407,36 @@ impl LocalAiService {
.unwrap_or(false)
}
/// Filesystem-only precondition: is *any* Ollama binary discoverable?
///
/// This is the cheapest possible check — no process spawns, no HTTP, no
/// timeouts. Callers that need to decide whether it's even worth talking
/// to `/api/tags` should consult this first. Returning `false` here means
/// the UI should drive the user to install Ollama instead of polling for
/// model state that can never appear.
pub(in crate::openhuman::local_ai::service) fn ollama_binary_present(
&self,
config: &Config,
) -> bool {
if let Some(ref custom) = config.local_ai.ollama_binary_path {
if PathBuf::from(custom).is_file() {
return true;
}
}
if let Some(env_path) = std::env::var("OLLAMA_BIN")
.ok()
.filter(|v| !v.trim().is_empty())
{
if PathBuf::from(env_path).is_file() {
return true;
}
}
if find_workspace_ollama_binary(config).is_some() {
return true;
}
find_system_ollama_binary().is_some()
}
pub(in crate::openhuman::local_ai::service) async fn ensure_models_available(
&self,
config: &Config,
@@ -909,9 +1073,23 @@ impl LocalAiService {
&self,
model: &str,
) -> Result<bool, String> {
// Issue the /api/tags GET directly. We previously short-circuited via
// ollama_healthy(), but that doubled the number of /api/tags round-trips
// on healthy polls (one probe + one tags fetch). With three has_model()
// calls per assets_status poll (chat, vision, embedding) that was 6
// network calls instead of 3. The 500ms connect_timeout on the shared
// reqwest client (set in bootstrap.rs) bounds the cost when the server
// is down — the connect failure surfaces as Err, same as ollama_healthy()
// would have surfaced as `false`.
log::debug!("[local_ai] has_model: checking for model `{model}`");
let response = self
.http
.get(format!("{}/api/tags", ollama_base_url()))
// Per-request timeout matches list_models (5s). The shared client's
// connect_timeout only bounds the TCP handshake; without this a
// hung server (accepted connection, no response body) would block
// assets_status polls indefinitely.
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.map_err(|e| format!("ollama tags request failed: {e}"))?;
+9
View File
@@ -93,6 +93,12 @@ pub struct LocalAiAssetsStatus {
pub stt: LocalAiAssetStatus,
pub tts: LocalAiAssetStatus,
pub quantization: String,
/// True when an Ollama binary is discoverable on disk (workspace install,
/// system install, or via `OLLAMA_BIN`/configured path). When false, the
/// frontend should render an "Install Ollama" CTA instead of model state —
/// querying `/api/tags` against a missing server otherwise lets a 30s
/// connect timeout cascade through `has_model`.
pub ollama_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,6 +129,9 @@ pub struct LocalAiDownloadsProgress {
pub embedding: LocalAiDownloadProgressItem,
pub stt: LocalAiDownloadProgressItem,
pub tts: LocalAiDownloadProgressItem,
/// Mirrors `LocalAiAssetsStatus::ollama_available` so a single
/// `local_ai.downloads_progress` poll can render the right UI state.
pub ollama_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]