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
29 changes: 22 additions & 7 deletions app/src/components/settings/panels/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const AIPanel = () => {
);
const [error, setError] = useState<string>('');
const [localAiStatus, setLocalAiStatus] = useState<LocalAiStatus | null>(null);
const localAiRuntimeEnabled = localAiStatus != null && localAiStatus.state !== 'disabled';

const loadAIPreview = useCallback(async () => {
setLoading(true);
Expand Down Expand Up @@ -48,12 +49,17 @@ const AIPanel = () => {
}, []);

useEffect(() => {
void loadAIPreview();
void loadLocalAiStatus();
const timer = setInterval(() => {
const initialLoad = window.setTimeout(() => {
void loadAIPreview();
void loadLocalAiStatus();
}, 0);
const timer = window.setInterval(() => {
void loadLocalAiStatus();
}, 5000);
return () => clearInterval(timer);
return () => {
window.clearTimeout(initialLoad);
window.clearInterval(timer);
};
}, [loadAIPreview, loadLocalAiStatus]);

const refreshConfig = async (target: 'soul' | 'tools' | 'all') => {
Expand Down Expand Up @@ -124,10 +130,19 @@ const AIPanel = () => {
</button>
<button
onClick={async () => {
await openhumanLocalAiDownload(true);
await loadLocalAiStatus();
if (!localAiRuntimeEnabled) return;
try {
setError('');
await openhumanLocalAiDownload(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to retry download';
setError(message);
} finally {
await loadLocalAiStatus();
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className="text-sm text-primary-500 hover:text-primary-600 transition-colors">
disabled={!localAiRuntimeEnabled}
className="text-sm text-primary-500 hover:text-primary-600 transition-colors disabled:opacity-50 disabled:hover:text-primary-500">
Retry Download
</button>
</div>
Expand Down
28 changes: 19 additions & 9 deletions app/src/components/settings/panels/LocalModelDebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const LocalModelDebugPanel = () => {
return progressFromStatus(status);
}, [downloads, status]);

const runtimeEnabled = status?.state !== 'disabled';
const currentState = downloads?.state ?? status?.state;
const isInstalling = currentState === 'installing';
const isIndeterminateDownload =
Expand Down Expand Up @@ -144,14 +145,20 @@ const LocalModelDebugPanel = () => {
};

useEffect(() => {
void loadStatus();
const timer = setInterval(() => {
const initialLoad = window.setTimeout(() => {
void loadStatus();
}, 0);
const timer = window.setInterval(() => {
void loadStatus();
}, 1500);
return () => clearInterval(timer);
return () => {
window.clearTimeout(initialLoad);
window.clearInterval(timer);
};
}, []);

const triggerDownload = async (force: boolean) => {
if (!runtimeEnabled) return;
setIsTriggeringDownload(true);
setStatusError('');
setBootstrapMessage('');
Expand All @@ -174,7 +181,7 @@ const LocalModelDebugPanel = () => {
};

const runSummaryTest = async () => {
if (!summaryInput.trim()) return;
if (!runtimeEnabled || !summaryInput.trim()) return;
setIsSummaryLoading(true);
setSummaryOutput('');
setStatusError('');
Expand All @@ -190,7 +197,7 @@ const LocalModelDebugPanel = () => {
};

const runPromptTest = async () => {
if (!promptInput.trim()) return;
if (!runtimeEnabled || !promptInput.trim()) return;
setIsPromptLoading(true);
setPromptOutput('');
setPromptError('');
Expand All @@ -206,7 +213,7 @@ const LocalModelDebugPanel = () => {
};

const runVisionTest = async () => {
if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
if (!runtimeEnabled || !visionPromptInput.trim() || !visionImageInput.trim()) return;
setIsVisionLoading(true);
setVisionOutput('');
setStatusError('');
Expand All @@ -226,7 +233,7 @@ const LocalModelDebugPanel = () => {
};

const runEmbeddingTest = async () => {
if (!embeddingInput.trim()) return;
if (!runtimeEnabled || !embeddingInput.trim()) return;
setIsEmbeddingLoading(true);
setEmbeddingOutput(null);
setStatusError('');
Expand All @@ -246,7 +253,7 @@ const LocalModelDebugPanel = () => {
};

const runTranscribeTest = async () => {
if (!audioPathInput.trim()) return;
if (!runtimeEnabled || !audioPathInput.trim()) return;
setIsTranscribeLoading(true);
setTranscribeOutput(null);
setStatusError('');
Expand All @@ -262,7 +269,7 @@ const LocalModelDebugPanel = () => {
};

const runTtsTest = async () => {
if (!ttsInput.trim()) return;
if (!runtimeEnabled || !ttsInput.trim()) return;
setIsTtsLoading(true);
setTtsOutput(null);
setStatusError('');
Expand All @@ -283,6 +290,7 @@ const LocalModelDebugPanel = () => {
const triggerAssetDownload = async (
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
) => {
if (!runtimeEnabled) return;
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
setStatusError('');
try {
Expand Down Expand Up @@ -365,6 +373,7 @@ const LocalModelDebugPanel = () => {
speedText={speedText}
etaText={etaText}
statusTone={statusTone}
runtimeEnabled={runtimeEnabled}
onRefreshStatus={() => void loadStatus()}
onTriggerDownload={force => void triggerDownload(force)}
onSetOllamaPath={() => void handleSetOllamaPath()}
Expand All @@ -378,6 +387,7 @@ const LocalModelDebugPanel = () => {
assets={assets}
assetDownloadBusy={assetDownloadBusy}
statusTone={statusTone}
runtimeEnabled={runtimeEnabled}
onTriggerAssetDownload={capability => void triggerAssetDownload(capability)}
summaryInput={summaryInput}
summaryOutput={summaryOutput}
Expand Down
26 changes: 18 additions & 8 deletions app/src/components/settings/panels/LocalModelPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const LocalModelPanel = () => {
return progressFromStatus(status);
}, [downloads, status]);
const currentState = downloads?.state ?? status?.state;
const runtimeEnabled = usageFlags.runtime_enabled;
const isInstalling = currentState === 'installing';
const isIndeterminateDownload =
isInstalling ||
Expand Down Expand Up @@ -141,16 +142,22 @@ const LocalModelPanel = () => {
};

useEffect(() => {
void loadStatus();
void loadPresets();
void loadUsage();
const timer = setInterval(() => {
const initialLoad = window.setTimeout(() => {
void loadStatus();
void loadPresets();
void loadUsage();
}, 0);
const timer = window.setInterval(() => {
void loadStatus();
}, 1500);
return () => clearInterval(timer);
return () => {
window.clearTimeout(initialLoad);
window.clearInterval(timer);
};
}, []);

const triggerDownload = async (force: boolean) => {
if (!runtimeEnabled) return;
setIsTriggeringDownload(true);
setStatusError('');
setBootstrapMessage('');
Expand Down Expand Up @@ -247,7 +254,7 @@ const LocalModelPanel = () => {
<button
type="button"
onClick={() => void triggerDownload(false)}
disabled={isTriggeringDownload}
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>
Expand Down Expand Up @@ -341,8 +348,11 @@ const LocalModelPanel = () => {

<button
type="button"
onClick={() => navigateToSettings('local-model-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
onClick={() => {
if (runtimeEnabled) navigateToSettings('local-model-debug');
}}
disabled={!runtimeEnabled}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors disabled:opacity-50 disabled:hover:text-stone-400">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
Expand Down
71 changes: 71 additions & 0 deletions app/src/components/settings/panels/__tests__/AIPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '../../../../test/test-utils';
import {
aiGetConfig,
type AIPreview,
aiRefreshConfig,
type CommandResponse,
type LocalAiStatus,
openhumanLocalAiDownload,
openhumanLocalAiStatus,
} from '../../../../utils/tauriCommands';
import AIPanel from '../AIPanel';

vi.mock('../../../../utils/tauriCommands', () => ({
aiGetConfig: vi.fn(),
aiRefreshConfig: vi.fn(),
openhumanLocalAiDownload: vi.fn(),
openhumanLocalAiStatus: vi.fn(),
}));

const aiPreview: AIPreview = {
soul: {
raw: '',
name: 'OpenHuman',
description: 'Test persona',
personalityPreview: [],
safetyRulesPreview: [],
loadedAt: 1,
},
tools: { raw: '', totalTools: 0, activeSkills: 0, skillsPreview: [], loadedAt: 1 },
metadata: {
loadedAt: 1,
loadingDuration: 1,
hasFallbacks: false,
sources: { soul: 'test', tools: 'test' },
errors: [],
},
};

const disabledStatus: LocalAiStatus = {
state: 'disabled',
model_id: 'local-v1',
} as unknown as LocalAiStatus;

describe('AIPanel local model runtime gate', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(aiGetConfig).mockResolvedValue(aiPreview);
vi.mocked(aiRefreshConfig).mockResolvedValue(aiPreview);
vi.mocked(openhumanLocalAiStatus).mockResolvedValue({
result: disabledStatus,
logs: [],
} as CommandResponse<LocalAiStatus>);
vi.mocked(openhumanLocalAiDownload).mockResolvedValue({
result: disabledStatus,
logs: [],
} as CommandResponse<LocalAiStatus>);
});

it('does not retry downloads while local AI runtime is disabled', async () => {
renderWithProviders(<AIPanel />, { initialEntries: ['/settings/ai'] });

const retryButton = await screen.findByRole('button', { name: 'Retry Download' });
expect(retryButton).toBeDisabled();
fireEvent.click(retryButton);

expect(openhumanLocalAiDownload).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ describe('LocalModelPanel — usage flags', () => {
});
});

it('does not invoke model downloads while runtime is disabled', async () => {
renderWithProviders(<LocalModelPanel />, { initialEntries: ['/settings/local-model'] });

const button = await screen.findByRole('button', { name: 'Download Models' });
expect(button).toBeDisabled();
fireEvent.click(button);

const advancedButton = screen.getByRole('button', { name: 'Advanced settings' });
expect(advancedButton).toBeDisabled();
fireEvent.click(advancedButton);

expect(openhumanLocalAiDownload).not.toHaveBeenCalled();
expect(openhumanLocalAiDownloadAllAssets).not.toHaveBeenCalled();
});

it('surfaces an error when the initial config load fails', async () => {
vi.mocked(openhumanGetConfig).mockRejectedValueOnce(new Error('boom: get_config'));
renderWithProviders(<LocalModelPanel />, { initialEntries: ['/settings/local-model'] });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import ModelDownloadSection from './ModelDownloadSection';

const makeProps = () => ({
assets: null,
assetDownloadBusy: {},
statusTone: (_state: string) => '',
runtimeEnabled: false,
onTriggerAssetDownload: vi.fn(),
summaryInput: 'summarize me',
summaryOutput: '',
isSummaryLoading: false,
onSetSummaryInput: vi.fn(),
onRunSummaryTest: vi.fn(),
promptInput: 'prompt',
promptOutput: '',
promptError: '',
isPromptLoading: false,
promptNoThink: true,
onSetPromptInput: vi.fn(),
onSetPromptNoThink: vi.fn(),
onRunPromptTest: vi.fn(),
visionPromptInput: 'what is this?',
visionImageInput: 'image-ref',
visionOutput: '',
isVisionLoading: false,
onSetVisionPromptInput: vi.fn(),
onSetVisionImageInput: vi.fn(),
onRunVisionTest: vi.fn(),
embeddingInput: 'one line',
embeddingOutput: null,
isEmbeddingLoading: false,
onSetEmbeddingInput: vi.fn(),
onRunEmbeddingTest: vi.fn(),
audioPathInput: '/tmp/audio.wav',
transcribeOutput: null,
isTranscribeLoading: false,
onSetAudioPathInput: vi.fn(),
onRunTranscribeTest: vi.fn(),
ttsInput: 'say this',
ttsOutputPath: '',
ttsOutput: null,
isTtsLoading: false,
onSetTtsInput: vi.fn(),
onSetTtsOutputPath: vi.fn(),
onRunTtsTest: vi.fn(),
});

describe('ModelDownloadSection runtime gate', () => {
it('does not invoke local-AI test actions when runtime is disabled', () => {
const props = makeProps();
render(<ModelDownloadSection {...props} />);

const summaryButton = screen.getByRole('button', { name: 'Run Summary Test' });
expect(summaryButton).toBeDisabled();
fireEvent.click(summaryButton);

const promptButton = screen.getByRole('button', { name: 'Run Prompt Test' });
expect(promptButton).toBeDisabled();
fireEvent.click(promptButton);

expect(props.onRunSummaryTest).not.toHaveBeenCalled();
expect(props.onRunPromptTest).not.toHaveBeenCalled();
});
});
Loading
Loading