From 6db66be471a0b0e2da5b3a9f044bb5c2fe3974e6 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 18 May 2026 22:55:00 -0700 Subject: [PATCH] feat: allow user to config Gemma 4 E4B and MDX-NET model download URL --- public/config.json | 6 +- src/agent/llm/LocalBrowserLLMProvider.ts | 15 ++- src/components/KGOnePanel.test.tsx | 23 +++- src/components/KGOnePanel.tsx | 13 +- .../sections/GeneralSettings.test.tsx | 75 ++++++++++++ .../settings/sections/GeneralSettings.tsx | 115 +++++++++++++++++- src/core/config/ConfigManager.ts | 10 +- src/core/io/LocalSeparatorModelCache.test.ts | 100 ++++++++++----- src/util/localLLMConfig.ts | 3 +- src/util/localLLMModelCache.ts | 18 ++- src/util/localLLMModelManager.ts | 1 - src/util/localSeparatorConfig.ts | 3 +- src/util/localSeparatorModelCache.ts | 18 ++- src/util/opfsModelCache.ts | 17 ++- 14 files changed, 366 insertions(+), 51 deletions(-) diff --git a/public/config.json b/public/config.json index 8f6b495..3d55123 100644 --- a/public/config.json +++ b/public/config.json @@ -27,8 +27,12 @@ "model": "" }, "local_browser": { - "context_length": 32768 + "context_length": 32768, + "model_url": "https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task" }, + "uvr5_web_runtime": { + "mdx_net_model_url": "https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx" + }, "soundfont": { "base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/" }, diff --git a/src/agent/llm/LocalBrowserLLMProvider.ts b/src/agent/llm/LocalBrowserLLMProvider.ts index 57970f8..46de966 100644 --- a/src/agent/llm/LocalBrowserLLMProvider.ts +++ b/src/agent/llm/LocalBrowserLLMProvider.ts @@ -12,7 +12,7 @@ import { import { LOCAL_LLM_DEFAULT_CONTEXT_LENGTH, LOCAL_LLM_MODEL_FILENAME, - LOCAL_LLM_MODEL_URL, + LOCAL_LLM_DEFAULT_MODEL_URL, normalizeLocalLLMContextLength, } from '../../util/localLLMConfig'; import { LocalLLMModelCache } from '../../util/localLLMModelCache'; @@ -67,12 +67,13 @@ export class LocalBrowserLLMProvider implements LLMProvider { } const maxTokens = this.getConfiguredContextLength(); + const modelUrl = this.getConfiguredModelUrl(); console.log(`[localLLM] Initializing with max context length: ${maxTokens} tokens`); const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([ this.getMediaPipeModule(), LocalLLMModelCache.loadModelReaderWithCache( - LOCAL_LLM_MODEL_URL, + modelUrl, LOCAL_LLM_MODEL_FILENAME, progress => { LocalLLMModelManager.notifyLoadProgress(progress.receivedBytes, progress.totalBytes, progress.fromCache); @@ -132,6 +133,16 @@ export class LocalBrowserLLMProvider implements LLMProvider { } } + private getConfiguredModelUrl(): string { + try { + const configManager = ConfigManager.instance(); + const configured = configManager.get('general.local_browser.model_url'); + return typeof configured === 'string' && configured.trim() ? configured : LOCAL_LLM_DEFAULT_MODEL_URL; + } catch { + return LOCAL_LLM_DEFAULT_MODEL_URL; + } + } + private applyTemplate(message: { role: 'user' | 'model'; text: string }): string { const template = PROMPT_TEMPLATE[message.role]; return `${template.pre}${message.text}${template.post}`; diff --git a/src/components/KGOnePanel.test.tsx b/src/components/KGOnePanel.test.tsx index f63f5bf..a4432b5 100644 --- a/src/components/KGOnePanel.test.tsx +++ b/src/components/KGOnePanel.test.tsx @@ -5,6 +5,10 @@ import KGOnePanel from './KGOnePanel'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; +const { mockLocalSeparatorDownload } = vi.hoisted(() => ({ + mockLocalSeparatorDownload: vi.fn(async (_url?: string, _filename?: string, _onProgress?: unknown) => undefined), +})); + let kgoneEnabled = false; let selectedRegionIds: string[] = []; let localModelCached = false; @@ -31,6 +35,7 @@ vi.mock('../core/config/ConfigManager', () => ({ get: (key: string) => { if (key === 'general.kgone.enabled') return kgoneEnabled; if (key === 'general.kgone.base_url') return 'http://127.0.0.1:8000'; + if (key === 'general.uvr5_web_runtime.mdx_net_model_url') return 'https://example.com/custom-uvr5.onnx'; return undefined; }, }), @@ -79,8 +84,9 @@ vi.mock('../util/audioUtil', () => ({ vi.mock('../util/localSeparatorModelCache', () => ({ LocalSeparatorModelCache: { exists: vi.fn(async () => localModelCached), - download: vi.fn(async () => { + download: vi.fn(async (url: string, filename: string, onProgress: (progress: unknown) => void) => { localModelCached = true; + return mockLocalSeparatorDownload(url, filename, onProgress); }), delete: vi.fn(async () => { localModelCached = false; @@ -124,6 +130,7 @@ describe('KGOnePanel local separator mode', () => { { name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) }, { name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) }, ]; + mockLocalSeparatorDownload.mockClear(); mockRefreshProjectState.mockReset(); mockExecuteCommand.mockReset(); }); @@ -155,6 +162,20 @@ describe('KGOnePanel local separator mode', () => { expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument(); }); + it('uses the configured UVR5 model URL when downloading the local model', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Download Model' })); + + await waitFor(() => { + expect(mockLocalSeparatorDownload).toHaveBeenCalledWith( + 'https://example.com/custom-uvr5.onnx', + 'UVR-MDX-NET-Inst_HQ_3.onnx', + expect.any(Function), + ); + }); + }); + it('prompts for an audio region when the model is cached but nothing is selected', async () => { localModelCached = true; diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 4c26bdb..5272efb 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -18,7 +18,7 @@ import { showAlert } from '../util/dialogUtil'; import { LOCAL_SEPARATOR_MODEL_CONFIG, LOCAL_SEPARATOR_MODEL_FILENAME, - LOCAL_SEPARATOR_MODEL_URL, + LOCAL_SEPARATOR_DEFAULT_MODEL_URL, } from '../util/localSeparatorConfig'; import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache'; import { runLocalSeparator } from '../util/localSeparatorRunner'; @@ -959,6 +959,13 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { return null; }, [selectedRegionIds]); + const getConfiguredLocalSeparatorModelUrl = useCallback(() => { + const configured = ConfigManager.instance().get('general.uvr5_web_runtime.mdx_net_model_url'); + return typeof configured === 'string' && configured.trim() + ? configured + : LOCAL_SEPARATOR_DEFAULT_MODEL_URL; + }, []); + const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error'; const handleDownloadLocalModel = useCallback(async () => { @@ -969,7 +976,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { try { await LocalSeparatorModelCache.download( - LOCAL_SEPARATOR_MODEL_URL, + getConfiguredLocalSeparatorModelUrl(), LOCAL_SEPARATOR_MODEL_FILENAME, progress => { const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1); @@ -992,7 +999,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { } finally { setIsDownloadingLocalModel(false); } - }, [refreshLocalModelCacheState]); + }, [getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]); const handleDeleteLocalModel = useCallback(async () => { setIsDeletingLocalModel(true); diff --git a/src/components/settings/sections/GeneralSettings.test.tsx b/src/components/settings/sections/GeneralSettings.test.tsx index 2ece260..93cfd81 100644 --- a/src/components/settings/sections/GeneralSettings.test.tsx +++ b/src/components/settings/sections/GeneralSettings.test.tsx @@ -3,6 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import GeneralSettings from './GeneralSettings'; +const { localSeparatorModelCacheMock } = vi.hoisted(() => ({ + localSeparatorModelCacheMock: { + delete: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(true), + }, +})); + const configState = new Map([ ['general.llm_provider', 'local_browser'], ['general.persist_api_keys_non_localhost', false], @@ -20,6 +27,8 @@ const configState = new Map([ ['general.openai_compatible.base_url', ''], ['general.openai_compatible.model', ''], ['general.local_browser.context_length', 65536], + ['general.local_browser.model_url', 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task'], + ['general.uvr5_web_runtime.mdx_net_model_url', 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx'], ['general.soundfont.base_url', 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'], ['general.kgone.enabled', false], ['general.kgone.base_url', 'http://127.0.0.1:8000'], @@ -71,6 +80,10 @@ vi.mock('../../../util/localLLMModelManager', () => ({ }, })); +vi.mock('../../../util/localSeparatorModelCache', () => ({ + LocalSeparatorModelCache: localSeparatorModelCacheMock, +})); + describe('GeneralSettings', () => { beforeEach(() => { configState.set('general.local_browser.context_length', 65536); @@ -91,6 +104,9 @@ describe('GeneralSettings', () => { secureContext: true, reason: null, }; + localSeparatorModelCacheMock.delete.mockClear(); + localSeparatorModelCacheMock.exists.mockClear(); + localSeparatorModelCacheMock.exists.mockResolvedValue(true); }); it('renders the local context length selector and VRAM hint', async () => { @@ -119,6 +135,65 @@ describe('GeneralSettings', () => { }); }); + it('renders and persists local runtime download URLs', async () => { + render(); + + expect(await screen.findByDisplayValue('https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task')).toBeTruthy(); + expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx')).toBeTruthy(); + + const inputs = screen.getAllByRole('textbox'); + const gemmaUrlInput = inputs.find(input => + (input as HTMLInputElement).value.includes('gemma-4-E4B-it-web.task'), + ) as HTMLInputElement | undefined; + const uvr5UrlInput = inputs.find(input => + (input as HTMLInputElement).value.includes('UVR-MDX-NET-Inst_HQ_3.onnx'), + ) as HTMLInputElement | undefined; + + expect(gemmaUrlInput).toBeTruthy(); + expect(uvr5UrlInput).toBeTruthy(); + + fireEvent.change(gemmaUrlInput!, { target: { value: 'https://example.com/gemma.task' } }); + fireEvent.change(uvr5UrlInput!, { target: { value: 'https://example.com/uvr5.onnx' } }); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledWith('general.local_browser.model_url', 'https://example.com/gemma.task'); + expect(configManagerMock.set).toHaveBeenCalledWith('general.uvr5_web_runtime.mdx_net_model_url', 'https://example.com/uvr5.onnx'); + }); + }); + + it('restores default download URLs and deletes the UVR5 model cache', async () => { + localSeparatorModelCacheMock.exists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + render(); + + expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy(); + + const restoreLinks = screen.getAllByText('Restore default'); + fireEvent.click(restoreLinks[0]); + fireEvent.click(restoreLinks[1]); + const uvr5DeleteButton = screen.getAllByRole('button', { name: 'Delete Cached Model' })[1]; + expect(uvr5DeleteButton).not.toBeDisabled(); + fireEvent.click(uvr5DeleteButton); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledWith( + 'general.local_browser.model_url', + 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task', + ); + expect(configManagerMock.set).toHaveBeenCalledWith( + 'general.uvr5_web_runtime.mdx_net_model_url', + 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx', + ); + expect(localSeparatorModelCacheMock.delete).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: 'Delete Cached Model' })[1]).toBeDisabled(); + }); + }); + it('keeps local runtime available when runtime may fail on this host', async () => { localModelState.runtimeSupport = { supported: true, diff --git a/src/components/settings/sections/GeneralSettings.tsx b/src/components/settings/sections/GeneralSettings.tsx index dcdde21..a066b9e 100644 --- a/src/components/settings/sections/GeneralSettings.tsx +++ b/src/components/settings/sections/GeneralSettings.tsx @@ -1,15 +1,18 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { ConfigManager } from '../../../core/config/ConfigManager'; import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager'; +import { LocalSeparatorModelCache } from '../../../util/localSeparatorModelCache'; import { formatLocalLLMContextLength, LOCAL_LLM_CONTEXT_LENGTH_OPTIONS, + LOCAL_LLM_DEFAULT_MODEL_URL, LOCAL_LLM_DEFAULT_CONTEXT_LENGTH, LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY, normalizeLocalLLMContextLength, type LocalLLMContextLength, } from '../../../util/localLLMConfig'; +import { LOCAL_SEPARATOR_DEFAULT_MODEL_URL } from '../../../util/localSeparatorConfig'; const GeneralSettings: React.FC = () => { const [llmProvider, setLlmProvider] = useState(LOCAL_LLM_PROVIDER_KEY); @@ -34,6 +37,11 @@ const GeneralSettings: React.FC = () => { const [soundfontServerManaged, setSoundfontServerManaged] = useState(false); const [localContextLength, setLocalContextLength] = useState(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH); const [localModelState, setLocalModelState] = useState(LocalLLMModelManager.getState()); + const [localModelUrl, setLocalModelUrl] = useState(''); + const [uvr5ModelUrl, setUvr5ModelUrl] = useState(''); + const [isUvr5ModelCached, setIsUvr5ModelCached] = useState(false); + const [isCheckingUvr5ModelCache, setIsCheckingUvr5ModelCache] = useState(false); + const [isDeletingUvr5Model, setIsDeletingUvr5Model] = useState(false); const configManager = ConfigManager.instance(); @@ -51,6 +59,18 @@ const GeneralSettings: React.FC = () => { } }, []); + const refreshUvr5ModelCacheState = useCallback(async () => { + setIsCheckingUvr5ModelCache(true); + try { + setIsUvr5ModelCached(await LocalSeparatorModelCache.exists()); + } catch (error) { + console.error('Failed to check UVR5 cached model state:', error); + setIsUvr5ModelCached(false); + } finally { + setIsCheckingUvr5ModelCache(false); + } + }, []); + // Load configuration values on component mount useEffect(() => { const loadConfig = async () => { @@ -74,6 +94,8 @@ const GeneralSettings: React.FC = () => { setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || ''); setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || ''); setLocalContextLength(normalizeLocalLLMContextLength(configManager.get('general.local_browser.context_length'))); + setLocalModelUrl((configManager.get('general.local_browser.model_url') as string) || LOCAL_LLM_DEFAULT_MODEL_URL); + setUvr5ModelUrl((configManager.get('general.uvr5_web_runtime.mdx_net_model_url') as string) || LOCAL_SEPARATOR_DEFAULT_MODEL_URL); setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || ''); setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false); setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || ''); @@ -83,8 +105,9 @@ const GeneralSettings: React.FC = () => { loadConfig(); const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState); + void refreshUvr5ModelCacheState(); return unsubscribe; - }, [configManager]); + }, [configManager, refreshUvr5ModelCacheState]); // Debounced save function for text inputs const debouncedSave = useCallback((key: string, value: string) => { @@ -212,6 +235,16 @@ const GeneralSettings: React.FC = () => { debouncedSave('general.kgone.base_url', value); }; + const handleLocalModelUrlChange = (value: string) => { + setLocalModelUrl(value); + debouncedSave('general.local_browser.model_url', value); + }; + + const handleUvr5ModelUrlChange = (value: string) => { + setUvr5ModelUrl(value); + debouncedSave('general.uvr5_web_runtime.mdx_net_model_url', value); + }; + const handleDeleteLocalModel = async () => { try { await LocalLLMModelManager.deleteCachedModel(); @@ -220,6 +253,19 @@ const GeneralSettings: React.FC = () => { } }; + const handleDeleteUvr5Model = async () => { + setIsDeletingUvr5Model(true); + try { + await LocalSeparatorModelCache.delete(); + setIsUvr5ModelCached(false); + } catch (error) { + console.error('Failed to delete UVR5 cached model:', error); + } finally { + setIsDeletingUvr5Model(false); + await refreshUvr5ModelCacheState(); + } + }; + const handleLocalContextLengthChange = async (value: string) => { const parsed = Number(value); const normalized = normalizeLocalLLMContextLength(parsed); @@ -325,6 +371,32 @@ const GeneralSettings: React.FC = () => { +
+ + handleLocalModelUrlChange(e.target.value)} + /> + +
+ {!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
The local model downloads automatically the next time you chat with `Local LLM (Browser)`. @@ -366,6 +438,47 @@ const GeneralSettings: React.FC = () => {
+
+

UVR5 Web Runtime

+ +
+ + handleUvr5ModelUrlChange(e.target.value)} + /> + +
+ +
+ +
+
+

OpenAI

diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 06e0464..9b08356 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -10,6 +10,10 @@ interface AppConfig { persist_api_keys_non_localhost: boolean; local_browser: { context_length: 32768 | 65536 | 131072; + model_url: string; + }; + uvr5_web_runtime: { + mdx_net_model_url: string; }; openai: { api_key: string; @@ -216,7 +220,11 @@ export class ConfigManager { model: '' }, local_browser: { - context_length: 32768 + context_length: 32768, + model_url: 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task' + }, + uvr5_web_runtime: { + mdx_net_model_url: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx' }, soundfont: { base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/' diff --git a/src/core/io/LocalSeparatorModelCache.test.ts b/src/core/io/LocalSeparatorModelCache.test.ts index 8e96f03..78d5f08 100644 --- a/src/core/io/LocalSeparatorModelCache.test.ts +++ b/src/core/io/LocalSeparatorModelCache.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalSeparatorModelCache } from '../../util/localSeparatorModelCache'; +import { + LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + LOCAL_SEPARATOR_MODEL_FILENAME, +} from '../../util/localSeparatorConfig'; class MockWritableFileStream { private readonly handle: MockFileSystemFileHandle; @@ -109,47 +113,81 @@ vi.stubGlobal('navigator', { }); describe('LocalSeparatorModelCache', () => { + const makeModelBytes = (fill: number): Uint8Array => new Uint8Array(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES).fill(fill); + beforeEach(() => { mockRoot.clear(); vi.restoreAllMocks(); }); it('downloads and stores a model in OPFS cache', async () => { + const bytes = makeModelBytes(1); + vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes, { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + expect(await LocalSeparatorModelCache.exists()).toBe(true); + const buffer = await LocalSeparatorModelCache.getArrayBuffer(); + expect(buffer.byteLength).toBe(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES); + expect(new Uint8Array(buffer)[0]).toBe(1); + }); + + it('replaces a broken cached file on redownload', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(1), { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(9), { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + const buffer = await LocalSeparatorModelCache.getArrayBuffer(); + expect(buffer.byteLength).toBe(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES); + expect(new Uint8Array(buffer)[0]).toBe(9); + }); + + it('deletes the cached model file', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(2), { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + await LocalSeparatorModelCache.delete(); + + expect(await LocalSeparatorModelCache.exists()).toBe(false); + }); + + it('rejects and deletes a cached file when the size is wrong', async () => { + const dir = await navigator.storage.getDirectory(); + const modelsDir = await dir.getDirectoryHandle('models', { create: true }); + const fileHandle = await modelsDir.getFileHandle(LOCAL_SEPARATOR_MODEL_FILENAME, { create: true }); + const fileWritable = await fileHandle.createWritable(); + await fileWritable.write(new Uint8Array([1, 2, 3])); + await fileWritable.close(); + + const sizeHandle = await modelsDir.getFileHandle(`${LOCAL_SEPARATOR_MODEL_FILENAME}.size`, { create: true }); + const sizeWritable = await sizeHandle.createWritable(); + await sizeWritable.write(String(3)); + await sizeWritable.close(); + + expect(await LocalSeparatorModelCache.exists()).toBe(false); + }); + + it('fails a download when the final size does not match the expected model size', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { 'Content-Length': '3' }, }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - expect(await LocalSeparatorModelCache.exists('model.onnx')).toBe(true); - const buffer = await LocalSeparatorModelCache.getArrayBuffer('model.onnx'); - expect(Array.from(new Uint8Array(buffer))).toEqual([1, 2, 3]); - }); - - it('replaces a broken cached file on redownload', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1]), { - status: 200, - headers: { 'Content-Length': '1' }, - }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([9, 8, 7, 6]), { - status: 200, - headers: { 'Content-Length': '4' }, - }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - const buffer = await LocalSeparatorModelCache.getArrayBuffer('model.onnx'); - expect(Array.from(new Uint8Array(buffer))).toEqual([9, 8, 7, 6]); - }); - - it('deletes the cached model file', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2]), { status: 200 }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - await LocalSeparatorModelCache.delete('model.onnx'); - - expect(await LocalSeparatorModelCache.exists('model.onnx')).toBe(false); + await expect(LocalSeparatorModelCache.download('https://example.com/model.onnx')).rejects.toThrow(/size mismatch/i); + expect(await LocalSeparatorModelCache.exists()).toBe(false); }); }); diff --git a/src/util/localLLMConfig.ts b/src/util/localLLMConfig.ts index 053e756..c32a580 100644 --- a/src/util/localLLMConfig.ts +++ b/src/util/localLLMConfig.ts @@ -1,7 +1,8 @@ export const LOCAL_LLM_PROVIDER_KEY = 'local_browser'; -export const LOCAL_LLM_MODEL_URL = +export const LOCAL_LLM_DEFAULT_MODEL_URL = 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task'; export const LOCAL_LLM_MODEL_FILENAME = 'gemma-4-E4B-it-web.task'; +export const LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES = 2964324352; export const LOCAL_LLM_DISPLAY_NAME = 'Gemma 4 E4B'; export const LOCAL_LLM_LEGACY_FILENAMES = [ 'gemma-3n-E4B-it-int4-Web.litertlm', diff --git a/src/util/localLLMModelCache.ts b/src/util/localLLMModelCache.ts index df71f1f..7ebfc9a 100644 --- a/src/util/localLLMModelCache.ts +++ b/src/util/localLLMModelCache.ts @@ -1,5 +1,5 @@ import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache'; -import { LOCAL_LLM_MODEL_FILENAME } from './localLLMConfig'; +import { LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, LOCAL_LLM_MODEL_FILENAME } from './localLLMConfig'; const cache = new OpfsModelCache({ directoryName: 'models' }); let writingToCachePromise: Promise | null = null; @@ -51,7 +51,9 @@ const createProgressReader = ( export class LocalLLMModelCache { public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise { - return cache.exists(filename); + return cache.exists(filename, { + expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, + }); } public static async getFile(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise { @@ -98,6 +100,9 @@ export class LocalLLMModelCache { streamForCache, filename, totalBytes > 0 ? totalBytes : null, + { + expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, + }, progress => onProgress?.({ ...progress, fromCache: false }), ); writingToCachePromise = writingToCachePromise.finally(() => { @@ -117,6 +122,13 @@ export class LocalLLMModelCache { filename: string = LOCAL_LLM_MODEL_FILENAME, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { - await cache.download(sourceUrl, filename, onProgress); + await cache.download( + sourceUrl, + filename, + { + expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, + }, + onProgress, + ); } } diff --git a/src/util/localLLMModelManager.ts b/src/util/localLLMModelManager.ts index 99354ff..82edefa 100644 --- a/src/util/localLLMModelManager.ts +++ b/src/util/localLLMModelManager.ts @@ -2,7 +2,6 @@ import { detectLocalLLMRuntimeSupport, LOCAL_LLM_LEGACY_FILENAMES, LOCAL_LLM_MODEL_FILENAME, - LOCAL_LLM_MODEL_URL, type LocalLLMRuntimeSupport, } from './localLLMConfig'; import { LocalLLMModelCache } from './localLLMModelCache'; diff --git a/src/util/localSeparatorConfig.ts b/src/util/localSeparatorConfig.ts index 752f468..4a52ec5 100644 --- a/src/util/localSeparatorConfig.ts +++ b/src/util/localSeparatorConfig.ts @@ -1,9 +1,10 @@ import type { LocalSeparatorModelConfig } from './localSeparatorTypes'; -export const LOCAL_SEPARATOR_MODEL_URL = +export const LOCAL_SEPARATOR_DEFAULT_MODEL_URL = 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx'; export const LOCAL_SEPARATOR_MODEL_FILENAME = 'UVR-MDX-NET-Inst_HQ_3.onnx'; +export const LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES = 66759214; export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = { filename: LOCAL_SEPARATOR_MODEL_FILENAME, diff --git a/src/util/localSeparatorModelCache.ts b/src/util/localSeparatorModelCache.ts index b941cd1..619cbc5 100644 --- a/src/util/localSeparatorModelCache.ts +++ b/src/util/localSeparatorModelCache.ts @@ -1,4 +1,7 @@ -import { LOCAL_SEPARATOR_MODEL_FILENAME } from './localSeparatorConfig'; +import { + LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + LOCAL_SEPARATOR_MODEL_FILENAME, +} from './localSeparatorConfig'; import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache'; const cache = new OpfsModelCache({ directoryName: 'models' }); @@ -7,7 +10,9 @@ export { type ModelDownloadProgress }; export class LocalSeparatorModelCache { public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { - return cache.exists(filename); + return cache.exists(filename, { + expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + }); } public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { @@ -27,6 +32,13 @@ export class LocalSeparatorModelCache { filename: string = LOCAL_SEPARATOR_MODEL_FILENAME, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { - await cache.download(sourceUrl, filename, onProgress); + await cache.download( + sourceUrl, + filename, + { + expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + }, + onProgress, + ); } } diff --git a/src/util/opfsModelCache.ts b/src/util/opfsModelCache.ts index 02044f6..766326b 100644 --- a/src/util/opfsModelCache.ts +++ b/src/util/opfsModelCache.ts @@ -9,6 +9,10 @@ interface OpfsModelCacheOptions { sizeSuffix?: string; } +interface ModelCacheValidationOptions { + expectedSizeBytes?: number | null; +} + export class OpfsModelCache { private readonly directoryName: string; private readonly sizeSuffix: string; @@ -18,7 +22,7 @@ export class OpfsModelCache { this.sizeSuffix = options.sizeSuffix ?? '.size'; } - public async exists(filename: string): Promise { + public async exists(filename: string, options: ModelCacheValidationOptions = {}): Promise { try { const dir = await this.getDir(); const fileHandle = await dir.getFileHandle(filename); @@ -29,6 +33,10 @@ export class OpfsModelCache { await this.delete(filename); return false; } + if (options.expectedSizeBytes != null && expectedSize !== options.expectedSizeBytes) { + await this.delete(filename); + return false; + } if (file.size !== expectedSize) { await this.delete(filename); return false; @@ -64,6 +72,7 @@ export class OpfsModelCache { public async download( sourceUrl: string, filename: string, + options: ModelCacheValidationOptions = {}, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { const response = await fetch(sourceUrl); @@ -75,13 +84,14 @@ export class OpfsModelCache { if (!response.body) { throw new Error('Model download response did not include a readable body.'); } - await this.downloadStream(response.body, filename, totalBytes, onProgress); + await this.downloadStream(response.body, filename, totalBytes, options, onProgress); } public async downloadStream( stream: ReadableStream, filename: string, totalBytes: number | null, + options: ModelCacheValidationOptions = {}, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { const dir = await this.getDir(); @@ -111,6 +121,9 @@ export class OpfsModelCache { if (!Number.isFinite(sizeValue) || sizeValue <= 0) { throw new Error('Model download did not provide a valid size.'); } + if (options.expectedSizeBytes != null && receivedBytes !== options.expectedSizeBytes) { + throw new Error(`Model download size mismatch for ${filename}: expected ${options.expectedSizeBytes} bytes, got ${receivedBytes}.`); + } const sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true }); const sizeWritable = await sizeHandle.createWritable();