feat: allow user to config Gemma 4 E4B and MDX-NET model download URL
This commit is contained in:
+5
-1
@@ -27,7 +27,11 @@
|
|||||||
"model": ""
|
"model": ""
|
||||||
},
|
},
|
||||||
"local_browser": {
|
"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": {
|
"soundfont": {
|
||||||
"base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
|
"base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
||||||
LOCAL_LLM_MODEL_FILENAME,
|
LOCAL_LLM_MODEL_FILENAME,
|
||||||
LOCAL_LLM_MODEL_URL,
|
LOCAL_LLM_DEFAULT_MODEL_URL,
|
||||||
normalizeLocalLLMContextLength,
|
normalizeLocalLLMContextLength,
|
||||||
} from '../../util/localLLMConfig';
|
} from '../../util/localLLMConfig';
|
||||||
import { LocalLLMModelCache } from '../../util/localLLMModelCache';
|
import { LocalLLMModelCache } from '../../util/localLLMModelCache';
|
||||||
@@ -67,12 +67,13 @@ export class LocalBrowserLLMProvider implements LLMProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxTokens = this.getConfiguredContextLength();
|
const maxTokens = this.getConfiguredContextLength();
|
||||||
|
const modelUrl = this.getConfiguredModelUrl();
|
||||||
console.log(`[localLLM] Initializing with max context length: ${maxTokens} tokens`);
|
console.log(`[localLLM] Initializing with max context length: ${maxTokens} tokens`);
|
||||||
|
|
||||||
const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([
|
const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([
|
||||||
this.getMediaPipeModule(),
|
this.getMediaPipeModule(),
|
||||||
LocalLLMModelCache.loadModelReaderWithCache(
|
LocalLLMModelCache.loadModelReaderWithCache(
|
||||||
LOCAL_LLM_MODEL_URL,
|
modelUrl,
|
||||||
LOCAL_LLM_MODEL_FILENAME,
|
LOCAL_LLM_MODEL_FILENAME,
|
||||||
progress => {
|
progress => {
|
||||||
LocalLLMModelManager.notifyLoadProgress(progress.receivedBytes, progress.totalBytes, progress.fromCache);
|
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 {
|
private applyTemplate(message: { role: 'user' | 'model'; text: string }): string {
|
||||||
const template = PROMPT_TEMPLATE[message.role];
|
const template = PROMPT_TEMPLATE[message.role];
|
||||||
return `${template.pre}${message.text}${template.post}`;
|
return `${template.pre}${message.text}${template.post}`;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import KGOnePanel from './KGOnePanel';
|
|||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
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 kgoneEnabled = false;
|
||||||
let selectedRegionIds: string[] = [];
|
let selectedRegionIds: string[] = [];
|
||||||
let localModelCached = false;
|
let localModelCached = false;
|
||||||
@@ -31,6 +35,7 @@ vi.mock('../core/config/ConfigManager', () => ({
|
|||||||
get: (key: string) => {
|
get: (key: string) => {
|
||||||
if (key === 'general.kgone.enabled') return kgoneEnabled;
|
if (key === 'general.kgone.enabled') return kgoneEnabled;
|
||||||
if (key === 'general.kgone.base_url') return 'http://127.0.0.1:8000';
|
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;
|
return undefined;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -79,8 +84,9 @@ vi.mock('../util/audioUtil', () => ({
|
|||||||
vi.mock('../util/localSeparatorModelCache', () => ({
|
vi.mock('../util/localSeparatorModelCache', () => ({
|
||||||
LocalSeparatorModelCache: {
|
LocalSeparatorModelCache: {
|
||||||
exists: vi.fn(async () => localModelCached),
|
exists: vi.fn(async () => localModelCached),
|
||||||
download: vi.fn(async () => {
|
download: vi.fn(async (url: string, filename: string, onProgress: (progress: unknown) => void) => {
|
||||||
localModelCached = true;
|
localModelCached = true;
|
||||||
|
return mockLocalSeparatorDownload(url, filename, onProgress);
|
||||||
}),
|
}),
|
||||||
delete: vi.fn(async () => {
|
delete: vi.fn(async () => {
|
||||||
localModelCached = false;
|
localModelCached = false;
|
||||||
@@ -124,6 +130,7 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
||||||
{ name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) },
|
{ name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) },
|
||||||
];
|
];
|
||||||
|
mockLocalSeparatorDownload.mockClear();
|
||||||
mockRefreshProjectState.mockReset();
|
mockRefreshProjectState.mockReset();
|
||||||
mockExecuteCommand.mockReset();
|
mockExecuteCommand.mockReset();
|
||||||
});
|
});
|
||||||
@@ -155,6 +162,20 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument();
|
expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the configured UVR5 model URL when downloading the local model', async () => {
|
||||||
|
render(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
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 () => {
|
it('prompts for an audio region when the model is cached but nothing is selected', async () => {
|
||||||
localModelCached = true;
|
localModelCached = true;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { showAlert } from '../util/dialogUtil';
|
|||||||
import {
|
import {
|
||||||
LOCAL_SEPARATOR_MODEL_CONFIG,
|
LOCAL_SEPARATOR_MODEL_CONFIG,
|
||||||
LOCAL_SEPARATOR_MODEL_FILENAME,
|
LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
LOCAL_SEPARATOR_MODEL_URL,
|
LOCAL_SEPARATOR_DEFAULT_MODEL_URL,
|
||||||
} from '../util/localSeparatorConfig';
|
} from '../util/localSeparatorConfig';
|
||||||
import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache';
|
import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache';
|
||||||
import { runLocalSeparator } from '../util/localSeparatorRunner';
|
import { runLocalSeparator } from '../util/localSeparatorRunner';
|
||||||
@@ -959,6 +959,13 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
return null;
|
return null;
|
||||||
}, [selectedRegionIds]);
|
}, [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 isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
|
||||||
|
|
||||||
const handleDownloadLocalModel = useCallback(async () => {
|
const handleDownloadLocalModel = useCallback(async () => {
|
||||||
@@ -969,7 +976,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await LocalSeparatorModelCache.download(
|
await LocalSeparatorModelCache.download(
|
||||||
LOCAL_SEPARATOR_MODEL_URL,
|
getConfiguredLocalSeparatorModelUrl(),
|
||||||
LOCAL_SEPARATOR_MODEL_FILENAME,
|
LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
progress => {
|
progress => {
|
||||||
const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
||||||
@@ -992,7 +999,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsDownloadingLocalModel(false);
|
setIsDownloadingLocalModel(false);
|
||||||
}
|
}
|
||||||
}, [refreshLocalModelCacheState]);
|
}, [getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]);
|
||||||
|
|
||||||
const handleDeleteLocalModel = useCallback(async () => {
|
const handleDeleteLocalModel = useCallback(async () => {
|
||||||
setIsDeletingLocalModel(true);
|
setIsDeletingLocalModel(true);
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import GeneralSettings from './GeneralSettings';
|
import GeneralSettings from './GeneralSettings';
|
||||||
|
|
||||||
|
const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
|
||||||
|
localSeparatorModelCacheMock: {
|
||||||
|
delete: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exists: vi.fn().mockResolvedValue(true),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const configState = new Map<string, unknown>([
|
const configState = new Map<string, unknown>([
|
||||||
['general.llm_provider', 'local_browser'],
|
['general.llm_provider', 'local_browser'],
|
||||||
['general.persist_api_keys_non_localhost', false],
|
['general.persist_api_keys_non_localhost', false],
|
||||||
@@ -20,6 +27,8 @@ const configState = new Map<string, unknown>([
|
|||||||
['general.openai_compatible.base_url', ''],
|
['general.openai_compatible.base_url', ''],
|
||||||
['general.openai_compatible.model', ''],
|
['general.openai_compatible.model', ''],
|
||||||
['general.local_browser.context_length', 65536],
|
['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.soundfont.base_url', 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'],
|
||||||
['general.kgone.enabled', false],
|
['general.kgone.enabled', false],
|
||||||
['general.kgone.base_url', 'http://127.0.0.1:8000'],
|
['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', () => {
|
describe('GeneralSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
configState.set('general.local_browser.context_length', 65536);
|
configState.set('general.local_browser.context_length', 65536);
|
||||||
@@ -91,6 +104,9 @@ describe('GeneralSettings', () => {
|
|||||||
secureContext: true,
|
secureContext: true,
|
||||||
reason: null,
|
reason: null,
|
||||||
};
|
};
|
||||||
|
localSeparatorModelCacheMock.delete.mockClear();
|
||||||
|
localSeparatorModelCacheMock.exists.mockClear();
|
||||||
|
localSeparatorModelCacheMock.exists.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the local context length selector and VRAM hint', async () => {
|
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(<GeneralSettings />);
|
||||||
|
|
||||||
|
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(<GeneralSettings />);
|
||||||
|
|
||||||
|
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 () => {
|
it('keeps local runtime available when runtime may fail on this host', async () => {
|
||||||
localModelState.runtimeSupport = {
|
localModelState.runtimeSupport = {
|
||||||
supported: true,
|
supported: true,
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
||||||
|
import { LocalSeparatorModelCache } from '../../../util/localSeparatorModelCache';
|
||||||
import {
|
import {
|
||||||
formatLocalLLMContextLength,
|
formatLocalLLMContextLength,
|
||||||
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
|
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
|
||||||
|
LOCAL_LLM_DEFAULT_MODEL_URL,
|
||||||
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
||||||
LOCAL_LLM_DISPLAY_NAME,
|
LOCAL_LLM_DISPLAY_NAME,
|
||||||
LOCAL_LLM_PROVIDER_KEY,
|
LOCAL_LLM_PROVIDER_KEY,
|
||||||
normalizeLocalLLMContextLength,
|
normalizeLocalLLMContextLength,
|
||||||
type LocalLLMContextLength,
|
type LocalLLMContextLength,
|
||||||
} from '../../../util/localLLMConfig';
|
} from '../../../util/localLLMConfig';
|
||||||
|
import { LOCAL_SEPARATOR_DEFAULT_MODEL_URL } from '../../../util/localSeparatorConfig';
|
||||||
|
|
||||||
const GeneralSettings: React.FC = () => {
|
const GeneralSettings: React.FC = () => {
|
||||||
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
|
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
|
||||||
@@ -34,6 +37,11 @@ const GeneralSettings: React.FC = () => {
|
|||||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||||
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
||||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||||
|
const [localModelUrl, setLocalModelUrl] = useState<string>('');
|
||||||
|
const [uvr5ModelUrl, setUvr5ModelUrl] = useState<string>('');
|
||||||
|
const [isUvr5ModelCached, setIsUvr5ModelCached] = useState<boolean>(false);
|
||||||
|
const [isCheckingUvr5ModelCache, setIsCheckingUvr5ModelCache] = useState<boolean>(false);
|
||||||
|
const [isDeletingUvr5Model, setIsDeletingUvr5Model] = useState<boolean>(false);
|
||||||
|
|
||||||
const configManager = ConfigManager.instance();
|
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
|
// Load configuration values on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -74,6 +94,8 @@ const GeneralSettings: React.FC = () => {
|
|||||||
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
||||||
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
||||||
setLocalContextLength(normalizeLocalLLMContextLength(configManager.get('general.local_browser.context_length')));
|
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) || '');
|
setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || '');
|
||||||
setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false);
|
setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false);
|
||||||
setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || '');
|
setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || '');
|
||||||
@@ -83,8 +105,9 @@ const GeneralSettings: React.FC = () => {
|
|||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
||||||
|
void refreshUvr5ModelCacheState();
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [configManager]);
|
}, [configManager, refreshUvr5ModelCacheState]);
|
||||||
|
|
||||||
// Debounced save function for text inputs
|
// Debounced save function for text inputs
|
||||||
const debouncedSave = useCallback((key: string, value: string) => {
|
const debouncedSave = useCallback((key: string, value: string) => {
|
||||||
@@ -212,6 +235,16 @@ const GeneralSettings: React.FC = () => {
|
|||||||
debouncedSave('general.kgone.base_url', value);
|
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 () => {
|
const handleDeleteLocalModel = async () => {
|
||||||
try {
|
try {
|
||||||
await LocalLLMModelManager.deleteCachedModel();
|
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 handleLocalContextLengthChange = async (value: string) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
const normalized = normalizeLocalLLMContextLength(parsed);
|
const normalized = normalizeLocalLLMContextLength(parsed);
|
||||||
@@ -325,6 +371,32 @@ const GeneralSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Download URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
placeholder={`e.g. ${LOCAL_LLM_DEFAULT_MODEL_URL}`}
|
||||||
|
value={localModelUrl}
|
||||||
|
onChange={(e) => handleLocalModelUrlChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Changing this URL may break downloads or point to an incompatible model file.{' '}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLocalModelUrlChange(LOCAL_LLM_DEFAULT_MODEL_URL);
|
||||||
|
}}
|
||||||
|
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Restore default
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
||||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
||||||
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
|
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
|
||||||
@@ -366,6 +438,47 @@ const GeneralSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>UVR5 Web Runtime</h4>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
UVR-MDX-NET-Inst_HQ_3 Download URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
placeholder={`e.g. ${LOCAL_SEPARATOR_DEFAULT_MODEL_URL}`}
|
||||||
|
value={uvr5ModelUrl}
|
||||||
|
onChange={(e) => handleUvr5ModelUrlChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Changing this URL may break downloads or point to an incompatible model file.{' '}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUvr5ModelUrlChange(LOCAL_SEPARATOR_DEFAULT_MODEL_URL);
|
||||||
|
}}
|
||||||
|
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Restore default
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item" style={{ marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-btn settings-btn-danger"
|
||||||
|
onClick={() => void handleDeleteUvr5Model()}
|
||||||
|
disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached}
|
||||||
|
>
|
||||||
|
{isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<h4>OpenAI</h4>
|
<h4>OpenAI</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ interface AppConfig {
|
|||||||
persist_api_keys_non_localhost: boolean;
|
persist_api_keys_non_localhost: boolean;
|
||||||
local_browser: {
|
local_browser: {
|
||||||
context_length: 32768 | 65536 | 131072;
|
context_length: 32768 | 65536 | 131072;
|
||||||
|
model_url: string;
|
||||||
|
};
|
||||||
|
uvr5_web_runtime: {
|
||||||
|
mdx_net_model_url: string;
|
||||||
};
|
};
|
||||||
openai: {
|
openai: {
|
||||||
api_key: string;
|
api_key: string;
|
||||||
@@ -216,7 +220,11 @@ export class ConfigManager {
|
|||||||
model: ''
|
model: ''
|
||||||
},
|
},
|
||||||
local_browser: {
|
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: {
|
soundfont: {
|
||||||
base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'
|
base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { LocalSeparatorModelCache } from '../../util/localSeparatorModelCache';
|
import { LocalSeparatorModelCache } from '../../util/localSeparatorModelCache';
|
||||||
|
import {
|
||||||
|
LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
|
} from '../../util/localSeparatorConfig';
|
||||||
|
|
||||||
class MockWritableFileStream {
|
class MockWritableFileStream {
|
||||||
private readonly handle: MockFileSystemFileHandle;
|
private readonly handle: MockFileSystemFileHandle;
|
||||||
@@ -109,47 +113,81 @@ vi.stubGlobal('navigator', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('LocalSeparatorModelCache', () => {
|
describe('LocalSeparatorModelCache', () => {
|
||||||
|
const makeModelBytes = (fill: number): Uint8Array => new Uint8Array(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES).fill(fill);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRoot.clear();
|
mockRoot.clear();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('downloads and stores a model in OPFS cache', async () => {
|
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]), {
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Length': '3' },
|
headers: { 'Content-Length': '3' },
|
||||||
})));
|
})));
|
||||||
|
|
||||||
await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx');
|
await expect(LocalSeparatorModelCache.download('https://example.com/model.onnx')).rejects.toThrow(/size mismatch/i);
|
||||||
|
expect(await LocalSeparatorModelCache.exists()).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export const LOCAL_LLM_PROVIDER_KEY = 'local_browser';
|
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';
|
'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_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_DISPLAY_NAME = 'Gemma 4 E4B';
|
||||||
export const LOCAL_LLM_LEGACY_FILENAMES = [
|
export const LOCAL_LLM_LEGACY_FILENAMES = [
|
||||||
'gemma-3n-E4B-it-int4-Web.litertlm',
|
'gemma-3n-E4B-it-int4-Web.litertlm',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache';
|
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' });
|
const cache = new OpfsModelCache({ directoryName: 'models' });
|
||||||
let writingToCachePromise: Promise<void> | null = null;
|
let writingToCachePromise: Promise<void> | null = null;
|
||||||
@@ -51,7 +51,9 @@ const createProgressReader = (
|
|||||||
|
|
||||||
export class LocalLLMModelCache {
|
export class LocalLLMModelCache {
|
||||||
public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<boolean> {
|
public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<boolean> {
|
||||||
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<File> {
|
public static async getFile(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<File> {
|
||||||
@@ -98,6 +100,9 @@ export class LocalLLMModelCache {
|
|||||||
streamForCache,
|
streamForCache,
|
||||||
filename,
|
filename,
|
||||||
totalBytes > 0 ? totalBytes : null,
|
totalBytes > 0 ? totalBytes : null,
|
||||||
|
{
|
||||||
|
expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
},
|
||||||
progress => onProgress?.({ ...progress, fromCache: false }),
|
progress => onProgress?.({ ...progress, fromCache: false }),
|
||||||
);
|
);
|
||||||
writingToCachePromise = writingToCachePromise.finally(() => {
|
writingToCachePromise = writingToCachePromise.finally(() => {
|
||||||
@@ -117,6 +122,13 @@ export class LocalLLMModelCache {
|
|||||||
filename: string = LOCAL_LLM_MODEL_FILENAME,
|
filename: string = LOCAL_LLM_MODEL_FILENAME,
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await cache.download(sourceUrl, filename, onProgress);
|
await cache.download(
|
||||||
|
sourceUrl,
|
||||||
|
filename,
|
||||||
|
{
|
||||||
|
expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
detectLocalLLMRuntimeSupport,
|
detectLocalLLMRuntimeSupport,
|
||||||
LOCAL_LLM_LEGACY_FILENAMES,
|
LOCAL_LLM_LEGACY_FILENAMES,
|
||||||
LOCAL_LLM_MODEL_FILENAME,
|
LOCAL_LLM_MODEL_FILENAME,
|
||||||
LOCAL_LLM_MODEL_URL,
|
|
||||||
type LocalLLMRuntimeSupport,
|
type LocalLLMRuntimeSupport,
|
||||||
} from './localLLMConfig';
|
} from './localLLMConfig';
|
||||||
import { LocalLLMModelCache } from './localLLMModelCache';
|
import { LocalLLMModelCache } from './localLLMModelCache';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { LocalSeparatorModelConfig } from './localSeparatorTypes';
|
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';
|
'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_FILENAME = 'UVR-MDX-NET-Inst_HQ_3.onnx';
|
||||||
|
export const LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES = 66759214;
|
||||||
|
|
||||||
export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = {
|
export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = {
|
||||||
filename: LOCAL_SEPARATOR_MODEL_FILENAME,
|
filename: LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
|
|||||||
@@ -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';
|
import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache';
|
||||||
|
|
||||||
const cache = new OpfsModelCache({ directoryName: 'models' });
|
const cache = new OpfsModelCache({ directoryName: 'models' });
|
||||||
@@ -7,7 +10,9 @@ export { type ModelDownloadProgress };
|
|||||||
|
|
||||||
export class LocalSeparatorModelCache {
|
export class LocalSeparatorModelCache {
|
||||||
public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<boolean> {
|
public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<boolean> {
|
||||||
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<File> {
|
public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<File> {
|
||||||
@@ -27,6 +32,13 @@ export class LocalSeparatorModelCache {
|
|||||||
filename: string = LOCAL_SEPARATOR_MODEL_FILENAME,
|
filename: string = LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await cache.download(sourceUrl, filename, onProgress);
|
await cache.download(
|
||||||
|
sourceUrl,
|
||||||
|
filename,
|
||||||
|
{
|
||||||
|
expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface OpfsModelCacheOptions {
|
|||||||
sizeSuffix?: string;
|
sizeSuffix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelCacheValidationOptions {
|
||||||
|
expectedSizeBytes?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpfsModelCache {
|
export class OpfsModelCache {
|
||||||
private readonly directoryName: string;
|
private readonly directoryName: string;
|
||||||
private readonly sizeSuffix: string;
|
private readonly sizeSuffix: string;
|
||||||
@@ -18,7 +22,7 @@ export class OpfsModelCache {
|
|||||||
this.sizeSuffix = options.sizeSuffix ?? '.size';
|
this.sizeSuffix = options.sizeSuffix ?? '.size';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(filename: string): Promise<boolean> {
|
public async exists(filename: string, options: ModelCacheValidationOptions = {}): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const dir = await this.getDir();
|
const dir = await this.getDir();
|
||||||
const fileHandle = await dir.getFileHandle(filename);
|
const fileHandle = await dir.getFileHandle(filename);
|
||||||
@@ -29,6 +33,10 @@ export class OpfsModelCache {
|
|||||||
await this.delete(filename);
|
await this.delete(filename);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (options.expectedSizeBytes != null && expectedSize !== options.expectedSizeBytes) {
|
||||||
|
await this.delete(filename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (file.size !== expectedSize) {
|
if (file.size !== expectedSize) {
|
||||||
await this.delete(filename);
|
await this.delete(filename);
|
||||||
return false;
|
return false;
|
||||||
@@ -64,6 +72,7 @@ export class OpfsModelCache {
|
|||||||
public async download(
|
public async download(
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
options: ModelCacheValidationOptions = {},
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(sourceUrl);
|
const response = await fetch(sourceUrl);
|
||||||
@@ -75,13 +84,14 @@ export class OpfsModelCache {
|
|||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new Error('Model download response did not include a readable 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(
|
public async downloadStream(
|
||||||
stream: ReadableStream<Uint8Array>,
|
stream: ReadableStream<Uint8Array>,
|
||||||
filename: string,
|
filename: string,
|
||||||
totalBytes: number | null,
|
totalBytes: number | null,
|
||||||
|
options: ModelCacheValidationOptions = {},
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dir = await this.getDir();
|
const dir = await this.getDir();
|
||||||
@@ -111,6 +121,9 @@ export class OpfsModelCache {
|
|||||||
if (!Number.isFinite(sizeValue) || sizeValue <= 0) {
|
if (!Number.isFinite(sizeValue) || sizeValue <= 0) {
|
||||||
throw new Error('Model download did not provide a valid size.');
|
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 sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true });
|
||||||
const sizeWritable = await sizeHandle.createWritable();
|
const sizeWritable = await sizeHandle.createWritable();
|
||||||
|
|||||||
Reference in New Issue
Block a user