Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .claude/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **`OPENHUMAN_LOCAL_AI_TIER` env var** overrides the selected tier at config load time (in `load.rs`).
- **Frontend tier selector** is in `LocalModelPanel.tsx` under Settings > Local AI Model. Uses `coreRpcClient` to call 3 RPC methods: `local_ai_device_profile`, `local_ai_presets`, `local_ai_apply_preset`.
- **Default config maps to Medium tier** (`gemma3:4b-it-qat`). If someone changes `model_ids.rs` defaults, they should keep `presets.rs` in sync.
- **`ollama_base_url()` previously ignored `config.local_ai.base_url`** — It only read env vars. Fixed in feat/ollama-external-server-url by adding `ollama_base_url_from_config(config)`. Any new Ollama URL resolution must go through the config-aware helper, not the env-only one.
- **`LocalModelDebugPanel.tsx` must seed URL from config on mount** — Previously initialized `ollamaBaseUrlInput` to the hardcoded default and only loaded the persisted URL when diagnostics ran. Fix: `useEffect` on mount calls `openhumanGetConfig()` and sets state from `config.local_ai.base_url`. Pattern to follow for any settings field backed by Rust config.

## Core process (in-process, no sidecar)

Expand Down Expand Up @@ -149,6 +151,7 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **`pnpm typecheck` script was renamed** — Check `app/package.json` for the current name; as of issue #830 work, use `pnpm workspace openhuman-app compile` for tsc checks.
- **PR #745 (command palette) merged without its deps** — `@radix-ui/react-dialog`, `cmdk`, and `@testing-library/user-event` are missing from `package.json`. Install them if tsc fails after syncing main.
- **Pre-push hooks fail on upstream lint warnings** — ESLint warns on `setState` in effects and unused `eslint-disable` directives inherited from upstream. Use `--no-verify` only when the lint errors are pre-existing upstream issues, not new code.
- **`pnpm test:coverage` ENOENT on `coverage/.tmp/coverage-0.json`** — Race condition in coverage file collection; flaky, not reproducible every run. Use `pnpm debug unit` instead — runs Vitest without coverage, faster and reliable for iteration.
Comment thread
M3gA-Mind marked this conversation as resolved.

## Mascot Native Window (macOS)

Expand Down Expand Up @@ -188,3 +191,35 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **`pnpm core:stage`** — no-op (sidecar removed in PR #1061). Use `pnpm dev:app` for full Tauri+core dev.
- **Kill stuck processes** — `lsof -i :7788` then `kill <PID>`. Useful when `dev:app` reports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery.
- **Skills runtime removed** — the QuickJS / `rquickjs` runtime is gone; `src/openhuman/skills/` is metadata-only ("Legacy skill metadata helpers retained after QuickJS runtime removal"). Skill execution surfaces are being rebuilt; don't assume a `.skill` can run end-to-end without checking the current code.

## Project Board & Issue Queries

- **Project #2 paginates at 100 items** — Board has 627+ items. Use GraphQL cursor pagination to find all open P0 issues; a single query only returns the first 100.
- **jq regex `\s+` causes parse errors** — Use plain `test("#NNNN")` to check if a PR/issue body references an issue number. `\s+` in jq regex triggers parse errors.
- **Most open P0s are security or Linux AppImage GLIBC issues** — When triaging P0s, filter for those categories first.
- **Project #2 shows only closed items on the board view** — Use `gh issue list --repo tinyhumansai/openhuman --state open --assignee ""` to find unassigned open issues instead of querying the project board.
- **Check linked PRs via timeline API, not body regex** — `gh api repos/tinyhumansai/openhuman/issues/$N/timeline --paginate | jq '[.[] | select(.event == "cross-referenced" and .source.issue.state == "open")] | length'` is more reliable than searching issue body text for PR references.

## Git Submodules

- **`tauri-cef` and `tauri-plugin-notification` are git submodules** — When upstream/main updates them, fix with `git submodule update --remote --checkout`, not by manually patching the vendored crate.

## Pre-existing Test Failures

- **`composio::action_tool::tests::factory_routes_through_direct_when_mode_is_direct` fails in `cargo test -p openhuman`** — Pre-existing failure unrelated to WhatsApp or any recent branch work. Do not attempt to fix unless explicitly tasked. Also intermittently flaky when run as part of the full suite — see "Pre-existing Flaky Tests" section.

## Workflow Gate (must not skip)

- **Steps 4–6 of `workflow/00-full-workflow.md` are mandatory before committing** — Step 4: architectobot verify. Step 5: full checks (`pnpm test:coverage`, `pnpm build`, `bash scripts/install.sh --dry-run`, PR quality scripts). Step 6: memory-keeper. Skipping any of these violates the workflow contract.
- **Encode architectobot answers in the codecrusher prompt** — When the architectobot plan includes clarifying questions and the user approves specific answers, embed those decisions as explicit constraints in the codecrusher prompt so the agent doesn't re-ask.

## Security Policy

- **Path validation entry point** — `src/openhuman/security/policy.rs` exposes `validate_path` / `validate_parent_path`. All file I/O path validation must go through this API. `is_path_string_allowed()` is a string-only first pass, not sufficient on its own.
- **validate_parent_path before create_dir_all** — For write operations, `validate_parent_path` MUST be called before any `create_dir_all` call. Calling it after allows symlink attacks to create directories outside the workspace before the security check fires (Issue #1927).
- **Tool callers must use `validate_path` / `validate_parent_path`** — All tool implementations under `src/openhuman/tools/impl/filesystem/` must use these functions, not the legacy `is_path_allowed` / `is_resolved_path_allowed`.
- **Security policy test filter** — Run only security policy tests with: `cargo test -p openhuman -- "security::policy"`. Runs the 100 tests in `src/openhuman/security/policy_tests.rs` cleanly.

## Pre-existing Flaky Tests

- **`composio::action_tool` and `agent::harness::session::turn` intermittent failures** — These tests fail randomly when run as part of the full suite (likely shared state or timing), but pass individually. Not related to security/policy changes. Do not treat as blockers for security-module PRs.
98 changes: 98 additions & 0 deletions app/src/components/settings/panels/LocalModelDebugPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import debug from 'debug';
import { useEffect, useMemo, useState } from 'react';

import { useT } from '../../../lib/i18n/I18nContext';
Expand All @@ -15,6 +16,7 @@ import {
type LocalAiSpeechResult,
type LocalAiStatus,
type LocalAiTtsResult,
type OllamaConnectionTestResult,
openhumanLocalAiAssetsStatus,
openhumanLocalAiDiagnostics,
openhumanLocalAiDownloadAsset,
Expand All @@ -23,15 +25,20 @@ import {
openhumanLocalAiPrompt,
openhumanLocalAiStatus,
openhumanLocalAiSummarize,
openhumanLocalAiTestConnection,
openhumanLocalAiTranscribe,
openhumanLocalAiTts,
openhumanLocalAiVisionPrompt,
openhumanUpdateLocalAiSettings,
} from '../../../utils/tauriCommands';
import { openhumanGetConfig } from '../../../utils/tauriCommands/config';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ModelDownloadSection from './local-model/ModelDownloadSection';
import ModelStatusSection from './local-model/ModelStatusSection';

const log = debug('openhuman:local-model-debug');

const statusTone = (state: string): string => {
switch (state) {
case 'ready':
Expand Down Expand Up @@ -93,6 +100,14 @@ const LocalModelDebugPanel = () => {

const [showErrorDetail, setShowErrorDetail] = useState(false);

const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
const [ollamaBaseUrlInput, setOllamaBaseUrlInput] = useState(DEFAULT_OLLAMA_URL);
const [savedOllamaBaseUrl, setSavedOllamaBaseUrl] = useState(DEFAULT_OLLAMA_URL);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionTestResult, setConnectionTestResult] =
useState<OllamaConnectionTestResult | null>(null);
const [isSavingUrl, setIsSavingUrl] = useState(false);

const progress = useMemo(() => {
const downloadProgress = progressFromDownloads(downloads);
if (downloadProgress != null) return downloadProgress;
Expand Down Expand Up @@ -151,6 +166,25 @@ const LocalModelDebugPanel = () => {
};
}, []);

useEffect(() => {
const seedSavedUrl = async () => {
try {
const configResponse = await openhumanGetConfig();
const localAi = configResponse.result?.config?.local_ai as
| Record<string, unknown>
| undefined;
const saved = localAi?.base_url as string | undefined | null;
if (saved && saved.trim()) {
setOllamaBaseUrlInput(saved.trim());
setSavedOllamaBaseUrl(saved.trim());
}
} catch {
// Non-critical — stay on default.
}
};
void seedSavedUrl();
}, []);

const runSummaryTest = async () => {
if (!runtimeEnabled || !summaryInput.trim()) return;
setIsSummaryLoading(true);
Expand Down Expand Up @@ -281,13 +315,68 @@ const LocalModelDebugPanel = () => {
try {
const result = await openhumanLocalAiDiagnostics();
setDiagnostics(result);
if (result.ollama_base_url) {
const reported = result.ollama_base_url;
// Only overwrite the input if the user hasn't made unsaved edits.
setOllamaBaseUrlInput(prev => (prev === savedOllamaBaseUrl ? reported : prev));
setSavedOllamaBaseUrl(reported);
}
Comment thread
M3gA-Mind marked this conversation as resolved.
} catch (err) {
setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setIsDiagnosticsLoading(false);
}
};

const handleTestConnection = async () => {
setIsTestingConnection(true);
setConnectionTestResult(null);
try {
const result = await openhumanLocalAiTestConnection(ollamaBaseUrlInput);
log('[local_ai:ui] test_connection result: reachable=%o', result.reachable);
setConnectionTestResult(result);
} catch (err) {
setConnectionTestResult({
reachable: false,
error: err instanceof Error ? err.message : 'Connection test failed',
models_count: null,
});
} finally {
setIsTestingConnection(false);
}
};

const handleSaveOllamaBaseUrl = async () => {
setIsSavingUrl(true);
try {
await openhumanUpdateLocalAiSettings({ base_url: ollamaBaseUrlInput });
log(
'[local_ai:ui] saved ollama base_url=%s',
ollamaBaseUrlInput.replace(/\/\/[^@]*@/, '//***@')
);
setSavedOllamaBaseUrl(ollamaBaseUrlInput);
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to save URL');
} finally {
setIsSavingUrl(false);
}
};

const handleResetOllamaBaseUrl = async () => {
setOllamaBaseUrlInput(DEFAULT_OLLAMA_URL);
setConnectionTestResult(null);
setIsSavingUrl(true);
try {
await openhumanUpdateLocalAiSettings({ base_url: null });
log('[local_ai:ui] reset ollama base_url to default');
setSavedOllamaBaseUrl(DEFAULT_OLLAMA_URL);
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to reset URL');
} finally {
setIsSavingUrl(false);
}
};

return (
<div>
<SettingsHeader
Expand Down Expand Up @@ -319,13 +408,22 @@ const LocalModelDebugPanel = () => {
etaText={etaText}
statusTone={statusTone}
runtimeEnabled={runtimeEnabled}
ollamaBaseUrlInput={ollamaBaseUrlInput}
isTestingConnection={isTestingConnection}
connectionTestResult={connectionTestResult}
isSavingUrl={isSavingUrl}
savedOllamaBaseUrl={savedOllamaBaseUrl}
onRefreshStatus={() => void loadStatus()}
onTriggerDownload={() => {}}
onSetOllamaPath={() => {}}
onClearOllamaPath={() => {}}
onSetOllamaPathInput={() => {}}
onToggleErrorDetail={() => setShowErrorDetail(v => !v)}
onRunDiagnostics={() => void handleRunDiagnostics()}
onSetOllamaBaseUrlInput={setOllamaBaseUrlInput}
onTestConnection={() => void handleTestConnection()}
onSaveOllamaBaseUrl={() => void handleSaveOllamaBaseUrl()}
onResetOllamaBaseUrl={() => void handleResetOllamaBaseUrl()}
/>

<ModelDownloadSection
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import LocalModelDebugPanel from '../LocalModelDebugPanel';

const { mockNavigateBack } = vi.hoisted(() => ({ mockNavigateBack: vi.fn() }));

vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: mockNavigateBack, breadcrumbs: [] }),
}));

const mockGetConfig = vi.fn();
vi.mock('../../../../utils/tauriCommands/config', () => ({
openhumanGetConfig: (...args: unknown[]) => mockGetConfig(...args),
}));

const mockLocalAiStatus = vi.fn();
const mockLocalAiAssetsStatus = vi.fn();
const mockLocalAiDownloadsProgress = vi.fn();
const mockLocalAiTestConnection = vi.fn();
const mockUpdateLocalAiSettings = vi.fn();
const mockLocalAiDiagnostics = vi.fn();

vi.mock('../../../../utils/tauriCommands', () => ({
openhumanLocalAiStatus: (...args: unknown[]) => mockLocalAiStatus(...args),
openhumanLocalAiAssetsStatus: (...args: unknown[]) => mockLocalAiAssetsStatus(...args),
openhumanLocalAiDownloadsProgress: (...args: unknown[]) => mockLocalAiDownloadsProgress(...args),
openhumanLocalAiTestConnection: (...args: unknown[]) => mockLocalAiTestConnection(...args),
openhumanUpdateLocalAiSettings: (...args: unknown[]) => mockUpdateLocalAiSettings(...args),
openhumanLocalAiDiagnostics: (...args: unknown[]) => mockLocalAiDiagnostics(...args),
openhumanLocalAiSummarize: vi.fn().mockResolvedValue({ result: '' }),
openhumanLocalAiPrompt: vi.fn().mockResolvedValue({ result: '' }),
openhumanLocalAiEmbed: vi.fn().mockResolvedValue({ result: [] }),
openhumanLocalAiVisionPrompt: vi.fn().mockResolvedValue({ result: '' }),
openhumanLocalAiTranscribe: vi.fn().mockResolvedValue({ result: '' }),
openhumanLocalAiTts: vi.fn().mockResolvedValue({ result: '' }),
openhumanLocalAiDownloadAsset: vi.fn().mockResolvedValue({ result: null }),
}));

function renderPanel() {
return render(
<MemoryRouter>
<LocalModelDebugPanel />
</MemoryRouter>
);
}

describe('LocalModelDebugPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLocalAiStatus.mockResolvedValue({ result: null });
mockLocalAiAssetsStatus.mockResolvedValue({ result: null });
mockLocalAiDownloadsProgress.mockResolvedValue({ result: null });
mockGetConfig.mockResolvedValue({ result: { config: {} } });
mockLocalAiDiagnostics.mockResolvedValue({
ok: true,
ollama_running: false,
ollama_base_url: null,
ollama_binary_path: null,
installed_models: [],
expected: {
chat_model: '',
chat_found: false,
embedding_model: '',
embedding_found: false,
vision_model: '',
vision_found: false,
},
issues: [],
repair_actions: [],
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('renders the Ollama Server URL section with default URL', () => {
renderPanel();
const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement;
expect(input.value).toBe('http://localhost:11434');
});

it('seeds the URL input from config on mount', async () => {
mockGetConfig.mockResolvedValue({
result: { config: { local_ai: { base_url: 'http://192.168.1.5:11434' } } },
});
renderPanel();
await waitFor(() => {
const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement;
expect(input.value).toBe('http://192.168.1.5:11434');
});
});

it('keeps the default URL when config returns no base_url', async () => {
mockGetConfig.mockResolvedValue({ result: { config: { local_ai: {} } } });
renderPanel();
await waitFor(() => {
const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement;
expect(input.value).toBe('http://localhost:11434');
});
expect(mockGetConfig).toHaveBeenCalledTimes(1);
});

it('calls openhumanLocalAiTestConnection when Test Connection is clicked', async () => {
mockLocalAiTestConnection.mockResolvedValue({ reachable: true, models_count: 2 });
renderPanel();
const testBtn = screen.getByRole('button', { name: /Test Connection/i });
fireEvent.click(testBtn);
await waitFor(() => {
expect(mockLocalAiTestConnection).toHaveBeenCalledWith('http://localhost:11434');
});
});

it('shows reachable result after a successful connection test', async () => {
mockLocalAiTestConnection.mockResolvedValue({ reachable: true, models_count: 5 });
renderPanel();
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
await waitFor(() => expect(screen.getByText(/Reachable/)).toBeTruthy());
expect(screen.getByText(/5 models/)).toBeTruthy();
});

it('shows unreachable result when connection test throws', async () => {
mockLocalAiTestConnection.mockRejectedValue(new Error('connect ECONNREFUSED'));
renderPanel();
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
await waitFor(() => expect(screen.getByText(/connect ECONNREFUSED/)).toBeTruthy());
});

it('saves the URL when Save is clicked after changing the input', async () => {
mockUpdateLocalAiSettings.mockResolvedValue({ result: true });
renderPanel();
const urlInput = screen.getByPlaceholderText('http://localhost:11434');
fireEvent.change(urlInput, { target: { value: 'http://192.168.1.5:11434' } });
const saveBtn = await screen.findByRole('button', { name: 'Save' });
expect((saveBtn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockUpdateLocalAiSettings).toHaveBeenCalledWith({
base_url: 'http://192.168.1.5:11434',
});
});
});

it('resets the URL to default when Reset to default is clicked', async () => {
mockUpdateLocalAiSettings.mockResolvedValue({ result: true });
renderPanel();
const resetBtn = screen.getByRole('button', { name: /Reset to default/i });
fireEvent.click(resetBtn);
await waitFor(() => {
expect(mockUpdateLocalAiSettings).toHaveBeenCalledWith({ base_url: null });
});
const urlInput = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement;
expect(urlInput.value).toBe('http://localhost:11434');
});
});
Loading
Loading