mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
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:
parent
5441d096ae
commit
49ff97c3ad
4 changed files with 77 additions and 3 deletions
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/>);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue