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 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-09-24 13:28:31 -06:00
parent 5441d096ae
commit 49ff97c3ad
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 77 additions and 3 deletions

View file

@ -26,7 +26,9 @@ export function AiModelRow({model, onDownload}) {
<Table.Cell>{humanFileSize(size)}</Table.Cell>
<Table.Cell>
{downloaded
? <Button size='xs' icon='check' disabled role='save'>Downloaded</Button>
? <Button size='xs' icon='check' disabled role='save'>
{tier === 'custom' ? 'On disk' : 'Downloaded'}
</Button>
: <APIButton size='xs' icon='download' color='violet' onClick={() => onDownload(model)}>
Download
</APIButton>}
@ -184,6 +186,10 @@ export function ManageAi() {
<p>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 <b>Downloads</b> page.</p>
<p>You can also copy your own GGUF file into <b>ai/models/</b>; it will appear here as a
<b>custom</b> 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 <b>context_size</b> in <b>ai.yaml</b>.</p>
</InfoMessage>
</>;
}

View file

@ -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(<ManageAi/>);
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(<ManageAi/>);

View file

@ -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(

View file

@ -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