(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 = () => {
+
+
+ Download URL
+
+
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
+
+
+
+ UVR-MDX-NET-Inst_HQ_3 Download URL
+
+
handleUvr5ModelUrlChange(e.target.value)}
+ />
+
+
+
+
+ void handleDeleteUvr5Model()}
+ disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached}
+ >
+ {isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'}
+
+
+
+
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();