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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,6 +339,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
}
|
||||
}, [isProcessing]);
|
||||
|
||||
const localRuntimeMessage = localModelState.runtimeSupport.reason;
|
||||
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
|
||||
const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage;
|
||||
|
||||
return (
|
||||
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
||||
<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-card">
|
||||
<h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
|
||||
{!localModelState.runtimeSupport.supported && (
|
||||
{hasLocalRuntimeWarning && (
|
||||
<div className="chatbox-local-runtime-warning">
|
||||
{localModelState.runtimeSupport.reason}
|
||||
{localRuntimeMessage}
|
||||
</div>
|
||||
)}
|
||||
{hasLocalRuntimeHardFailure && (
|
||||
<div className="chatbox-local-runtime-error">
|
||||
{localRuntimeMessage}
|
||||
</div>
|
||||
)}
|
||||
{!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'],
|
||||
]);
|
||||
|
||||
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(<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.
|
||||
return (
|
||||
<div className="settings-section">
|
||||
@@ -282,9 +286,15 @@ const GeneralSettings: React.FC = () => {
|
||||
<div className="settings-group">
|
||||
<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' }}>
|
||||
{localModelState.runtimeSupport.reason}
|
||||
{localRuntimeMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLocalRuntimeHardFailure && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a', marginTop: '4px', marginBottom: '8px' }}>
|
||||
{localRuntimeMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>();
|
||||
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<typeof import('../localLLMConfig')>('../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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user