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