diff --git a/src/components/ollama_downloader.py b/src/components/ollama_downloader.py index 75fd60c..d293899 100644 --- a/src/components/ollama_downloader.py +++ b/src/components/ollama_downloader.py @@ -12,6 +12,7 @@ class OllamaDownloaderComponent(AsyncCard): self.download_status = '' async def build(self) -> None: + self.classes('w-full') with self: with ui.column().classes('w-full gap-4'): ui.label('Model Downloader').classes('text-xl font-bold') @@ -21,8 +22,9 @@ class OllamaDownloaderComponent(AsyncCard): placeholder='e.g., TheBloke/Llama-2-7B-GGUF', value='qwen2.5:0.5b' ).props('outlined dense').classes('w-full') - 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'): + 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'): ui.icon('check_circle').props(f'color=positive').bind_visibility_from(self, 'download_status', backward=lambda x: True if x == 'success' else False) diff --git a/src/components/ollama_model_creation.py b/src/components/ollama_model_creation.py index b37d827..b5c7c0c 100644 --- a/src/components/ollama_model_creation.py +++ b/src/components/ollama_model_creation.py @@ -4,12 +4,6 @@ from pathlib import Path from utils import ollama 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): model_name = binding.BindableProperty() @@ -74,33 +68,59 @@ class OllamaModelCreationComponent(AsyncCard): self.use_seed = 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') + + # Load existing model data if provided + if existing_model_name: + await self.load_existing_model(existing_model_name) + with self: 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 - ui.input('Model Name', value='my-custom-model:latest').props('outlined dense').classes('w-full').bind_value(self, 'model_name') - ui.input('Base Model', value='llama3.2:3b').props('outlined dense').classes('w-full').bind_value(self, 'model_from') + model_name_default = existing_model_name if existing_model_name else 'my-custom-model:latest' + 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) ui.textarea('System Message', placeholder='You are a helpful assistant...').classes('w-full').props('autogrow outlined').bind_value(self, 'system_message') - # Parameters section - ui.label('Parameters').classes('text-md font-medium mt-2 mb-3') + # Common Parameters section + 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 with ui.expansion('Generation', icon='tune').classes('w-full mb-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 with ui.row().classes('items-center gap-3 w-full'): 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.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 with ui.expansion('Repetition Control', icon='repeat').classes('w-full mb-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') # 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): # Validate required fields diff --git a/src/pages/ollama_manager.py b/src/pages/ollama_manager.py index 9af6213..e734d43 100644 --- a/src/pages/ollama_manager.py +++ b/src/pages/ollama_manager.py @@ -1,6 +1,6 @@ from nicegui import ui from utils import ollama -from typing import Literal, List, Dict +from typing import Literal, List, Dict, Optional from pprint import pprint from niceguiasyncelement import AsyncColumn from components.ollama_downloader import OllamaDownloaderComponent @@ -62,7 +62,7 @@ class OllamaManagerPage(AsyncColumn): self.models_container.refresh() async def _download_model_dialog(self): - with ui.dialog() as dialog: + with ui.dialog().classes('max-w-none') as dialog: await OllamaDownloaderComponent.create() await dialog self.models_container.refresh() @@ -96,14 +96,26 @@ class OllamaManagerPage(AsyncColumn): # self.quick_test_select.set_options(select_options) 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.row().classes('w-full items-center'): with ui.column().classes('flex-grow gap-1'): # 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'') + if base_model == 'huggingface': + ui.html(f'') # Details row with chips with ui.row().classes('gap-2 flex-wrap'): @@ -151,10 +163,16 @@ class OllamaManagerPage(AsyncColumn): 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='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='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): result = await ollama.model_info(model_name) for key, value in result.items():