From 49ff97c3ad70bb71c476fe5a16c2dc0ced49c055 Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 24 Sep 2026 13:28:31 -0600 Subject: [PATCH] ai: list custom GGUF files in the Manage tab A GGUF copied into ai/models/ by hand could be activated by editing ai.yaml, but never appeared in the Manage tab because the catalog endpoint only returned CDN/bundled models. The Active Model dropdown builds from downloaded models, so a custom file was unselectable. The catalog endpoint now merges any on-disk *.gguf that is not in the catalog as a `custom` tier model with its real size and no URL. It is discovered on every request, so a file appears the next time the page loads; the file refresh is not involved and does not index models. Catalog models are never duplicated and in-progress .tmp downloads are ignored. Custom rows read "On disk" and the hint explains the copy-in path, the tool-calling requirement, and the context_size fallback. Co-Authored-By: Claude Fable 5.1 --- app/src/components/Ai.js | 8 +++++- app/src/components/Ai.test.js | 18 +++++++++++++ modules/ai/api.py | 18 +++++++++++-- modules/ai/test/test_config_catalog.py | 36 ++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/app/src/components/Ai.js b/app/src/components/Ai.js index b5af8916..01197b1d 100644 --- a/app/src/components/Ai.js +++ b/app/src/components/Ai.js @@ -26,7 +26,9 @@ export function AiModelRow({model, onDownload}) { {humanFileSize(size)} {downloaded - ? + ? : onDownload(model)}> Download } @@ -184,6 +186,10 @@ export function ManageAi() {

The AI assistant runs entirely on this WROLPi — no internet is needed after a model is downloaded. Download a model, select it, then enable the assistant. Monitor download progress on the Downloads page.

+

You can also copy your own GGUF file into ai/models/; it will appear here as a + custom model the next time this page loads. The model must support tool calling + (Qwen3, Llama 3.x, and Mistral instruct models do). Custom models use the default + context size unless you set context_size in ai.yaml.

; } diff --git a/app/src/components/Ai.test.js b/app/src/components/Ai.test.js index 7cf1a0ee..12775237 100644 --- a/app/src/components/Ai.test.js +++ b/app/src/components/Ai.test.js @@ -104,6 +104,24 @@ describe('ManageAi', () => { await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({enabled: true})); }); + it('lists a custom GGUF from ai/models in the Active Model dropdown', async () => { + const custom = { + name: 'My-Custom-Model-Q8_0.gguf', tier: 'custom', size: 3_000_000_000, url: null, + description: 'Custom model found in ai/models/. Not from the WROLPi catalog.', + downloaded: true, active: false, + }; + const fixture = catalogFixture(); + getAiCatalog.mockResolvedValue({...fixture, models: [...fixture.models, custom]}); + render(); + await waitFor(() => expect(screen.getByText('On disk')).toBeInTheDocument()); + expect(screen.getByRole('cell', {name: 'custom'})).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('textbox', {name: 'Active Model'})); + const option = await screen.findByRole('option', {name: 'My-Custom-Model-Q8_0.gguf'}); + fireEvent.click(option); + await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({active_model: 'My-Custom-Model-Q8_0.gguf'})); + }); + it('shows the slow-hardware warning on a Pi 4', async () => { getAiCatalog.mockResolvedValue(catalogFixture({slow_hardware: true})); render(); diff --git a/modules/ai/api.py b/modules/ai/api.py index c0257e33..5bf3c44c 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -740,12 +740,26 @@ async def read_content(request: Request, file_group_id: int): async def manage_catalog(_: Request): models, source = await catalog.get_models_catalog() models_directory = catalog.get_models_directory() - downloaded = {i.name for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else set() + on_disk = {i.name: i for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else dict() disk_usage = sum(i.stat().st_size for i in models_directory.glob('*')) if models_directory.is_dir() else 0 config = get_ai_config() - models = [dict(i, downloaded=i['name'] in downloaded, active=i['name'] == config.active_model) + models = [dict(i, downloaded=i['name'] in on_disk, active=i['name'] == config.active_model) for i in models] + # Any GGUF the user copied into ai/models themselves is a "custom" model. It is discovered + # here on every request (no file refresh) so it appears in the Active Model dropdown. + catalog_names = {i['name'] for i in models} + for name, path in sorted(on_disk.items()): + if name not in catalog_names: + models.append(dict( + name=name, + tier='custom', + url=None, + size=path.stat().st_size, + description='Custom model found in ai/models/. Not from the WROLPi catalog.', + downloaded=True, + active=name == config.active_model, + )) total_ram = catalog.get_total_ram_bytes() return json_response(dict( diff --git a/modules/ai/test/test_config_catalog.py b/modules/ai/test/test_config_catalog.py index e5c78631..91ab97ad 100644 --- a/modules/ai/test/test_config_catalog.py +++ b/modules/ai/test/test_config_catalog.py @@ -192,3 +192,39 @@ async def test_manage_settings_adopts_model_context(async_client, test_directory content = dict(active_model='Qwen3-4B-Instruct-2507-Q4_K_M.gguf', context_size=4_096) request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content)) assert response.json['context_size'] == 4_096 + + +@pytest.mark.asyncio +async def test_manage_catalog_lists_custom_models(async_client, test_directory, test_ai_config): + """A GGUF the user copied into ai/models (not from the catalog) is listed as a custom model so it + can be selected in the Manage tab. It is discovered on every request; no file refresh needed.""" + models_dir = test_directory / 'ai/models' + models_dir.mkdir(parents=True) + (models_dir / 'My-Custom-Model-Q8_0.gguf').write_bytes(b'GGUF' * 10) + (models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF') + (models_dir / 'partial.gguf.tmp').write_bytes(b'x') # an in-progress download is not a model + + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + request, response = await async_client.get('/api/ai/manage/catalog') + assert response.status_code == HTTPStatus.OK + names = [i['name'] for i in response.json['models']] + assert names.count('Qwen3-1.7B-Q4_K_M.gguf') == 1, 'catalog model must not be duplicated' + assert 'partial.gguf.tmp' not in names + + custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf') + assert custom['tier'] == 'custom' + assert custom['downloaded'] is True + assert custom['active'] is False + assert custom['size'] == 40 + assert custom['url'] is None + + # It can be activated like any downloaded model; it has no catalog context default. + content = dict(active_model='My-Custom-Model-Q8_0.gguf') + request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['active_model'] == 'My-Custom-Model-Q8_0.gguf' + assert response.json['context_size'] is None + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + request, response = await async_client.get('/api/ai/manage/catalog') + custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf') + assert custom['active'] is True