mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
fix(local-ai): fix false-negative Ollama diagnostics (binary + model detection) (#1327)
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { LocalAiDiagnostics, RepairAction } from '../../../../utils/tauriCommands';
|
||||
import ModelStatusSection from './ModelStatusSection';
|
||||
|
||||
const defaultProps = {
|
||||
status: null,
|
||||
downloads: null,
|
||||
diagnostics: null,
|
||||
isDiagnosticsLoading: false,
|
||||
diagnosticsError: '',
|
||||
statusError: '',
|
||||
isTriggeringDownload: false,
|
||||
bootstrapMessage: '',
|
||||
progress: 0,
|
||||
isIndeterminateDownload: false,
|
||||
isInstalling: false,
|
||||
isInstallError: false,
|
||||
showErrorDetail: false,
|
||||
ollamaPathInput: '',
|
||||
isSettingPath: false,
|
||||
downloadedText: '',
|
||||
speedText: '',
|
||||
etaText: '',
|
||||
statusTone: (_state: string) => '',
|
||||
onRefreshStatus: vi.fn(),
|
||||
onTriggerDownload: vi.fn(),
|
||||
onSetOllamaPath: vi.fn(),
|
||||
onClearOllamaPath: vi.fn(),
|
||||
onSetOllamaPathInput: vi.fn(),
|
||||
onToggleErrorDetail: vi.fn(),
|
||||
onRunDiagnostics: vi.fn(),
|
||||
onRepairAction: vi.fn(),
|
||||
};
|
||||
|
||||
const makeDiagnostics = (overrides: Partial<LocalAiDiagnostics> = {}): LocalAiDiagnostics => ({
|
||||
ollama_running: true,
|
||||
ollama_base_url: 'http://localhost:11434',
|
||||
ollama_binary_path: '/usr/local/bin/ollama',
|
||||
installed_models: [],
|
||||
expected: {
|
||||
chat_model: 'gemma3:1b-it-qat',
|
||||
chat_found: true,
|
||||
embedding_model: 'nomic-embed-text',
|
||||
embedding_found: true,
|
||||
vision_model: 'llava',
|
||||
vision_found: false,
|
||||
},
|
||||
issues: [],
|
||||
repair_actions: [],
|
||||
ok: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('ModelStatusSection diagnostics', () => {
|
||||
it('shows the base URL being checked', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({ ollama_base_url: 'http://192.168.1.5:11434' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTitle('http://192.168.1.5:11434')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Running when server is up', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({ ollama_running: true })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Running')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Not running when server is down', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({ ollama_running: false })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Not running')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Running via external process when binary is null but server is running', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({ ollama_binary_path: null, ollama_running: true })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Running via external process')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Not found when binary is null and server is not running', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({ ollama_binary_path: null, ollama_running: false })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Not found')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the binary path when set', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({ ollama_binary_path: '/opt/homebrew/bin/ollama' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('/opt/homebrew/bin/ollama')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders repair action buttons', () => {
|
||||
const repairActions: RepairAction[] = [
|
||||
{ action: 'install_ollama' },
|
||||
{ action: 'start_server', binary_path: '/usr/local/bin/ollama' },
|
||||
{ action: 'pull_model', model: 'gemma3:1b-it-qat' },
|
||||
];
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({
|
||||
ok: false,
|
||||
issues: ['Ollama server is not running'],
|
||||
repair_actions: repairActions,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Install Ollama')).toBeTruthy();
|
||||
expect(screen.getByText('Start Server')).toBeTruthy();
|
||||
expect(screen.getByText('Pull gemma3:1b-it-qat')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onRepairAction with the correct action when button is clicked', () => {
|
||||
const onRepairAction = vi.fn();
|
||||
const repairActions: RepairAction[] = [{ action: 'install_ollama' }];
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
onRepairAction={onRepairAction}
|
||||
diagnostics={makeDiagnostics({
|
||||
ok: false,
|
||||
issues: ['Ollama server is not running'],
|
||||
repair_actions: repairActions,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Install Ollama'));
|
||||
expect(onRepairAction).toHaveBeenCalledWith({ action: 'install_ollama' });
|
||||
});
|
||||
|
||||
it('calls onRepairAction with pull_model action', () => {
|
||||
const onRepairAction = vi.fn();
|
||||
const repairActions: RepairAction[] = [{ action: 'pull_model', model: 'gemma3:1b-it-qat' }];
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
onRepairAction={onRepairAction}
|
||||
diagnostics={makeDiagnostics({
|
||||
ok: false,
|
||||
issues: ['Chat model is not installed'],
|
||||
repair_actions: repairActions,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Pull gemma3:1b-it-qat'));
|
||||
expect(onRepairAction).toHaveBeenCalledWith({
|
||||
action: 'pull_model',
|
||||
model: 'gemma3:1b-it-qat',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render repair actions section when repair_actions is empty', () => {
|
||||
render(
|
||||
<ModelStatusSection {...defaultProps} diagnostics={makeDiagnostics({ repair_actions: [] })} />
|
||||
);
|
||||
expect(screen.queryByText('Suggested Fixes')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows all checks passed when ok is true', () => {
|
||||
render(<ModelStatusSection {...defaultProps} diagnostics={makeDiagnostics({ ok: true })} />);
|
||||
expect(screen.getByText('All checks passed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows issue count when ok is false', () => {
|
||||
render(
|
||||
<ModelStatusSection
|
||||
{...defaultProps}
|
||||
diagnostics={makeDiagnostics({
|
||||
ok: false,
|
||||
issues: ['issue one', 'issue two'],
|
||||
repair_actions: [],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('2 issue(s) found')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders prompt text when diagnostics is null', () => {
|
||||
render(<ModelStatusSection {...defaultProps} diagnostics={null} />);
|
||||
expect(screen.getByText(/Click.*Run Diagnostics/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
LocalAiDiagnostics,
|
||||
LocalAiDownloadsProgress,
|
||||
LocalAiStatus,
|
||||
RepairAction,
|
||||
} from '../../../../utils/tauriCommands';
|
||||
|
||||
interface ModelStatusSectionProps {
|
||||
@@ -32,8 +33,20 @@ interface ModelStatusSectionProps {
|
||||
onSetOllamaPathInput: (value: string) => void;
|
||||
onToggleErrorDetail: () => void;
|
||||
onRunDiagnostics: () => void;
|
||||
onRepairAction?: (action: RepairAction) => void;
|
||||
}
|
||||
|
||||
const repairActionLabel = (action: RepairAction): string => {
|
||||
switch (action.action) {
|
||||
case 'install_ollama':
|
||||
return 'Install Ollama';
|
||||
case 'start_server':
|
||||
return 'Start Server';
|
||||
case 'pull_model':
|
||||
return `Pull ${action.model}`;
|
||||
}
|
||||
};
|
||||
|
||||
const ModelStatusSection = ({
|
||||
status,
|
||||
downloads,
|
||||
@@ -61,6 +74,7 @@ const ModelStatusSection = ({
|
||||
onSetOllamaPathInput,
|
||||
onToggleErrorDetail,
|
||||
onRunDiagnostics,
|
||||
onRepairAction,
|
||||
}: ModelStatusSectionProps) => {
|
||||
return (
|
||||
<>
|
||||
@@ -288,13 +302,27 @@ const ModelStatusSection = ({
|
||||
className={`mt-1 font-medium ${diagnostics.ollama_running ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{diagnostics.ollama_running ? 'Running' : 'Not running'}
|
||||
</div>
|
||||
{diagnostics.ollama_base_url && (
|
||||
<div
|
||||
className="mt-0.5 text-stone-400 truncate text-[10px]"
|
||||
title={diagnostics.ollama_base_url}>
|
||||
{diagnostics.ollama_base_url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-stone-200 p-2">
|
||||
<div className="text-stone-400 uppercase tracking-wide text-[10px]">Binary</div>
|
||||
<div
|
||||
className="mt-1 text-stone-600 truncate"
|
||||
title={diagnostics.ollama_binary_path ?? 'Not found'}>
|
||||
{diagnostics.ollama_binary_path ?? 'Not found'}
|
||||
title={
|
||||
diagnostics.ollama_binary_path ??
|
||||
(diagnostics.ollama_running ? 'External process' : 'Not found')
|
||||
}>
|
||||
{diagnostics.ollama_binary_path === null
|
||||
? diagnostics.ollama_running
|
||||
? 'Running via external process'
|
||||
: 'Not found'
|
||||
: diagnostics.ollama_binary_path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,6 +401,24 @@ const ModelStatusSection = ({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diagnostics.repair_actions && diagnostics.repair_actions.length > 0 && (
|
||||
<div>
|
||||
<div className="text-amber-700 uppercase tracking-wide text-[10px] mb-1">
|
||||
Suggested Fixes
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{diagnostics.repair_actions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onRepairAction?.(action)}
|
||||
className="px-2.5 py-1 text-xs rounded-md bg-amber-50 border border-amber-300 text-amber-800 hover:bg-amber-100 transition-colors">
|
||||
{repairActionLabel(action)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -195,8 +195,14 @@ export interface ApplyPresetResult {
|
||||
local_ai_enabled?: boolean;
|
||||
}
|
||||
|
||||
export type RepairAction =
|
||||
| { action: 'install_ollama' }
|
||||
| { action: 'start_server'; binary_path: string | null }
|
||||
| { action: 'pull_model'; model: string };
|
||||
|
||||
export interface LocalAiDiagnostics {
|
||||
ollama_running: boolean;
|
||||
ollama_base_url: string;
|
||||
ollama_binary_path: string | null;
|
||||
vision_mode?: string;
|
||||
installed_models: Array<{ name: string; size?: number | null; modified_at?: string | null }>;
|
||||
@@ -209,6 +215,7 @@ export interface LocalAiDiagnostics {
|
||||
vision_found: boolean;
|
||||
};
|
||||
issues: string[];
|
||||
repair_actions: RepairAction[];
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,12 +191,27 @@ pub(crate) fn find_system_ollama_binary() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
let common = [
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/usr/local/bin/ollama"),
|
||||
PathBuf::from("/opt/homebrew/bin/ollama"),
|
||||
];
|
||||
for candidate in common {
|
||||
// Ollama.app installed in /Applications or ~/Applications ships its
|
||||
// CLI binary inside the app bundle resources directory.
|
||||
let bundle_rel = std::path::Path::new("Applications")
|
||||
.join("Ollama.app")
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join("ollama");
|
||||
candidates.push(PathBuf::from("/").join(&bundle_rel));
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
candidates.push(PathBuf::from(home).join(&bundle_rel));
|
||||
}
|
||||
for candidate in candidates {
|
||||
if candidate.is_file() {
|
||||
log::debug!(
|
||||
"[local_ai] found system Ollama at macOS path: {}",
|
||||
candidate.display()
|
||||
);
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
@@ -325,7 +340,6 @@ mod tests {
|
||||
#[test]
|
||||
fn find_system_ollama_binary_finds_binary_via_path() {
|
||||
let _lock = env_lock();
|
||||
// Build a fake binary and inject its directory as the first PATH entry.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let binary_name = if cfg!(windows) {
|
||||
"ollama.exe"
|
||||
@@ -351,4 +365,53 @@ mod tests {
|
||||
"PATH-based lookup should succeed with a valid stub"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn find_system_ollama_binary_detects_macos_app_bundle_in_applications() {
|
||||
let _lock = env_lock();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Build a fake /Applications/Ollama.app/Contents/Resources/ollama tree.
|
||||
let bundle_bin = tmp
|
||||
.path()
|
||||
.join("Applications")
|
||||
.join("Ollama.app")
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join("ollama");
|
||||
std::fs::create_dir_all(bundle_bin.parent().unwrap()).unwrap();
|
||||
std::fs::write(&bundle_bin, b"stub").unwrap();
|
||||
|
||||
// Clear OLLAMA_BIN, clear PATH so the normal PATH lookup won't find it,
|
||||
// and point HOME to tmp so the ~/Applications branch is exercised via a
|
||||
// separate sub-test below. Here we exercise /Applications by building
|
||||
// the file at root and verifying the function returns it when the static
|
||||
// /Applications path exists — we skip direct-path injection since the
|
||||
// function hard-codes "/" as root and we cannot mock the filesystem.
|
||||
// Instead verify the ~/Applications path via the HOME trick.
|
||||
let _home_guard = EnvGuard::set("HOME", tmp.path());
|
||||
let _bin_guard = EnvGuard::unset("OLLAMA_BIN");
|
||||
let prev_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let _path_guard = EnvGuard::set("PATH", "");
|
||||
|
||||
// ~/Applications bundle path is under HOME.
|
||||
let home_bundle = tmp
|
||||
.path()
|
||||
.join("Applications")
|
||||
.join("Ollama.app")
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join("ollama");
|
||||
std::fs::create_dir_all(home_bundle.parent().unwrap()).unwrap();
|
||||
std::fs::write(&home_bundle, b"stub").unwrap();
|
||||
|
||||
let found = find_system_ollama_binary();
|
||||
assert_eq!(
|
||||
found.as_deref(),
|
||||
Some(home_bundle.as_path()),
|
||||
"should find Ollama in ~/Applications bundle"
|
||||
);
|
||||
drop(_path_guard);
|
||||
let _ = prev_path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,35 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
|
||||
|
||||
/// Returns the effective Ollama base URL, honouring the
|
||||
/// `OPENHUMAN_OLLAMA_BASE_URL` env override so tests can point it at
|
||||
/// a local mock server. In production builds this is always
|
||||
/// [`DEFAULT_OLLAMA_BASE_URL`] unless an operator has deliberately
|
||||
/// pointed the sidecar at a remote Ollama.
|
||||
/// Returns the effective Ollama base URL.
|
||||
///
|
||||
/// Priority (highest to lowest):
|
||||
/// 1. `OPENHUMAN_OLLAMA_BASE_URL` — app-specific override, used in tests.
|
||||
/// 2. `OLLAMA_HOST` — Ollama's own env var; normalized to a full URL by
|
||||
/// prepending `http://` when no scheme is present.
|
||||
/// 3. [`DEFAULT_OLLAMA_BASE_URL`] — `http://localhost:11434`.
|
||||
pub(crate) fn ollama_base_url() -> String {
|
||||
match std::env::var("OPENHUMAN_OLLAMA_BASE_URL") {
|
||||
Ok(url) if !url.trim().is_empty() => url.trim().trim_end_matches('/').to_string(),
|
||||
_ => DEFAULT_OLLAMA_BASE_URL.to_string(),
|
||||
if let Ok(url) = std::env::var("OPENHUMAN_OLLAMA_BASE_URL") {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.trim_end_matches('/').to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(host) = std::env::var("OLLAMA_HOST") {
|
||||
let trimmed = host.trim().trim_end_matches('/');
|
||||
if !trimmed.is_empty() {
|
||||
let url = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
log::debug!("[local_ai] ollama_base_url: using OLLAMA_HOST -> {url}");
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_OLLAMA_BASE_URL.to_string()
|
||||
}
|
||||
|
||||
/// Back-compat constant kept at its original value for callers that
|
||||
@@ -271,8 +290,10 @@ mod tests {
|
||||
// calls from other tests in the same binary.
|
||||
|
||||
const ENV_VAR: &str = "OPENHUMAN_OLLAMA_BASE_URL";
|
||||
const OLLAMA_HOST_VAR: &str = "OLLAMA_HOST";
|
||||
|
||||
struct OllamaEnvGuard {
|
||||
var: &'static str,
|
||||
prior: Option<String>,
|
||||
}
|
||||
|
||||
@@ -280,13 +301,31 @@ mod tests {
|
||||
fn clear() -> Self {
|
||||
let prior = std::env::var(ENV_VAR).ok();
|
||||
unsafe { std::env::remove_var(ENV_VAR) };
|
||||
Self { prior }
|
||||
Self {
|
||||
var: ENV_VAR,
|
||||
prior,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(value: &str) -> Self {
|
||||
let prior = std::env::var(ENV_VAR).ok();
|
||||
unsafe { std::env::set_var(ENV_VAR, value) };
|
||||
Self { prior }
|
||||
Self {
|
||||
var: ENV_VAR,
|
||||
prior,
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_var(var: &'static str) -> Self {
|
||||
let prior = std::env::var(var).ok();
|
||||
unsafe { std::env::remove_var(var) };
|
||||
Self { var, prior }
|
||||
}
|
||||
|
||||
fn set_var(var: &'static str, value: &str) -> Self {
|
||||
let prior = std::env::var(var).ok();
|
||||
unsafe { std::env::set_var(var, value) };
|
||||
Self { var, prior }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,8 +333,8 @@ mod tests {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match self.prior.take() {
|
||||
Some(v) => std::env::set_var(ENV_VAR, v),
|
||||
None => std::env::remove_var(ENV_VAR),
|
||||
Some(v) => std::env::set_var(self.var, v),
|
||||
None => std::env::remove_var(self.var),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,4 +386,52 @@ mod tests {
|
||||
assert_eq!(ollama_base_url(), DEFAULT_OLLAMA_BASE_URL);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_uses_ollama_host_when_openhuman_var_unset() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "192.168.1.5:11434");
|
||||
assert_eq!(ollama_base_url(), "http://192.168.1.5:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_prepends_http_for_host_without_scheme() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "myhost:11434");
|
||||
assert_eq!(ollama_base_url(), "http://myhost:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_preserves_existing_scheme_in_ollama_host() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "https://remote-ollama.example.com");
|
||||
assert_eq!(ollama_base_url(), "https://remote-ollama.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_openhuman_var_takes_priority_over_ollama_host() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::set("http://127.0.0.1:55555");
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "192.168.1.5:11434");
|
||||
assert_eq!(ollama_base_url(), "http://127.0.0.1:55555");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_ignores_empty_ollama_host() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, " ");
|
||||
assert_eq!(ollama_base_url(), DEFAULT_OLLAMA_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_strips_trailing_slash_from_ollama_host() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "myhost:11434/");
|
||||
assert_eq!(ollama_base_url(), "http://myhost:11434");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,8 +579,15 @@ impl LocalAiService {
|
||||
/// Run full diagnostics: check Ollama server health, list installed models,
|
||||
/// and verify expected models are present. Returns a JSON-serializable report.
|
||||
pub async fn diagnostics(&self, config: &Config) -> Result<serde_json::Value, String> {
|
||||
let base_url = ollama_base_url();
|
||||
let healthy = self.ollama_healthy().await;
|
||||
|
||||
log::debug!(
|
||||
"[local_ai] diagnostics: entry base_url={} healthy={}",
|
||||
base_url,
|
||||
healthy
|
||||
);
|
||||
|
||||
let (models, tags_error) = if healthy {
|
||||
match self.list_models().await {
|
||||
Ok(models) => (models, None),
|
||||
@@ -609,20 +616,38 @@ impl LocalAiService {
|
||||
let binary_path = self.resolve_binary_path(config);
|
||||
|
||||
let mut issues: Vec<String> = Vec::new();
|
||||
let mut repair_actions: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
if !healthy {
|
||||
issues.push(format!(
|
||||
"Ollama server is not running or not reachable at {}",
|
||||
ollama_base_url()
|
||||
base_url
|
||||
));
|
||||
if binary_path.is_none() {
|
||||
repair_actions.push(serde_json::json!({"action": "install_ollama"}));
|
||||
} else {
|
||||
repair_actions.push(serde_json::json!({
|
||||
"action": "start_server",
|
||||
"binary_path": binary_path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if healthy && !chat_found {
|
||||
issues.push(format!("Chat model `{}` is not installed", expected_chat));
|
||||
repair_actions.push(serde_json::json!({
|
||||
"action": "pull_model",
|
||||
"model": expected_chat,
|
||||
}));
|
||||
}
|
||||
if healthy && config.local_ai.preload_embedding_model && !embedding_found {
|
||||
issues.push(format!(
|
||||
"Embedding model `{}` is not installed",
|
||||
expected_embedding
|
||||
));
|
||||
repair_actions.push(serde_json::json!({
|
||||
"action": "pull_model",
|
||||
"model": expected_embedding,
|
||||
}));
|
||||
}
|
||||
if healthy
|
||||
&& matches!(
|
||||
@@ -635,20 +660,26 @@ impl LocalAiService {
|
||||
"Vision model `{}` is not installed",
|
||||
expected_vision
|
||||
));
|
||||
repair_actions.push(serde_json::json!({
|
||||
"action": "pull_model",
|
||||
"model": expected_vision,
|
||||
}));
|
||||
}
|
||||
if let Some(ref e) = tags_error {
|
||||
issues.push(format!("Failed to list models: {e}"));
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[local_ai] diagnostics: healthy={} models={} issues={}",
|
||||
"[local_ai] diagnostics: healthy={} models={} issues={} repair_actions={}",
|
||||
healthy,
|
||||
models.len(),
|
||||
issues.len(),
|
||||
repair_actions.len(),
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ollama_running": healthy,
|
||||
"ollama_base_url": base_url,
|
||||
"ollama_binary_path": binary_path,
|
||||
"installed_models": models,
|
||||
"vision_mode": presets::vision_mode_for_config(&config.local_ai),
|
||||
@@ -661,6 +692,7 @@ impl LocalAiService {
|
||||
"vision_found": vision_found,
|
||||
},
|
||||
"issues": issues,
|
||||
"repair_actions": repair_actions,
|
||||
"ok": issues.is_empty(),
|
||||
}))
|
||||
}
|
||||
@@ -748,16 +780,63 @@ impl LocalAiService {
|
||||
}
|
||||
|
||||
fn resolve_binary_path(&self, config: &Config) -> Option<String> {
|
||||
// 1. Explicit user-configured path in Settings.
|
||||
if let Some(ref custom) = config.local_ai.ollama_binary_path {
|
||||
let p = PathBuf::from(custom);
|
||||
if p.is_file() {
|
||||
log::debug!(
|
||||
"[local_ai] resolve_binary_path: using configured path {}",
|
||||
p.display()
|
||||
);
|
||||
return Some(custom.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. OLLAMA_BIN env var (mirrors bootstrap detection).
|
||||
if let Some(from_env) = std::env::var("OLLAMA_BIN")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
{
|
||||
let p = PathBuf::from(&from_env);
|
||||
if p.is_file() {
|
||||
log::debug!(
|
||||
"[local_ai] resolve_binary_path: using OLLAMA_BIN {}",
|
||||
p.display()
|
||||
);
|
||||
return Some(from_env);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Workspace-managed binary installed by the app.
|
||||
let workspace_bin = workspace_ollama_binary(config);
|
||||
if workspace_bin.is_file() {
|
||||
log::debug!(
|
||||
"[local_ai] resolve_binary_path: using workspace binary {}",
|
||||
workspace_bin.display()
|
||||
);
|
||||
return Some(workspace_bin.display().to_string());
|
||||
}
|
||||
|
||||
// 4. Bare `ollama` on PATH — same as bootstrap's `which ollama` step.
|
||||
let binary_name = if cfg!(windows) {
|
||||
"ollama.exe"
|
||||
} else {
|
||||
"ollama"
|
||||
};
|
||||
if let Some(path_var) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
let candidate = dir.join(binary_name);
|
||||
if candidate.is_file() {
|
||||
log::debug!(
|
||||
"[local_ai] resolve_binary_path: found on PATH at {}",
|
||||
candidate.display()
|
||||
);
|
||||
return Some(candidate.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Platform-specific well-known locations (macOS bundles, Windows, Linux).
|
||||
crate::openhuman::local_ai::install::find_system_ollama_binary()
|
||||
.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ async fn diagnostics_reports_server_unreachable_when_url_unbound() {
|
||||
let service = LocalAiService::new(&config);
|
||||
let diag = service.diagnostics(&config).await.expect("diagnostics");
|
||||
assert_eq!(diag["ollama_running"], false);
|
||||
assert!(
|
||||
diag["ollama_base_url"].as_str().is_some(),
|
||||
"diagnostics must include ollama_base_url"
|
||||
);
|
||||
let issues = diag["issues"].as_array().cloned().unwrap_or_default();
|
||||
assert!(
|
||||
!issues.is_empty(),
|
||||
@@ -141,6 +145,14 @@ async fn diagnostics_reports_server_unreachable_when_url_unbound() {
|
||||
assert!(issues
|
||||
.iter()
|
||||
.any(|v| v.as_str().unwrap_or("").contains("not running")));
|
||||
let repair_actions = diag["repair_actions"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!repair_actions.is_empty(),
|
||||
"unreachable server must produce at least one repair action"
|
||||
);
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
|
||||
}
|
||||
@@ -162,9 +174,25 @@ async fn diagnostics_with_running_server_but_missing_models_flags_issues() {
|
||||
let service = LocalAiService::new(&config);
|
||||
let diag = service.diagnostics(&config).await.expect("diagnostics");
|
||||
assert_eq!(diag["ollama_running"], true);
|
||||
assert_eq!(
|
||||
diag["ollama_base_url"].as_str(),
|
||||
Some(base.as_str()),
|
||||
"diagnostics must echo back the base url being checked"
|
||||
);
|
||||
// No models are installed → expected chat model issue surfaces.
|
||||
let issues = diag["issues"].as_array().cloned().unwrap_or_default();
|
||||
assert!(!issues.is_empty());
|
||||
// Missing chat model should produce a pull_model repair action.
|
||||
let repair_actions = diag["repair_actions"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
repair_actions
|
||||
.iter()
|
||||
.any(|a| a["action"].as_str() == Some("pull_model")),
|
||||
"missing models must produce pull_model repair action"
|
||||
);
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
|
||||
}
|
||||
@@ -178,15 +206,19 @@ async fn diagnostics_ok_when_expected_models_are_present() {
|
||||
|
||||
let config = Config::default();
|
||||
let chat = crate::openhuman::local_ai::model_ids::effective_chat_model_id(&config);
|
||||
let embedding = crate::openhuman::local_ai::model_ids::effective_embedding_model_id(&config);
|
||||
let chat_tag = format!("{}:latest", chat);
|
||||
let embed_tag = format!("{}:latest", embedding);
|
||||
let app = Router::new().route(
|
||||
"/api/tags",
|
||||
get(move || {
|
||||
let chat_tag = chat_tag.clone();
|
||||
let embed_tag = embed_tag.clone();
|
||||
async move {
|
||||
Json(json!({
|
||||
"models": [
|
||||
{ "name": chat_tag, "modified_at": "", "size": 1u64, "digest": "d" }
|
||||
{ "name": chat_tag, "modified_at": "", "size": 1u64, "digest": "d" },
|
||||
{ "name": embed_tag, "modified_at": "", "size": 2u64, "digest": "e" },
|
||||
]
|
||||
}))
|
||||
}
|
||||
@@ -201,6 +233,124 @@ async fn diagnostics_ok_when_expected_models_are_present() {
|
||||
let diag = service.diagnostics(&config).await.expect("diagnostics");
|
||||
assert_eq!(diag["ollama_running"], true);
|
||||
assert_eq!(diag["expected"]["chat_found"], true);
|
||||
assert_eq!(diag["expected"]["embedding_found"], true);
|
||||
assert!(diag["ollama_base_url"].as_str().is_some());
|
||||
// All required models present → no issues and no repair actions.
|
||||
let issues = diag["issues"].as_array().cloned().unwrap_or_default();
|
||||
assert!(
|
||||
issues.is_empty(),
|
||||
"all models present should produce no issues, got: {:?}",
|
||||
issues
|
||||
);
|
||||
let repair_actions = diag["repair_actions"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
repair_actions.is_empty(),
|
||||
"no issues should produce no repair actions"
|
||||
);
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_binary_path_finds_binary_via_ollama_bin_env() {
|
||||
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
|
||||
.lock()
|
||||
.expect("local ai mutex");
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let fake_bin = tmp.path().join(if cfg!(windows) {
|
||||
"ollama.exe"
|
||||
} else {
|
||||
"ollama"
|
||||
});
|
||||
std::fs::write(&fake_bin, b"stub").unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("OLLAMA_BIN", fake_bin.to_str().unwrap());
|
||||
// Point the base URL at a dead port so we don't depend on a real server.
|
||||
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1");
|
||||
}
|
||||
|
||||
let config = Config::default();
|
||||
let service = LocalAiService::new(&config);
|
||||
let diag = service.diagnostics(&config).await.expect("diagnostics");
|
||||
assert_eq!(
|
||||
diag["ollama_binary_path"].as_str(),
|
||||
Some(fake_bin.to_str().unwrap()),
|
||||
"diagnostics should resolve binary via OLLAMA_BIN"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OLLAMA_BIN");
|
||||
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_repair_actions_include_start_server_when_binary_known() {
|
||||
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
|
||||
.lock()
|
||||
.expect("local ai mutex");
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let fake_bin = tmp.path().join(if cfg!(windows) {
|
||||
"ollama.exe"
|
||||
} else {
|
||||
"ollama"
|
||||
});
|
||||
std::fs::write(&fake_bin, b"stub").unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("OLLAMA_BIN", fake_bin.to_str().unwrap());
|
||||
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1");
|
||||
}
|
||||
|
||||
let config = Config::default();
|
||||
let service = LocalAiService::new(&config);
|
||||
let diag = service.diagnostics(&config).await.expect("diagnostics");
|
||||
|
||||
assert_eq!(diag["ollama_running"], false);
|
||||
let repair_actions = diag["repair_actions"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
repair_actions
|
||||
.iter()
|
||||
.any(|a| a["action"].as_str() == Some("start_server")),
|
||||
"when binary is known but server is down, repair action should be start_server"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OLLAMA_BIN");
|
||||
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_repair_actions_field_always_present() {
|
||||
// Verifies that the "repair_actions" key is always present in the diagnostics
|
||||
// JSON, regardless of the server state, so the UI can always iterate over it.
|
||||
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
|
||||
.lock()
|
||||
.expect("local ai mutex");
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1");
|
||||
}
|
||||
let config = Config::default();
|
||||
let service = LocalAiService::new(&config);
|
||||
let diag = service.diagnostics(&config).await.expect("diagnostics");
|
||||
|
||||
assert!(
|
||||
diag["repair_actions"].is_array(),
|
||||
"repair_actions must always be a JSON array"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user