"""Ollama LLM configuration screen.""" import configparser import os import re import socket import threading from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox, filedialog from installer import screen as ui from installer.core.env import ( DEFAULT_OLLAMA_PORT, PROJECT_ROOT, _format_ollama_host, _parse_ollama_host, ) from installer.config import _collect_cfg_vars, _expand_cfg_value class OllamaScreenMixin: """Ollama LLM configuration screen.""" def _render_ollama_config_page(self): # Letterhead at top right (matching welcome screen theme) content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Ollama Server', y=150) self._render_paragraph( "Detect and select a network Ollama server. The selected host, port, and model are saved to prole.cfg " "and exported as OLLAMA_* for k3s hosts and services.", y=200 ) x_label = 48 x_field = 300 y = 270 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Host:', fill='black', font=('SF Pro Text', 12, 'bold'))) host_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_host, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) host_window = self.bg_canvas.create_window(x_field, y-12, window=host_entry, anchor='nw', width=400, height=32) self._canvas_items.append(host_window) self._overlay_widgets.append(host_entry) y += 42 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Port:', fill='black', font=('SF Pro Text', 12, 'bold'))) port_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_port, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=120, height=32) self._canvas_items.append(port_window) self._overlay_widgets.append(port_entry) y += 42 # Removed global model selector; model is now selected per-server row save_btn = tk.Button(self.bg_canvas, text='Save', command=self._save_ollama_config, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=16, pady=6) save_window = self.bg_canvas.create_window(x_field + 320, y-14, window=save_btn, anchor='nw') self._canvas_items.append(save_window) self._overlay_widgets.append(save_btn) y += 42 preview_item = ui.canvas_text(self, x_label, y, 'OLLAMA_HOST: ', fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(preview_item) def update_preview(*_args): host = (self.ollama_server_host.get() or '').strip() port = (self.ollama_server_port.get() or '').strip() url = _format_ollama_host(host, port) text = f"OLLAMA_HOST: {url}" if url else "OLLAMA_HOST: (not set)" try: self.bg_canvas.itemconfig(preview_item, text=text) except Exception: pass # No global model selector updates to avoid UI spin-lock self.ollama_server_host.trace_add('write', update_preview) self.ollama_server_port.trace_add('write', update_preview) update_preview() y += 40 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) refresh_btn = tk.Button(self.bg_canvas, text='Scan', command=self._refresh_ollama_table, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) refresh_window = self.bg_canvas.create_window(x_label + 190, y - 8, window=refresh_btn, anchor='nw') self._canvas_items.append(refresh_window) self._overlay_widgets.append(refresh_btn) self._ollama_status_var = tk.StringVar(value="Ready") status_item = ui.canvas_text(self, x_label + 270, y, "Status: Ready", fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(status_item) def update_status_text(*args): try: self.bg_canvas.itemconfig(status_item, text=f"Status: {self._ollama_status_var.get()}") except Exception: pass self._ollama_status_var.trace_add('write', update_status_text) table_y = y + 30 table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) table_window = self.bg_canvas.create_window(x_label, table_y, window=table_frame, anchor='nw', width=900, height=220) self._canvas_items.append(table_window) self._overlay_widgets.append(table_frame) # Add an editable Default Model column; available models shown for reference columns = ('select', 'host', 'port', 'default', 'models') tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=6) tree.heading('select', text='Select') tree.heading('host', text='Host') tree.heading('port', text='Port') tree.heading('default', text='Default Model') tree.heading('models', text='Models') tree.column('select', width=60, anchor='center') tree.column('host', width=200, anchor='w') tree.column('port', width=70, anchor='center') tree.column('default', width=180, anchor='w') tree.column('models', width=380, anchor='w') tree.pack(side='left', fill='both', expand=True) scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) tree.configure(yscrollcommand=scroll.set) scroll.pack(side='right', fill='y') self._overlay_widgets.append(tree) self._overlay_widgets.append(scroll) self._ollama_server_tree = tree # Inline combobox to edit Default Model for the selected row self._ollama_row_model_combo = None def _place_row_model_combo(item_id): try: # Compute bbox for 'default' column bbox = tree.bbox(item_id, column='default') if not bbox: return x, y, w, h = bbox # Create lazily if not self._ollama_row_model_combo: self._ollama_row_model_combo = ttk.Combobox(tree, state='readonly') self._ollama_row_model_combo.bind('<>', _on_row_model_change) # Identify models by key vals = tree.item(item_id, 'values') if not vals: return host = vals[1] port = vals[2] or DEFAULT_OLLAMA_PORT key = f"{host}:{port}" models = self._ollama_model_options.get(key, []) try: self._ollama_row_model_combo.configure(values=models) except Exception: pass # Set current selection current = (self._ollama_default_model_by_key.get(key) or (models[0] if models else '')) if current: self._ollama_row_model_combo.set(current) # Place within Treeview self._ollama_row_model_combo.place(x=x+1, y=y+1, width=max(w-2, 60), height=h-2) except Exception: pass def _on_row_model_change(_evt=None): try: if not self._ollama_row_model_combo: return sel = tree.selection() if not sel: return item_id = sel[0] vals = tree.item(item_id, 'values') if not vals: return host = vals[1] port = vals[2] or DEFAULT_OLLAMA_PORT key = f"{host}:{port}" choice = self._ollama_row_model_combo.get().strip() self._ollama_default_model_by_key[key] = choice # If this row is the active selection for host/port, reflect into self.ollama_model current_key = self._ollama_current_key() if current_key == key and choice: try: self.ollama_model.set(choice) except Exception: pass # Update the table cell text new_vals = list(vals) # Default column index is 3 if len(new_vals) >= 4: new_vals[3] = choice or '—' tree.item(item_id, values=new_vals) except Exception: pass def on_select(event): if getattr(self, '_refreshing_ollama_table', False): return sel = tree.selection() if not sel: # Hide editor when nothing selected if self._ollama_row_model_combo: self._ollama_row_model_combo.place_forget() return vals = tree.item(sel[0], 'values') if not vals: return host = vals[1] port = vals[2] or DEFAULT_OLLAMA_PORT self.ollama_server_host.set(host) self.ollama_server_port.set(port) key = f"{host}:{port}" # If we have a stored default model for this key, reflect into self.ollama_model for saving chosen = (self._ollama_default_model_by_key.get(key) or '') if chosen: try: self.ollama_model.set(chosen) except Exception: pass # Refresh to update the selection indicator, then place editor self._render_ollama_table_from_cache() # Reselect and place inline model editor over this row try: for item in tree.get_children(): v = tree.item(item, 'values') if v and v[1] == host and (v[2] or DEFAULT_OLLAMA_PORT) == port: tree.selection_set(item) _place_row_model_combo(item) break except Exception: pass tree.bind('<>', on_select) # Also allow double-click on default cell to focus the combobox def on_double_click(event): if getattr(self, '_refreshing_ollama_table', False): return item_id = tree.identify_row(event.y) col = tree.identify_column(event.x) if not item_id: return if col in ('#4', 'default'): _place_row_model_combo(item_id) tree.bind('', on_double_click) self._ollama_note_item = ui.canvas_text(self, x_label, table_y + 230, '', fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(self._ollama_note_item) self._render_ollama_table_from_cache() self._refresh_ollama_table() def _ollama_current_key(self) -> str: host = (self.ollama_server_host.get() or '').strip() port = (self.ollama_server_port.get() or '').strip() or DEFAULT_OLLAMA_PORT if not host: return '' return f"{host}:{port}" def _set_ollama_model_values(self, models: list[str]): combo = getattr(self, '_ollama_model_combo', None) if not combo: return if getattr(self, '_refreshing_ollama_table', False): return values = models or [] try: combo.configure(values=values) except Exception: pass current = (self.ollama_model.get() or '').strip() if values and (not current or current not in values): self.ollama_model.set(values[0]) def _render_ollama_table_from_cache(self): rows = list(getattr(self, '_ollama_servers_cache', []) or []) notice = getattr(self, '_ollama_last_notice', '') self._update_ollama_table(rows, notice) def _update_ollama_table(self, rows: list[dict], notice: str = ''): tree = getattr(self, '_ollama_server_tree', None) if not tree: return self._ollama_servers_cache = rows # Map of key -> list[str] available models self._ollama_model_options = {} # Map of key -> selected default model if not hasattr(self, '_ollama_default_model_by_key'): self._ollama_default_model_by_key = {} self._ollama_last_notice = notice or '' for item in tree.get_children(): tree.delete(item) current_key = self._ollama_current_key() for row in rows: host = row.get('host', '') port = row.get('port', '') or DEFAULT_OLLAMA_PORT models = row.get('models', []) or [] key = f"{host}:{port}" self._ollama_model_options[key] = models # Initialize default selection if missing if key not in self._ollama_default_model_by_key: self._ollama_default_model_by_key[key] = (models[0] if models else '') prefix = ' [✓] ' if key == current_key else ' [ ] ' models_preview = ', '.join(models) if models else '—' default_model = self._ollama_default_model_by_key.get(key, '') or '—' try: tree.insert('', 'end', values=(prefix, host, port, default_model, models_preview)) except Exception: # Fallback if columns not updated yet tree.insert('', 'end', values=(prefix, host, port, models_preview)) if current_key: for item in tree.get_children(): vals = tree.item(item, 'values') if vals and f"{vals[1]}:{vals[2]}" == current_key: tree.selection_set(item) tree.see(item) break note_item = getattr(self, '_ollama_note_item', None) if note_item and self.bg_canvas.winfo_exists(): msg = notice if notice else "" self.bg_canvas.itemconfig(note_item, text=msg) models = self._ollama_model_options.get(current_key, []) if models: self._set_ollama_model_values(models) def _refresh_ollama_table(self): if getattr(self, '_refreshing_ollama_table', False): return self._refreshing_ollama_table = True self._ollama_model_options = {} if getattr(self, '_ollama_status_var', None): self._ollama_status_var.set("Scanning...") def worker(): notice = '' rows: list[dict] = [] try: script_path = PROJECT_ROOT / "etc" / "init_ollama.sh" if not script_path.exists(): notice = f"Missing script: {script_path}" self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) else: env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) rc, out = self._run_cmd_capture(['bash', str(script_path), 'scan'], env=env) if rc == 0: for line in (out or '').splitlines(): line = line.strip() if not line or line.startswith('#'): continue parts = [p.strip() for p in line.split('\t')] if len(parts) < 2: continue host = parts[0] port = parts[1] or DEFAULT_OLLAMA_PORT model_list: list[str] = [] if len(parts) > 2 and parts[2]: model_list = [m.strip() for m in parts[2].split(',') if m.strip() and m.strip() != '-'] rows.append({'host': host, 'port': port, 'models': model_list}) # Deduplicate by canonical host:port while preferring hostname labels if rows: def _is_ip(name: str) -> bool: if not name: return False return bool(re.match(r'^(?:\d{1,3}\.){3}\d{1,3}$', name)) or ':' in name def _canon_host(name: str) -> str: n = (name or '').strip().lower() if n in {'localhost', '127.0.0.1', '::1', 'k3d.local'}: return '127.0.0.1' try: # Prefer IPv4 return socket.gethostbyname(n) except Exception: return n def _label_rank(name: str) -> int: n = (name or '').strip().lower() if n == 'k3d.local': return 0 if not _is_ip(n) and n not in {'localhost'}: return 1 # other hostnames preferred after k3d.local if n == 'localhost': return 2 return 3 # IPs last merged: dict[tuple[str, str], dict] = {} for r in rows: disp_host = r.get('host', '') port = str(r.get('port', '') or DEFAULT_OLLAMA_PORT) canon = _canon_host(disp_host) key = (canon, port) cur = merged.get(key) if not cur: merged[key] = { 'host': disp_host, # display label 'port': port, 'models': list(r.get('models', []) or []) } else: # Merge models existing = set(cur.get('models', []) or []) for m in (r.get('models', []) or []): if m not in existing: cur['models'].append(m) existing.add(m) # Pick better display host old_label = cur.get('host', '') if _label_rank(disp_host) < _label_rank(old_label): cur['host'] = disp_host # Convert back to list and sort by host rows = sorted(merged.values(), key=lambda d: (d.get('host') or '')) if rows: notice = f"Detected {len(rows)} server(s)." self.safe_after(lambda: self._ollama_status_var.set("Scan complete") if getattr(self, '_ollama_status_var', None) else None) else: notice = "No Ollama servers detected." self.safe_after(lambda: self._ollama_status_var.set("No servers found") if getattr(self, '_ollama_status_var', None) else None) else: notice = "Scan failed. See logs for details." self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) except Exception as e: notice = f"Scan error: {e}" self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) finally: def finish(): self._refreshing_ollama_table = False self._update_ollama_table(rows, notice) self.safe_after(finish) threading.Thread(target=worker, daemon=True).start() def _save_ollama_config(self): raw_host = (self.ollama_server_host.get() or '').strip() raw_port = (self.ollama_server_port.get() or '').strip() raw_model = (self.ollama_model.get() or '').strip() host = raw_host port = raw_port if raw_host: parsed_host, parsed_port = _parse_ollama_host(raw_host) if parsed_host: host = parsed_host if not port and parsed_port: port = parsed_port if host and not port: port = DEFAULT_OLLAMA_PORT if host and host != raw_host: self.ollama_server_host.set(host) if port and port != raw_port: self.ollama_server_port.set(port) data = self.prole_cfg_data.setdefault('Ollama', {}) if host: data['OLLAMA_SERVER_HOST'] = host data['OLLAMA_SERVER_PORT'] = port data['OLLAMA_HOST'] = _format_ollama_host(host, port) else: data.pop('OLLAMA_SERVER_HOST', None) data.pop('OLLAMA_SERVER_PORT', None) data.pop('OLLAMA_HOST', None) if raw_model: data['OLLAMA_MODEL'] = raw_model else: data.pop('OLLAMA_MODEL', None) self._save_prole_cfg() if getattr(self, '_ollama_status_var', None): self._ollama_status_var.set("Saved") def _apply_ollama_defaults(self): env_host = (os.environ.get('OLLAMA_SERVER_HOST') or '').strip() env_port = (os.environ.get('OLLAMA_SERVER_PORT') or '').strip() env_model = (os.environ.get('OLLAMA_MODEL') or '').strip() env_url = (os.environ.get('OLLAMA_HOST') or '').strip() if env_url and not env_host: parsed_host, parsed_port = _parse_ollama_host(env_url) if parsed_host: env_host = parsed_host if parsed_port and not env_port: env_port = parsed_port if env_host and not self.ollama_server_host.get().strip(): self.ollama_server_host.set(env_host) if env_port and not self.ollama_server_port.get().strip(): self.ollama_server_port.set(env_port) if env_model and not self.ollama_model.get().strip(): self.ollama_model.set(env_model) cfg_host, cfg_port, cfg_model = self._read_ollama_cfg_values() if cfg_host and not self.ollama_server_host.get().strip(): self.ollama_server_host.set(cfg_host) if cfg_port and not self.ollama_server_port.get().strip(): self.ollama_server_port.set(cfg_port) if cfg_model and not self.ollama_model.get().strip(): self.ollama_model.set(cfg_model) def _read_ollama_cfg_values(self) -> tuple[str, str, str]: """Return (host, port, model) from prole.cfg if present.""" cfg_path = None try: if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): cfg_path = cfg_path / 'prole.cfg' else: cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' except Exception: cfg_path = None if not cfg_path or not Path(cfg_path).exists(): return '', '', '' cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str try: cfg.read(cfg_path) except Exception: return '', '', '' cfg_vars = _collect_cfg_vars(cfg) host_val = '' port_val = '' model_val = '' host_url = '' if cfg.has_section('Ollama'): sec = cfg['Ollama'] host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() if cfg.has_section('Global'): sec = cfg['Global'] if not host_val: host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() if not port_val: port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() if not model_val: model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() if not host_url: host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() if host_url and not host_val: parsed_host, parsed_port = _parse_ollama_host(host_url) if parsed_host: host_val = parsed_host if parsed_port and not port_val: port_val = parsed_port return host_val, port_val, model_val