diff --git a/src/agent/llm/LocalBrowserLLMProvider.test.ts b/src/agent/llm/LocalBrowserLLMProvider.test.ts index 1284c9d..6a9fe3c 100644 --- a/src/agent/llm/LocalBrowserLLMProvider.test.ts +++ b/src/agent/llm/LocalBrowserLLMProvider.test.ts @@ -103,4 +103,37 @@ describe('LocalBrowserLLMProvider', () => { const options = await runProviderAndCaptureOptions(99999); expect(options.maxTokens).toBe(32768); }); + + it('reports runtime initialization failures through the model manager', async () => { + configGetMock.mockReturnValue(32768); + loadModelReaderWithCacheMock.mockResolvedValue({ + reader: new Uint8Array([1, 2, 3]), + totalBytes: 3, + fromCache: false, + cacheWritePromise: null, + }); + + const runtimeError = new Error('runtime init failed'); + vi.spyOn(LocalBrowserLLMProvider.prototype as never, 'getMediaPipeModule' as never).mockResolvedValue({ + FilesetResolver: { + forGenAiTasks: vi.fn(async () => ({})), + }, + LlmInference: { + createFromOptions: vi.fn(async () => { + throw runtimeError; + }), + }, + }); + + const provider = new LocalBrowserLLMProvider(); + + await expect(async () => { + for await (const _chunk of provider.generateStream([])) { + // No-op. + } + }).rejects.toThrow('runtime init failed'); + + expect(ensureRuntimeSupportedMock).toHaveBeenCalled(); + expect(notifyLoadErrorMock).toHaveBeenCalledWith(runtimeError); + }); }); diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 7a3732b..3f1ba24 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -339,6 +339,10 @@ const ChatBox: React.FC = ({ isVisible }) => { } }, [isProcessing]); + const localRuntimeMessage = localModelState.runtimeSupport.reason; + const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; + const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage; + return (
@@ -391,9 +395,14 @@ const ChatBox: React.FC = ({ isVisible }) => {

{LOCAL_LLM_DISPLAY_NAME} Local Runtime

- {!localModelState.runtimeSupport.supported && ( + {hasLocalRuntimeWarning && (
- {localModelState.runtimeSupport.reason} + {localRuntimeMessage} +
+ )} + {hasLocalRuntimeHardFailure && ( +
+ {localRuntimeMessage}
)} {!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && ( diff --git a/src/components/settings/sections/GeneralSettings.test.tsx b/src/components/settings/sections/GeneralSettings.test.tsx index d45197f..8bdf3b0 100644 --- a/src/components/settings/sections/GeneralSettings.test.tsx +++ b/src/components/settings/sections/GeneralSettings.test.tsx @@ -25,6 +25,24 @@ const configState = new Map([ ['general.kgone.base_url', 'http://127.0.0.1:8000'], ]); +const localModelState = { + isCached: false, + isChecking: false, + isDownloading: false, + isDeleting: false, + progressPercent: 0, + progressText: '', + error: '', + runtimeSupport: { + supported: true, + webgpuExposed: true, + crossOriginIsolated: true, + sharedArrayBufferAvailable: true, + secureContext: true, + reason: null as string | null, + }, +}; + const configManagerMock = { getIsInitialized: vi.fn(() => true), initialize: vi.fn().mockResolvedValue(undefined), @@ -44,41 +62,9 @@ vi.mock('../../../core/config/ConfigManager', () => ({ vi.mock('../../../util/localLLMModelManager', () => ({ LocalLLMModelManager: { - getState: () => ({ - isCached: false, - isChecking: false, - isDownloading: false, - isDeleting: false, - progressPercent: 0, - progressText: '', - error: '', - runtimeSupport: { - supported: true, - webgpuExposed: true, - crossOriginIsolated: true, - sharedArrayBufferAvailable: true, - secureContext: true, - reason: null, - }, - }), + getState: () => localModelState, subscribe: (listener: (state: unknown) => void) => { - listener({ - isCached: false, - isChecking: false, - isDownloading: false, - isDeleting: false, - progressPercent: 0, - progressText: '', - error: '', - runtimeSupport: { - supported: true, - webgpuExposed: true, - crossOriginIsolated: true, - sharedArrayBufferAvailable: true, - secureContext: true, - reason: null, - }, - }); + listener(localModelState); return () => {}; }, deleteCachedModel: vi.fn().mockResolvedValue(undefined), @@ -90,6 +76,21 @@ describe('GeneralSettings', () => { configState.set('general.local_browser.context_length', 65536); configManagerMock.get.mockClear(); configManagerMock.set.mockClear(); + localModelState.isCached = false; + localModelState.isChecking = false; + localModelState.isDownloading = false; + localModelState.isDeleting = false; + localModelState.progressPercent = 0; + localModelState.progressText = ''; + localModelState.error = ''; + localModelState.runtimeSupport = { + supported: true, + webgpuExposed: true, + crossOriginIsolated: true, + sharedArrayBufferAvailable: true, + secureContext: true, + reason: null, + }; }); it('renders the local context length selector and VRAM hint', async () => { @@ -117,4 +118,20 @@ describe('GeneralSettings', () => { expect(configManagerMock.set).toHaveBeenCalledWith('general.local_browser.context_length', 131072); }); }); + + it('shows a warning when runtime may fail on this host but is still allowed', async () => { + localModelState.runtimeSupport = { + supported: true, + webgpuExposed: true, + crossOriginIsolated: false, + sharedArrayBufferAvailable: false, + secureContext: true, + reason: 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.', + }; + + render(); + + expect(await screen.findByText(/may not support the local browser runtime reliably/i)).toBeTruthy(); + expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy(); + }); }); diff --git a/src/components/settings/sections/GeneralSettings.tsx b/src/components/settings/sections/GeneralSettings.tsx index b3edf73..dc5bad9 100644 --- a/src/components/settings/sections/GeneralSettings.tsx +++ b/src/components/settings/sections/GeneralSettings.tsx @@ -232,6 +232,10 @@ const GeneralSettings: React.FC = () => { } }; + const localRuntimeMessage = localModelState.runtimeSupport.reason; + const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; + const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage; + // NOTE: Gemini and Claude are not supported yet due to CORS issues. return (
@@ -282,9 +286,15 @@ const GeneralSettings: React.FC = () => {

{LOCAL_LLM_DISPLAY_NAME} Local Runtime

- {!localModelState.runtimeSupport.supported && ( + {hasLocalRuntimeWarning && (
- {localModelState.runtimeSupport.reason} + {localRuntimeMessage} +
+ )} + + {hasLocalRuntimeHardFailure && ( +
+ {localRuntimeMessage}
)} diff --git a/src/util/localLLMConfig.test.ts b/src/util/localLLMConfig.test.ts index 0b6522b..a5afe9b 100644 --- a/src/util/localLLMConfig.test.ts +++ b/src/util/localLLMConfig.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + detectLocalLLMRuntimeSupport, formatLocalLLMContextLength, LOCAL_LLM_DEFAULT_CONTEXT_LENGTH, normalizeLocalLLMContextLength, @@ -16,4 +17,40 @@ describe('localLLMConfig', () => { expect(formatLocalLLMContextLength(65536)).toBe('64k'); expect(formatLocalLLMContextLength(131072)).toBe('128k'); }); + + it('marks insecure contexts as unsupported', () => { + Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }); + Object.defineProperty(window, 'crossOriginIsolated', { value: true, configurable: true }); + Object.defineProperty(globalThis, 'SharedArrayBuffer', { value: class SharedArrayBuffer {}, configurable: true }); + Object.defineProperty(globalThis, 'navigator', { value: { gpu: {} }, configurable: true }); + + const support = detectLocalLLMRuntimeSupport(); + + expect(support.supported).toBe(false); + expect(support.reason).toContain('secure context'); + }); + + it('marks missing WebGPU as unsupported', () => { + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + Object.defineProperty(window, 'crossOriginIsolated', { value: true, configurable: true }); + Object.defineProperty(globalThis, 'SharedArrayBuffer', { value: class SharedArrayBuffer {}, configurable: true }); + Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true }); + + const support = detectLocalLLMRuntimeSupport(); + + expect(support.supported).toBe(false); + expect(support.reason).toContain('WebGPU'); + }); + + it('allows runtime attempts when only SharedArrayBuffer isolation support is missing', () => { + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + Object.defineProperty(window, 'crossOriginIsolated', { value: false, configurable: true }); + Object.defineProperty(globalThis, 'SharedArrayBuffer', { value: undefined, configurable: true }); + Object.defineProperty(globalThis, 'navigator', { value: { gpu: {} }, configurable: true }); + + const support = detectLocalLLMRuntimeSupport(); + + expect(support.supported).toBe(true); + expect(support.reason).toContain('cross-origin isolation'); + }); }); diff --git a/src/util/localLLMConfig.ts b/src/util/localLLMConfig.ts index 4b30060..053e756 100644 --- a/src/util/localLLMConfig.ts +++ b/src/util/localLLMConfig.ts @@ -29,14 +29,14 @@ export function detectLocalLLMRuntimeSupport(): LocalLLMRuntimeSupport { let reason: string | null = null; if (!secureContext) { reason = 'Local browser LLM requires a secure context (HTTPS or localhost).'; - } else if (!crossOriginIsolated || !sharedArrayBufferAvailable) { - reason = 'Local browser LLM requires SharedArrayBuffer support. Ensure COOP/COEP headers are enabled.'; } else if (!webgpuExposed) { reason = 'Local browser LLM currently requires a browser with WebGPU support.'; + } else if (!crossOriginIsolated || !sharedArrayBufferAvailable) { + reason = 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.'; } return { - supported: reason === null, + supported: secureContext && webgpuExposed, webgpuExposed, crossOriginIsolated, sharedArrayBufferAvailable, diff --git a/src/util/messageFilter/UserMessageFilter.test.ts b/src/util/messageFilter/UserMessageFilter.test.ts index 8c50965..1eb3fe7 100644 --- a/src/util/messageFilter/UserMessageFilter.test.ts +++ b/src/util/messageFilter/UserMessageFilter.test.ts @@ -2,7 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { processUserMessage } from './UserMessageFilter'; import { addWelcomeMessage } from '../../utils/chatMessageUtils'; +const { detectLocalLLMRuntimeSupportMock } = vi.hoisted(() => ({ + detectLocalLLMRuntimeSupportMock: vi.fn(), +})); + const configState = new Map(); +const storeState = { + setStatus: vi.fn(), + activeRegionId: null as string | null, + selectedRegionIds: [] as string[], +}; const configManagerMock = { getIsInitialized: vi.fn(() => true), @@ -22,11 +31,7 @@ vi.mock('../chatUtil', () => ({ vi.mock('../../stores/projectStore', () => ({ useProjectStore: { - getState: () => ({ - setStatus: vi.fn(), - activeRegionId: null, - selectedRegionIds: [], - }), + getState: () => storeState, }, })); @@ -36,6 +41,14 @@ vi.mock('../../agent/core/SystemPrompts', () => ({ }, })); +vi.mock('../localLLMConfig', async () => { + const actual = await vi.importActual('../localLLMConfig'); + return { + ...actual, + detectLocalLLMRuntimeSupport: detectLocalLLMRuntimeSupportMock, + }; +}); + describe('processUserMessage /welcome', () => { beforeEach(() => { configState.clear(); @@ -50,6 +63,18 @@ describe('processUserMessage /welcome', () => { configManagerMock.getIsInitialized.mockReturnValue(true); configManagerMock.initialize.mockClear(); configManagerMock.get.mockClear(); + storeState.setStatus.mockClear(); + storeState.activeRegionId = null; + storeState.selectedRegionIds = []; + detectLocalLLMRuntimeSupportMock.mockReset(); + detectLocalLLMRuntimeSupportMock.mockReturnValue({ + supported: true, + webgpuExposed: true, + crossOriginIsolated: true, + sharedArrayBufferAvailable: true, + secureContext: true, + reason: null, + }); vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { const url = String(input); @@ -106,4 +131,39 @@ describe('processUserMessage /welcome', () => { expect(message?.role).toBe('assistant'); expect(message?.content).toContain('welcome_local_llm.md'); }); + + it('blocks local-browser messages when the runtime is hard unsupported', async () => { + detectLocalLLMRuntimeSupportMock.mockReturnValue({ + supported: false, + webgpuExposed: false, + crossOriginIsolated: false, + sharedArrayBufferAvailable: false, + secureContext: true, + reason: 'Local browser LLM currently requires a browser with WebGPU support.', + }); + + const result = await processUserMessage('hello'); + + expect(result.sendToLLM).toBe(false); + expect(result.pseudoAssistantResponse).toContain('WebGPU'); + expect(result.metadata).toMatchObject({ error: 'local_browser_unsupported' }); + }); + + it('allows local-browser messages when only SharedArrayBuffer isolation support is missing', async () => { + detectLocalLLMRuntimeSupportMock.mockReturnValue({ + supported: true, + webgpuExposed: true, + crossOriginIsolated: false, + sharedArrayBufferAvailable: false, + secureContext: true, + reason: 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.', + }); + storeState.activeRegionId = 'region-1'; + + const result = await processUserMessage('hello'); + + expect(result.sendToLLM).toBe(true); + expect(result.finalMessageForLLM).toContain('hello'); + expect(result.pseudoAssistantResponse).toBeNull(); + }); });