feat: allow user to config Gemma 4 E4B and MDX-NET model download URL
This commit is contained in:
@@ -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(<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 () => {
|
||||
localModelCached = true;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>([
|
||||
['general.llm_provider', 'local_browser'],
|
||||
['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.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(<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 () => {
|
||||
localModelState.runtimeSupport = {
|
||||
supported: true,
|
||||
|
||||
@@ -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<string>(LOCAL_LLM_PROVIDER_KEY);
|
||||
@@ -34,6 +37,11 @@ const GeneralSettings: React.FC = () => {
|
||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
||||
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();
|
||||
|
||||
@@ -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 = () => {
|
||||
</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 && (
|
||||
<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)`.
|
||||
@@ -366,6 +438,47 @@ const GeneralSettings: React.FC = () => {
|
||||
</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">
|
||||
<h4>OpenAI</h4>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user