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);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user