improved model management

This commit is contained in:
2025-09-20 13:11:26 +02:00
parent b1be9afa78
commit 05d0b18f95
3 changed files with 144 additions and 46 deletions

View File

@@ -12,6 +12,7 @@ class OllamaDownloaderComponent(AsyncCard):
self.download_status = '' self.download_status = ''
async def build(self) -> None: async def build(self) -> None:
self.classes('w-full')
with self: with self:
with ui.column().classes('w-full gap-4'): with ui.column().classes('w-full gap-4'):
ui.label('Model Downloader').classes('text-xl font-bold') ui.label('Model Downloader').classes('text-xl font-bold')
@@ -21,8 +22,9 @@ class OllamaDownloaderComponent(AsyncCard):
placeholder='e.g., TheBloke/Llama-2-7B-GGUF', placeholder='e.g., TheBloke/Llama-2-7B-GGUF',
value='qwen2.5:0.5b' value='qwen2.5:0.5b'
).props('outlined dense').classes('w-full') ).props('outlined dense').classes('w-full')
ui.link('Ollama Library', target='https://ollama.com/library/', new_tab=True) with ui.row().classes('items-center gap-2'):
ui.link('Using HF Models', target='https://huggingface.co/docs/hub/en/ollama', new_tab=True) ui.link('Ollama Library', target='https://ollama.com/library/', new_tab=True)
ui.link('Using HF Models', target='https://huggingface.co/docs/hub/en/ollama', new_tab=True)
with ui.row().classes('items-center gap-2'): with ui.row().classes('items-center gap-2'):
ui.icon('check_circle').props(f'color=positive').bind_visibility_from(self, 'download_status', backward=lambda x: True if x == 'success' else False) ui.icon('check_circle').props(f'color=positive').bind_visibility_from(self, 'download_status', backward=lambda x: True if x == 'success' else False)

View File

@@ -4,12 +4,6 @@ from pathlib import Path
from utils import ollama from utils import ollama
from typing import Optional from typing import Optional
modelfile_example = """FROM qwen2.5-coder:7b
PARAMETER num_ctx 8192
PARAMETER temperature 0.1
SYSTEM "Du bist ein Python-Experte."
"""
class OllamaModelCreationComponent(AsyncCard): class OllamaModelCreationComponent(AsyncCard):
model_name = binding.BindableProperty() model_name = binding.BindableProperty()
@@ -74,33 +68,59 @@ class OllamaModelCreationComponent(AsyncCard):
self.use_seed = False self.use_seed = False
self.use_stop = False self.use_stop = False
async def build(self) -> None: async def build(self, existing_model_name: Optional[str] = None) -> None:
self.classes('w-full') self.classes('w-full')
# Load existing model data if provided
if existing_model_name:
await self.load_existing_model(existing_model_name)
with self: with self:
with ui.column().classes('w-full gap-4'): with ui.column().classes('w-full gap-4'):
ui.label('Create Model').classes('text-xl font-bold') title = 'Edit Model' if existing_model_name else 'Create Model'
ui.label(title).classes('text-xl font-bold')
# Basic fields # Basic fields
ui.input('Model Name', value='my-custom-model:latest').props('outlined dense').classes('w-full').bind_value(self, 'model_name') model_name_default = existing_model_name if existing_model_name else 'my-custom-model:latest'
ui.input('Base Model', value='llama3.2:3b').props('outlined dense').classes('w-full').bind_value(self, 'model_from') base_model_default = '' if existing_model_name else 'llama3.2:3b'
ui.input('Model Name', value=model_name_default).props('outlined dense').classes('w-full').bind_value(self, 'model_name')
ui.input('Base Model', value=base_model_default).props('outlined dense').classes('w-full').bind_value(self, 'model_from')
# System message field (commonly used) # System message field (commonly used)
ui.textarea('System Message', placeholder='You are a helpful assistant...').classes('w-full').props('autogrow outlined').bind_value(self, 'system_message') ui.textarea('System Message', placeholder='You are a helpful assistant...').classes('w-full').props('autogrow outlined').bind_value(self, 'system_message')
# Parameters section # Common Parameters section
ui.label('Parameters').classes('text-md font-medium mt-2 mb-3') ui.label('Common Parameters').classes('text-md font-medium mt-2 mb-3')
# Temperature (always visible)
with ui.row().classes('items-center gap-3 w-full mb-3'):
ui.switch().bind_value(self, 'use_temperature')
ui.label('Temperature').classes('min-w-fit')
ui.slider(min=0.0, max=2.0, step=0.1).classes('flex-1').bind_value(self, 'temperature').bind_enabled_from(self, 'use_temperature')
ui.label().bind_text_from(self, 'temperature', backward=lambda x: f'{x:.1f}').classes('text-xs text-gray-500 min-w-fit')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('The temperature of the model. Higher values (e.g., 1.2) make output more creative, lower values (e.g., 0.5) more focused. Default: 0.8')
# Context Length (always visible)
with ui.row().classes('items-center gap-3 w-full mb-3'):
ui.switch().bind_value(self, 'use_num_ctx')
ui.label('Context Length').classes('min-w-fit')
ui.number(value=4096, min=1, max=32768).classes('flex-1').bind_value(self, 'num_ctx').bind_enabled_from(self, 'use_num_ctx')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('Size of the context window used to generate the next token. Default: 4096')
# Max Tokens (always visible)
with ui.row().classes('items-center gap-3 w-full mb-4'):
ui.switch().bind_value(self, 'use_num_predict')
ui.label('Max Tokens').classes('min-w-fit')
ui.number(value=-1, min=-1, max=4096).classes('flex-1').bind_value(self, 'num_predict').bind_enabled_from(self, 'use_num_predict')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('Maximum number of tokens to predict. -1 for infinite generation. Default: -1')
# Advanced Parameters section
ui.label('Advanced Parameters').classes('text-md font-medium mt-2 mb-3')
# Generation Parameters # Generation Parameters
with ui.expansion('Generation', icon='tune').classes('w-full mb-2'): with ui.expansion('Generation', icon='tune').classes('w-full mb-2'):
with ui.column().classes('w-full gap-3 pt-2'): with ui.column().classes('w-full gap-3 pt-2'):
# Temperature
with ui.row().classes('items-center gap-3 w-full'):
ui.switch().bind_value(self, 'use_temperature')
ui.label('Temperature').classes('min-w-fit')
ui.slider(min=0.0, max=2.0, step=0.1).classes('flex-1').bind_value(self, 'temperature').bind_enabled_from(self, 'use_temperature')
ui.label().bind_text_from(self, 'temperature', backward=lambda x: f'{x:.1f}').classes('text-xs text-gray-500 min-w-fit')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('The temperature of the model. Higher values (e.g., 1.2) make output more creative, lower values (e.g., 0.5) more focused. Default: 0.8')
# Top K # Top K
with ui.row().classes('items-center gap-3 w-full'): with ui.row().classes('items-center gap-3 w-full'):
ui.switch().bind_value(self, 'use_top_k') ui.switch().bind_value(self, 'use_top_k')
@@ -124,23 +144,6 @@ class OllamaModelCreationComponent(AsyncCard):
ui.label().bind_text_from(self, 'min_p', backward=lambda x: f'{x:.2f}').classes('text-xs text-gray-500 min-w-fit') ui.label().bind_text_from(self, 'min_p', backward=lambda x: f'{x:.2f}').classes('text-xs text-gray-500 min-w-fit')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('Alternative to top_p. Minimum probability for a token relative to the most likely token. Default: 0.0') ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('Alternative to top_p. Minimum probability for a token relative to the most likely token. Default: 0.0')
# Context Parameters
with ui.expansion('Context', icon='memory').classes('w-full mb-2'):
with ui.column().classes('w-full gap-3 pt-2'):
# Context Length
with ui.row().classes('items-center gap-3 w-full'):
ui.switch().bind_value(self, 'use_num_ctx')
ui.label('Context Length').classes('min-w-fit')
ui.number(value=4096, min=1, max=32768).classes('flex-1').bind_value(self, 'num_ctx').bind_enabled_from(self, 'use_num_ctx')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('Size of the context window used to generate the next token. Default: 4096')
# Max Tokens
with ui.row().classes('items-center gap-3 w-full'):
ui.switch().bind_value(self, 'use_num_predict')
ui.label('Max Tokens').classes('min-w-fit')
ui.number(value=-1, min=-1, max=4096).classes('flex-1').bind_value(self, 'num_predict').bind_enabled_from(self, 'use_num_predict')
ui.icon('info', size='sm').classes('text-gray-500 cursor-help').tooltip('Maximum number of tokens to predict. -1 for infinite generation. Default: -1')
# Repetition Parameters # Repetition Parameters
with ui.expansion('Repetition Control', icon='repeat').classes('w-full mb-2'): with ui.expansion('Repetition Control', icon='repeat').classes('w-full mb-2'):
with ui.column().classes('w-full gap-3 pt-2'): with ui.column().classes('w-full gap-3 pt-2'):
@@ -194,7 +197,82 @@ class OllamaModelCreationComponent(AsyncCard):
ui.linear_progress(value=0, show_value=False).props('buffer=0.0 animation-speed=0').bind_value_from(self, 'download_progress') ui.linear_progress(value=0, show_value=False).props('buffer=0.0 animation-speed=0').bind_value_from(self, 'download_progress')
# Create button # Create button
self.create_btn = ui.button('Create Model', icon='add', on_click=self.create_model).props('color=primary').classes('w-full').bind_enabled_from(self, 'model_name', backward=lambda x: bool(x) and not self.is_downloading) button_text = 'Update Model' if existing_model_name else 'Create Model'
self.create_btn = ui.button(button_text, icon='add', on_click=self.create_model).props('color=primary').classes('w-full').bind_enabled_from(self, 'model_name', backward=lambda x: bool(x) and not self.is_downloading)
async def load_existing_model(self, model_name):
"""Load existing model data and populate form fields"""
try:
model_info = await ollama.model_info(model_name)
modelfile = model_info.get('modelfile', '')
# Parse the modelfile content to extract settings
for line in modelfile.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith('FROM '):
self.model_from = line[5:].strip()
elif line.startswith('SYSTEM '):
# Extract system message (remove quotes)
system_msg = line[7:].strip()
if system_msg.startswith('"') and system_msg.endswith('"'):
system_msg = system_msg[1:-1]
self.system_message = system_msg
elif line.startswith('TEMPLATE '):
# Extract template (remove quotes)
template = line[9:].strip()
if template.startswith('"') and template.endswith('"'):
template = template[1:-1]
self.template = template
elif line.startswith('PARAMETER '):
# Parse parameter lines
param_line = line[10:].strip()
try:
key, value = param_line.split(' ', 1)
# Set parameter values and enable toggles
if key == 'temperature':
self.temperature = float(value)
self.use_temperature = True
elif key == 'top_k':
self.top_k = int(value)
self.use_top_k = True
elif key == 'top_p':
self.top_p = float(value)
self.use_top_p = True
elif key == 'min_p':
self.min_p = float(value)
self.use_min_p = True
elif key == 'num_ctx':
self.num_ctx = int(value)
self.use_num_ctx = True
elif key == 'num_predict':
self.num_predict = int(value)
self.use_num_predict = True
elif key == 'repeat_last_n':
self.repeat_last_n = int(value)
self.use_repeat_last_n = True
elif key == 'repeat_penalty':
self.repeat_penalty = float(value)
self.use_repeat_penalty = True
elif key == 'seed':
self.seed = int(value)
self.use_seed = True
elif key == 'stop':
# Remove quotes from stop sequence
stop_value = value.strip()
if stop_value.startswith('"') and stop_value.endswith('"'):
stop_value = stop_value[1:-1]
self.stop = stop_value
self.use_stop = True
except ValueError:
# Skip invalid parameter lines
continue
except Exception as e:
ui.notify(f'Error loading model info: {str(e)}', type='negative')
async def create_model(self): async def create_model(self):
# Validate required fields # Validate required fields

View File

@@ -1,6 +1,6 @@
from nicegui import ui from nicegui import ui
from utils import ollama from utils import ollama
from typing import Literal, List, Dict from typing import Literal, List, Dict, Optional
from pprint import pprint from pprint import pprint
from niceguiasyncelement import AsyncColumn from niceguiasyncelement import AsyncColumn
from components.ollama_downloader import OllamaDownloaderComponent from components.ollama_downloader import OllamaDownloaderComponent
@@ -62,7 +62,7 @@ class OllamaManagerPage(AsyncColumn):
self.models_container.refresh() self.models_container.refresh()
async def _download_model_dialog(self): async def _download_model_dialog(self):
with ui.dialog() as dialog: with ui.dialog().classes('max-w-none') as dialog:
await OllamaDownloaderComponent.create() await OllamaDownloaderComponent.create()
await dialog await dialog
self.models_container.refresh() self.models_container.refresh()
@@ -96,14 +96,26 @@ class OllamaManagerPage(AsyncColumn):
# self.quick_test_select.set_options(select_options) # self.quick_test_select.set_options(select_options)
for model in self.models: for model in self.models:
self._create_model_item(model) await self._create_model_item(model)
def _create_model_item(self, model: Dict): async def _create_model_item(self, model: Dict):
model_info = await ollama.model_info(model['name'])
base_model: Optional[Literal['ollama', 'huggingface']] = None
if len(model_info['details']['parent_model']) == 0:
if model['name'].startswith('hf.co/'):
base_model = 'huggingface'
else:
base_model = 'ollama'
with ui.card().classes('w-full'): with ui.card().classes('w-full'):
with ui.row().classes('w-full items-center'): with ui.row().classes('w-full items-center'):
with ui.column().classes('flex-grow gap-1'): with ui.column().classes('flex-grow gap-1'):
# Model name # Model name
ui.label(model['name']).classes('font-bold text-h6') with ui.row().classes('items-center'):
ui.label(model['name']).classes('font-bold text-h6')
if base_model == 'ollama':
ui.html(f'<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ollama-dark.svg" style="width: 20px; height: 20px; object-fit: contain;" />')
if base_model == 'huggingface':
ui.html(f'<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hugging-face.svg" style="width: 20px; height: 20px; object-fit: contain;" />')
# Details row with chips # Details row with chips
with ui.row().classes('gap-2 flex-wrap'): with ui.row().classes('gap-2 flex-wrap'):
@@ -151,10 +163,16 @@ class OllamaManagerPage(AsyncColumn):
with ui.row().classes('gap-2'): with ui.row().classes('gap-2'):
ui.button(icon='chat', on_click=lambda m=model['name']: self._test_model_dialog(m)).props('round flat').tooltip('Test Model') ui.button(icon='chat', on_click=lambda m=model['name']: self._test_model_dialog(m)).props('round flat').tooltip('Test Model')
ui.button(icon='play_arrow').props('round flat color=primary').tooltip('Run Model') ui.button(icon='edit', on_click=lambda m=model['name']: self._model_edit_model_dialog(m)).props('round flat').tooltip('Model Info')
ui.button(icon='info', on_click=lambda m=model['name']: self._model_information_dialog(m)).props('round flat').tooltip('Model Info') ui.button(icon='info', on_click=lambda m=model['name']: self._model_information_dialog(m)).props('round flat').tooltip('Model Info')
ui.button(icon='delete', on_click=lambda m=model['name']: self._delete_model(m)).props('round flat color=negative').tooltip('Delete Model') ui.button(icon='delete', on_click=lambda m=model['name']: self._delete_model(m)).props('round flat color=negative').tooltip('Delete Model')
async def _model_edit_model_dialog(self, model_name):
with ui.dialog() as dialog:
await OllamaModelCreationComponent.create(model_name)
await dialog
self.models_container.refresh()
async def _print_model_info(self, model_name): async def _print_model_info(self, model_name):
result = await ollama.model_info(model_name) result = await ollama.model_info(model_name)
for key, value in result.items(): for key, value in result.items():