Merge pull request #42 from KGAudioLab/feat/2026-05-11-embedded-models

fix: remove CORS checking for downloading remote model file
This commit is contained in:
Xiaohan-Tian
2026-05-15 12:53:54 -07:00
committed by GitHub
7 changed files with 212 additions and 46 deletions
@@ -103,4 +103,37 @@ describe('LocalBrowserLLMProvider', () => {
const options = await runProviderAndCaptureOptions(99999); const options = await runProviderAndCaptureOptions(99999);
expect(options.maxTokens).toBe(32768); 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);
});
}); });
+11 -2
View File
@@ -339,6 +339,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} }
}, [isProcessing]); }, [isProcessing]);
const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage;
return ( return (
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}> <div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
<div className="chatbox-header"> <div className="chatbox-header">
@@ -391,9 +395,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div className="chatbox-local-runtime-section"> <div className="chatbox-local-runtime-section">
<div className="chatbox-local-runtime-card"> <div className="chatbox-local-runtime-card">
<h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4> <h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
{!localModelState.runtimeSupport.supported && ( {hasLocalRuntimeWarning && (
<div className="chatbox-local-runtime-warning"> <div className="chatbox-local-runtime-warning">
{localModelState.runtimeSupport.reason} {localRuntimeMessage}
</div>
)}
{hasLocalRuntimeHardFailure && (
<div className="chatbox-local-runtime-error">
{localRuntimeMessage}
</div> </div>
)} )}
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && ( {!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
@@ -25,6 +25,24 @@ const configState = new Map<string, unknown>([
['general.kgone.base_url', 'http://127.0.0.1:8000'], ['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 = { const configManagerMock = {
getIsInitialized: vi.fn(() => true), getIsInitialized: vi.fn(() => true),
initialize: vi.fn().mockResolvedValue(undefined), initialize: vi.fn().mockResolvedValue(undefined),
@@ -44,41 +62,9 @@ vi.mock('../../../core/config/ConfigManager', () => ({
vi.mock('../../../util/localLLMModelManager', () => ({ vi.mock('../../../util/localLLMModelManager', () => ({
LocalLLMModelManager: { LocalLLMModelManager: {
getState: () => ({ getState: () => 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,
},
}),
subscribe: (listener: (state: unknown) => void) => { subscribe: (listener: (state: unknown) => void) => {
listener({ listener(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,
},
});
return () => {}; return () => {};
}, },
deleteCachedModel: vi.fn().mockResolvedValue(undefined), deleteCachedModel: vi.fn().mockResolvedValue(undefined),
@@ -90,6 +76,21 @@ describe('GeneralSettings', () => {
configState.set('general.local_browser.context_length', 65536); configState.set('general.local_browser.context_length', 65536);
configManagerMock.get.mockClear(); configManagerMock.get.mockClear();
configManagerMock.set.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 () => { 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); 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(<GeneralSettings />);
expect(await screen.findByText(/may not support the local browser runtime reliably/i)).toBeTruthy();
expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy();
});
}); });
@@ -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. // NOTE: Gemini and Claude are not supported yet due to CORS issues.
return ( return (
<div className="settings-section"> <div className="settings-section">
@@ -282,9 +286,15 @@ const GeneralSettings: React.FC = () => {
<div className="settings-group"> <div className="settings-group">
<h4>{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4> <h4>{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
{!localModelState.runtimeSupport.supported && ( {hasLocalRuntimeWarning && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '4px', marginBottom: '8px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '4px', marginBottom: '8px' }}>
{localModelState.runtimeSupport.reason} {localRuntimeMessage}
</div>
)}
{hasLocalRuntimeHardFailure && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a', marginTop: '4px', marginBottom: '8px' }}>
{localRuntimeMessage}
</div> </div>
)} )}
+37
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
detectLocalLLMRuntimeSupport,
formatLocalLLMContextLength, formatLocalLLMContextLength,
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH, LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
normalizeLocalLLMContextLength, normalizeLocalLLMContextLength,
@@ -16,4 +17,40 @@ describe('localLLMConfig', () => {
expect(formatLocalLLMContextLength(65536)).toBe('64k'); expect(formatLocalLLMContextLength(65536)).toBe('64k');
expect(formatLocalLLMContextLength(131072)).toBe('128k'); 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');
});
}); });
+3 -3
View File
@@ -29,14 +29,14 @@ export function detectLocalLLMRuntimeSupport(): LocalLLMRuntimeSupport {
let reason: string | null = null; let reason: string | null = null;
if (!secureContext) { if (!secureContext) {
reason = 'Local browser LLM requires a secure context (HTTPS or localhost).'; 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) { } else if (!webgpuExposed) {
reason = 'Local browser LLM currently requires a browser with WebGPU support.'; 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 { return {
supported: reason === null, supported: secureContext && webgpuExposed,
webgpuExposed, webgpuExposed,
crossOriginIsolated, crossOriginIsolated,
sharedArrayBufferAvailable, sharedArrayBufferAvailable,
@@ -2,7 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { processUserMessage } from './UserMessageFilter'; import { processUserMessage } from './UserMessageFilter';
import { addWelcomeMessage } from '../../utils/chatMessageUtils'; import { addWelcomeMessage } from '../../utils/chatMessageUtils';
const { detectLocalLLMRuntimeSupportMock } = vi.hoisted(() => ({
detectLocalLLMRuntimeSupportMock: vi.fn(),
}));
const configState = new Map<string, unknown>(); const configState = new Map<string, unknown>();
const storeState = {
setStatus: vi.fn(),
activeRegionId: null as string | null,
selectedRegionIds: [] as string[],
};
const configManagerMock = { const configManagerMock = {
getIsInitialized: vi.fn(() => true), getIsInitialized: vi.fn(() => true),
@@ -22,11 +31,7 @@ vi.mock('../chatUtil', () => ({
vi.mock('../../stores/projectStore', () => ({ vi.mock('../../stores/projectStore', () => ({
useProjectStore: { useProjectStore: {
getState: () => ({ getState: () => storeState,
setStatus: vi.fn(),
activeRegionId: null,
selectedRegionIds: [],
}),
}, },
})); }));
@@ -36,6 +41,14 @@ vi.mock('../../agent/core/SystemPrompts', () => ({
}, },
})); }));
vi.mock('../localLLMConfig', async () => {
const actual = await vi.importActual<typeof import('../localLLMConfig')>('../localLLMConfig');
return {
...actual,
detectLocalLLMRuntimeSupport: detectLocalLLMRuntimeSupportMock,
};
});
describe('processUserMessage /welcome', () => { describe('processUserMessage /welcome', () => {
beforeEach(() => { beforeEach(() => {
configState.clear(); configState.clear();
@@ -50,6 +63,18 @@ describe('processUserMessage /welcome', () => {
configManagerMock.getIsInitialized.mockReturnValue(true); configManagerMock.getIsInitialized.mockReturnValue(true);
configManagerMock.initialize.mockClear(); configManagerMock.initialize.mockClear();
configManagerMock.get.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) => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input); const url = String(input);
@@ -106,4 +131,39 @@ describe('processUserMessage /welcome', () => {
expect(message?.role).toBe('assistant'); expect(message?.role).toBe('assistant');
expect(message?.content).toContain('welcome_local_llm.md'); 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();
});
}); });