mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
59bbf67e4e
commit
4be66bafd1
@@ -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't installed yet. The “Disabled
|
||||
(cloud fallback)” 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'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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 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<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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user