From 4be66bafd1c642af853257d15b64bb726a56491d Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 13 May 2026 09:01:23 +0530 Subject: [PATCH] feat(local_ai): Ollama precondition gate + install UX hardening (#1475) (#1569) Co-authored-by: Steven Enamakel --- .../settings/panels/LocalModelPanel.tsx | 195 ++++++++++++------ .../local-model/DeviceCapabilitySection.tsx | 141 ++++++++++++- .../panels/local-model/ModelStatusSection.tsx | 107 ++++++++++ app/src/utils/tauriCommands/localAi.ts | 9 + src/openhuman/local_ai/install.rs | 100 ++++++++- src/openhuman/local_ai/service/assets.rs | 20 +- src/openhuman/local_ai/service/bootstrap.rs | 12 +- .../local_ai/service/ollama_admin.rs | 184 ++++++++++++++++- src/openhuman/local_ai/types.rs | 9 + 9 files changed, 701 insertions(+), 76 deletions(-) diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx index 14fe7a8ff..5077f273e 100644 --- a/app/src/components/settings/panels/LocalModelPanel.tsx +++ b/app/src/components/settings/panels/LocalModelPanel.tsx @@ -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()` 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 (
{ 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 */} -
-

Model Status

+ {/* + 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)}` : ''} - -
+
+ State:{' '} + + {currentState ?? 'unknown'} +
- )} - {bootstrapMessage &&
{bootstrapMessage}
} + {(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} +
+ +
- )} -
+ + {statusError && ( +
+ {statusError} +
+ )} +
+ )}
diff --git a/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx b/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx index 45ed4b33c..b910d2e2f 100644 --- a/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx +++ b/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx @@ -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(null); const [applyError, setApplyError] = useState(''); const [applySuccess, setApplySuccess] = useState(null); @@ -92,6 +127,93 @@ const DeviceCapabilitySection = ({
)} + {presetsData && !ollamaAvailable && ( +
+ {installInProgress ? ( + <> +
+
+
+ Installing Ollama + {installState === 'downloading' ? ' (downloading models)' : '…'} +
+
+
+ {installWarning ?? + 'Downloading the OllamaSetup installer (~2 GB) and unpacking it. This can take a minute on first install.'} +
+
+
+
+ + ) : installFailed ? ( + <> +
Ollama install failed
+
+ {installWarning ?? + 'The installer exited before Ollama was usable. Click retry to try again, or install manually from ollama.com.'} +
+ {installError && ( +
+                  {installError}
+                
+ )} +
+ {onTriggerOllamaInstall && ( + + )} + + Install manually + +
+ + ) : ( + <> +
+ Install Ollama first. Local + tiers run on the Ollama runtime, which isn't installed yet. The “Disabled + (cloud fallback)” option stays available either way. +
+
+ {onTriggerOllamaInstall && ( + + )} + + Install manually + +
+ + )} +
+ )} + {presetsData && (
{/* Disabled — Cloud fallback card (always available, recommended on low-RAM) */} @@ -112,7 +234,7 @@ const DeviceCapabilitySection = ({ Active )} - {presetsData.recommend_disabled && !isDisabledActive && ( + {(presetsData.recommend_disabled || !ollamaAvailable) && !isDisabledActive && ( Recommended @@ -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 ( + + Install manually + +
+ + {isInstallError && status?.error_detail && ( +
+ + {showErrorDetail && ( +
+                {status.error_detail}
+              
+ )} +
+ )} + +
+
+ Already installed in a custom location? +
+
+ Point us at the binary and we'll use it instead of running the installer. +
+
+ 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" + /> + + {ollamaPathInput && ( + + )} +
+
+
+ ); + } + return ( <>
diff --git a/app/src/utils/tauriCommands/localAi.ts b/app/src/utils/tauriCommands/localAi.ts index 65cd151fe..1db167c2b 100644 --- a/app/src/utils/tauriCommands/localAi.ts +++ b/app/src/utils/tauriCommands/localAi.ts @@ -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 { diff --git a/src/openhuman/local_ai/install.rs b/src/openhuman/local_ai/install.rs index 72c1f9396..84027b82c 100644 --- a/src/openhuman/local_ai/install.rs +++ b/src/openhuman/local_ai/install.rs @@ -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 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 { #[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 60–120s 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 Result Result30s 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); diff --git a/src/openhuman/local_ai/service/ollama_admin.rs b/src/openhuman/local_ai/service/ollama_admin.rs index 1bce600ec..f8744238a 100644 --- a/src/openhuman/local_ai/service/ollama_admin.rs +++ b/src/openhuman/local_ai/service/ollama_admin.rs @@ -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 { + // 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}"))?; diff --git a/src/openhuman/local_ai/types.rs b/src/openhuman/local_ai/types.rs index 2ae881c6f..cc5873ca1 100644 --- a/src/openhuman/local_ai/types.rs +++ b/src/openhuman/local_ai/types.rs @@ -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)]