#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Ollama Manager — графический менеджер для управления удалённым сервером Ollama. Возможности: * Подключение к Ollama по HTTP (по умолчанию 192.168.1.118:11434). * Список установленных моделей, их детали, удаление, копирование. * Установка (pull) моделей с индикатором прогресса. * Список запущенных (загруженных в память) моделей и их выгрузка. * Создание модели из Modelfile, push модели в реестр. * Управление сервером (start / stop / restart / status) по SSH. * Определение железа (RAM / VRAM / GPU) по SSH. * "Найти модель" — рекомендация моделей, которые поместятся в железо. Зависимости: pip install requests paramiko (paramiko нужен только для функций по SSH: управление сервером и определение железа. Без него остальные функции работают.) Запуск: python ollama_manager.py """ import json import threading import time import tkinter as tk from tkinter import ttk, messagebox, scrolledtext try: import requests except ImportError: # pragma: no cover raise SystemExit("Не установлен пакет 'requests'. Установите: pip install requests") try: import paramiko HAS_PARAMIKO = True except ImportError: HAS_PARAMIKO = False # --------------------------------------------------------------------------- # # Каталог моделей для функции рекомендаций. # size_gb — примерный размер загрузки (по умолчанию квантизация Q4). # min_ram — примерный минимум ОЗУ для запуска (CPU/частичная выгрузка), ГБ. # vram_rec — рекомендуемый объём VRAM для полного ускорения на GPU, ГБ. # Значения приблизительные — служат ориентиром, а не точной гарантией. # --------------------------------------------------------------------------- # MODEL_CATALOG = [ # name, size_gb, min_ram, vram_rec, tag ("qwen2.5:0.5b", 0.4, 1.0, 1.0, "чат"), ("tinyllama:1.1b", 0.6, 2.0, 1.5, "чат"), ("deepseek-r1:1.5b", 1.1, 2.5, 2.0, "рассуждение"), ("qwen2.5:1.5b", 1.0, 2.5, 2.0, "чат"), ("llama3.2:1b", 1.3, 2.5, 2.0, "чат"), ("gemma2:2b", 1.6, 3.5, 3.0, "чат"), ("llama3.2:3b", 2.0, 4.5, 4.0, "чат"), ("qwen2.5:3b", 1.9, 4.5, 4.0, "чат"), ("phi3:3.8b", 2.2, 5.0, 4.0, "чат"), ("starcoder2:3b", 1.7, 4.5, 4.0, "код"), ("mistral:7b", 4.1, 8.0, 6.0, "чат"), ("llama3.1:8b", 4.7, 8.0, 6.0, "чат"), ("qwen2.5:7b", 4.7, 8.0, 6.0, "чат"), ("qwen2.5-coder:7b", 4.7, 8.0, 6.0, "код"), ("deepseek-r1:7b", 4.7, 8.0, 6.0, "рассуждение"), ("deepseek-r1:8b", 4.9, 8.0, 6.0, "рассуждение"), ("codellama:7b", 3.8, 8.0, 6.0, "код"), ("llava:7b", 4.7, 8.0, 6.0, "vision"), ("gemma2:9b", 5.4, 10.0, 8.0, "чат"), ("phi4:14b", 9.0, 12.0, 10.0, "чат"), ("phi3:14b", 7.9, 12.0, 10.0, "чат"), ("qwen2.5:14b", 9.0, 12.0, 10.0, "чат"), ("deepseek-r1:14b", 9.0, 12.0, 10.0, "рассуждение"), ("codellama:13b", 7.4, 12.0, 10.0, "код"), ("gemma2:27b", 16.0, 20.0, 18.0, "чат"), ("qwen2.5:32b", 20.0, 24.0, 22.0, "чат"), ("deepseek-r1:32b", 20.0, 24.0, 22.0, "рассуждение"), ("mixtral:8x7b", 26.0, 32.0, 28.0, "MoE"), ("llama3.3:70b", 43.0, 48.0, 42.0, "чат"), ("deepseek-r1:70b", 43.0, 48.0, 42.0, "рассуждение"), ("qwen2.5:72b", 47.0, 50.0, 45.0, "чат"), ("mixtral:8x22b", 80.0, 90.0, 85.0, "MoE"), # вспомогательные / эмбеддинги ("nomic-embed-text", 0.3, 1.0, 1.0, "эмбеддинги"), ("mxbai-embed-large", 0.7, 1.5, 1.0, "эмбеддинги"), ] # --------------------------------------------------------------------------- # # HTTP-клиент Ollama # --------------------------------------------------------------------------- # class OllamaClient: def __init__(self, host="192.168.1.118", port=11434): self.set_endpoint(host, port) def set_endpoint(self, host, port): self.host = host self.port = int(port) self.base_url = f"http://{host}:{self.port}" def version(self, timeout=5): r = requests.get(f"{self.base_url}/api/version", timeout=timeout) r.raise_for_status() return r.json().get("version", "?") def list_models(self, timeout=10): r = requests.get(f"{self.base_url}/api/tags", timeout=timeout) r.raise_for_status() return r.json().get("models", []) def list_running(self, timeout=10): r = requests.get(f"{self.base_url}/api/ps", timeout=timeout) r.raise_for_status() return r.json().get("models", []) def show(self, name, timeout=15): r = requests.post(f"{self.base_url}/api/show", json={"model": name}, timeout=timeout) r.raise_for_status() return r.json() def delete(self, name, timeout=30): r = requests.delete(f"{self.base_url}/api/delete", json={"model": name}, timeout=timeout) if r.status_code == 404: raise RuntimeError(f"Модель '{name}' не найдена.") r.raise_for_status() return True def copy(self, source, destination, timeout=30): r = requests.post(f"{self.base_url}/api/copy", json={"source": source, "destination": destination}, timeout=timeout) if r.status_code == 404: raise RuntimeError(f"Исходная модель '{source}' не найдена.") r.raise_for_status() return True def unload(self, name, timeout=30): """Выгрузить модель из памяти (keep_alive=0 с пустым запросом).""" r = requests.post(f"{self.base_url}/api/generate", json={"model": name, "prompt": "", "keep_alive": 0}, timeout=timeout) r.raise_for_status() return True def pull_stream(self, name): """Генератор: возвращает словари прогресса при загрузке модели.""" with requests.post(f"{self.base_url}/api/pull", json={"model": name, "stream": True}, stream=True, timeout=None) as r: r.raise_for_status() for line in r.iter_lines(): if not line: continue data = json.loads(line.decode("utf-8")) if "error" in data: raise RuntimeError(data["error"]) yield data def push_stream(self, name): with requests.post(f"{self.base_url}/api/push", json={"model": name, "stream": True}, stream=True, timeout=None) as r: r.raise_for_status() for line in r.iter_lines(): if not line: continue data = json.loads(line.decode("utf-8")) if "error" in data: raise RuntimeError(data["error"]) yield data def create_stream(self, name, modelfile_from=None, system=None, template=None): body = {"model": name, "stream": True} if modelfile_from: body["from"] = modelfile_from if system: body["system"] = system if template: body["template"] = template with requests.post(f"{self.base_url}/api/create", json=body, stream=True, timeout=None) as r: r.raise_for_status() for line in r.iter_lines(): if not line: continue data = json.loads(line.decode("utf-8")) if "error" in data: raise RuntimeError(data["error"]) yield data # --------------------------------------------------------------------------- # # SSH-контроллер (управление сервером и определение железа) # --------------------------------------------------------------------------- # class SSHController: def __init__(self): self.client = None self.info = "" @property def connected(self): return self.client is not None def connect(self, host, port, user, password=None, keyfile=None): if not HAS_PARAMIKO: raise RuntimeError("Не установлен paramiko. Установите: pip install paramiko") cli = paramiko.SSHClient() cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) cli.connect(hostname=host, port=int(port), username=user, password=password or None, key_filename=keyfile or None, timeout=10, allow_agent=True, look_for_keys=True) self.client = cli self.info = f"{user}@{host}:{port}" def close(self): if self.client: self.client.close() self.client = None def run(self, cmd, timeout=30): if not self.connected: raise RuntimeError("Нет SSH-подключения.") stdin, stdout, stderr = self.client.exec_command(cmd, timeout=timeout) out = stdout.read().decode("utf-8", "replace") err = stderr.read().decode("utf-8", "replace") code = stdout.channel.recv_exit_status() return code, out, err # ----- управление сервисом ----- # def start_server(self): cmd = ("sudo -n systemctl start ollama 2>&1 " "|| (nohup ollama serve > /tmp/ollama.log 2>&1 & echo 'started via ollama serve')") return self.run(cmd) def stop_server(self): cmd = ("sudo -n systemctl stop ollama 2>&1 " "|| (pkill -f 'ollama serve' && echo 'stopped via pkill') " "|| echo 'процесс ollama не найден'") return self.run(cmd) def restart_server(self): cmd = ("sudo -n systemctl restart ollama 2>&1 " "|| (pkill -f 'ollama serve'; sleep 2; " "nohup ollama serve > /tmp/ollama.log 2>&1 & echo 'restarted via ollama serve')") return self.run(cmd) def status_server(self): cmd = ("systemctl status ollama --no-pager 2>&1 " "|| ps aux | grep -i '[o]llama' " "|| echo 'процесс ollama не найден'") return self.run(cmd) # ----- определение железа ----- # def detect_hardware(self): """Возвращает (ram_gb, vram_gb, описание).""" script = r""" echo "OS=$(uname -s)" if [ -f /proc/meminfo ]; then echo "RAM_KB=$(awk '/MemTotal/{print $2}' /proc/meminfo)" else echo "RAM_BYTES=$(sysctl -n hw.memsize 2>/dev/null)" fi echo "GPU=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1)" echo "VRAM_MB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1)" """ code, out, err = self.run(script) ram_gb = 0.0 vram_gb = 0.0 gpu_name = "" os_name = "" for line in out.splitlines(): line = line.strip() if line.startswith("OS="): os_name = line[3:] elif line.startswith("RAM_KB=") and line[7:].strip().isdigit(): ram_gb = int(line[7:]) / (1024 * 1024) elif line.startswith("RAM_BYTES=") and line[10:].strip().isdigit(): ram_gb = int(line[10:]) / (1024 ** 3) elif line.startswith("GPU="): gpu_name = line[4:] elif line.startswith("VRAM_MB="): val = line[8:].strip() if val.isdigit(): vram_gb = int(val) / 1024 # Apple Silicon: единая память — VRAM фактически равна RAM. if os_name == "Darwin" and not gpu_name: gpu_name = "Apple GPU (unified memory)" vram_gb = ram_gb desc = f"OS={os_name or '?'}, RAM={ram_gb:.1f}ГБ" if gpu_name: desc += f", GPU={gpu_name}, VRAM={vram_gb:.1f}ГБ" else: desc += ", GPU не обнаружен (CPU)" return round(ram_gb, 1), round(vram_gb, 1), desc # --------------------------------------------------------------------------- # # Основное приложение # --------------------------------------------------------------------------- # class OllamaManagerApp: def __init__(self, root): self.root = root self.root.title("Ollama Manager — 192.168.1.118") self.root.geometry("960x720") self.client = OllamaClient() self.ssh = SSHController() self._build_top_bar() self._build_notebook() self._build_console() self.log("Готово. Укажите адрес сервера и нажмите «Подключиться».") if not HAS_PARAMIKO: self.log("paramiko не установлен — управление сервером и автоопределение " "железа по SSH недоступны (pip install paramiko).", "warn") # ---------------------- верхняя панель подключения --------------------- # def _build_top_bar(self): bar = ttk.LabelFrame(self.root, text="Подключение к Ollama (HTTP)") bar.pack(fill="x", padx=8, pady=(8, 4)) ttk.Label(bar, text="Хост:").grid(row=0, column=0, padx=4, pady=6, sticky="e") self.var_host = tk.StringVar(value="192.168.1.118") ttk.Entry(bar, textvariable=self.var_host, width=18).grid(row=0, column=1, padx=4) ttk.Label(bar, text="Порт:").grid(row=0, column=2, padx=4, sticky="e") self.var_port = tk.StringVar(value="11434") ttk.Entry(bar, textvariable=self.var_port, width=8).grid(row=0, column=3, padx=4) ttk.Button(bar, text="Подключиться", command=self.on_connect).grid(row=0, column=4, padx=6) self.var_status = tk.StringVar(value="● не подключено") self.lbl_status = ttk.Label(bar, textvariable=self.var_status, foreground="#b00") self.lbl_status.grid(row=0, column=5, padx=10) def _build_notebook(self): nb = ttk.Notebook(self.root) nb.pack(fill="both", expand=True, padx=8, pady=4) self._build_tab_models(nb) self._build_tab_running(nb) self._build_tab_server(nb) self._build_tab_recommend(nb) # ------------------------------ вкладка: модели ------------------------ # def _build_tab_models(self, nb): tab = ttk.Frame(nb) nb.add(tab, text="Модели") # установка inst = ttk.LabelFrame(tab, text="Установка модели") inst.pack(fill="x", padx=6, pady=6) self.var_pull = tk.StringVar() ttk.Entry(inst, textvariable=self.var_pull, width=40).pack(side="left", padx=6, pady=6) ttk.Button(inst, text="Установить (pull)", command=self.on_pull).pack(side="left", padx=4) self.progress = ttk.Progressbar(inst, length=240, mode="determinate") self.progress.pack(side="left", padx=8) self.var_pull_status = tk.StringVar(value="") ttk.Label(inst, textvariable=self.var_pull_status).pack(side="left", padx=4) # список установленных lst = ttk.LabelFrame(tab, text="Установленные модели") lst.pack(fill="both", expand=True, padx=6, pady=6) cols = ("name", "size", "params", "quant", "modified") self.tree_models = ttk.Treeview(lst, columns=cols, show="headings", height=12) for c, t, w in (("name", "Модель", 240), ("size", "Размер", 90), ("params", "Параметры", 90), ("quant", "Квант.", 90), ("modified", "Изменена", 160)): self.tree_models.heading(c, text=t) self.tree_models.column(c, width=w, anchor="w") self.tree_models.pack(side="left", fill="both", expand=True, padx=(6, 0), pady=6) sb = ttk.Scrollbar(lst, orient="vertical", command=self.tree_models.yview) sb.pack(side="left", fill="y", pady=6) self.tree_models.configure(yscrollcommand=sb.set) btns = ttk.Frame(tab) btns.pack(fill="x", padx=6, pady=(0, 6)) ttk.Button(btns, text="Обновить список", command=self.refresh_models).pack(side="left", padx=4) ttk.Button(btns, text="Детали", command=self.on_show_details).pack(side="left", padx=4) ttk.Button(btns, text="Копировать…", command=self.on_copy).pack(side="left", padx=4) ttk.Button(btns, text="Удалить", command=self.on_delete).pack(side="left", padx=4) ttk.Button(btns, text="Создать из Modelfile…", command=self.on_create).pack(side="left", padx=4) ttk.Button(btns, text="Push…", command=self.on_push).pack(side="left", padx=4) # ----------------------------- вкладка: запущенные --------------------- # def _build_tab_running(self, nb): tab = ttk.Frame(nb) nb.add(tab, text="Запущенные") lst = ttk.LabelFrame(tab, text="Модели, загруженные в память") lst.pack(fill="both", expand=True, padx=6, pady=6) cols = ("name", "size", "vram", "expires") self.tree_running = ttk.Treeview(lst, columns=cols, show="headings", height=12) for c, t, w in (("name", "Модель", 260), ("size", "Размер в памяти", 140), ("vram", "Из них в VRAM", 140), ("expires", "Выгрузка в", 160)): self.tree_running.heading(c, text=t) self.tree_running.column(c, width=w, anchor="w") self.tree_running.pack(side="left", fill="both", expand=True, padx=(6, 0), pady=6) sb = ttk.Scrollbar(lst, orient="vertical", command=self.tree_running.yview) sb.pack(side="left", fill="y", pady=6) self.tree_running.configure(yscrollcommand=sb.set) btns = ttk.Frame(tab) btns.pack(fill="x", padx=6, pady=(0, 6)) ttk.Button(btns, text="Обновить", command=self.refresh_running).pack(side="left", padx=4) ttk.Button(btns, text="Выгрузить из памяти", command=self.on_unload).pack(side="left", padx=4) # ----------------------------- вкладка: сервер (SSH) ------------------- # def _build_tab_server(self, nb): tab = ttk.Frame(nb) nb.add(tab, text="Сервер (SSH)") conn = ttk.LabelFrame(tab, text="SSH-подключение к машине с Ollama") conn.pack(fill="x", padx=6, pady=6) ttk.Label(conn, text="Хост:").grid(row=0, column=0, padx=4, pady=4, sticky="e") self.var_ssh_host = tk.StringVar(value="192.168.1.118") ttk.Entry(conn, textvariable=self.var_ssh_host, width=16).grid(row=0, column=1, padx=4) ttk.Label(conn, text="Порт:").grid(row=0, column=2, padx=4, sticky="e") self.var_ssh_port = tk.StringVar(value="22") ttk.Entry(conn, textvariable=self.var_ssh_port, width=6).grid(row=0, column=3, padx=4) ttk.Label(conn, text="Логин:").grid(row=0, column=4, padx=4, sticky="e") self.var_ssh_user = tk.StringVar() ttk.Entry(conn, textvariable=self.var_ssh_user, width=14).grid(row=0, column=5, padx=4) ttk.Label(conn, text="Пароль:").grid(row=1, column=0, padx=4, pady=4, sticky="e") self.var_ssh_pass = tk.StringVar() ttk.Entry(conn, textvariable=self.var_ssh_pass, width=16, show="*").grid(row=1, column=1, padx=4) ttk.Label(conn, text="Ключ (путь):").grid(row=1, column=2, padx=4, sticky="e") self.var_ssh_key = tk.StringVar() ttk.Entry(conn, textvariable=self.var_ssh_key, width=24).grid(row=1, column=3, columnspan=2, padx=4, sticky="w") ttk.Button(conn, text="SSH-подключение", command=self.on_ssh_connect).grid(row=1, column=5, padx=4) ctrl = ttk.LabelFrame(tab, text="Управление сервером Ollama") ctrl.pack(fill="x", padx=6, pady=6) ttk.Button(ctrl, text="▶ Запустить", command=lambda: self.on_server("start")).pack(side="left", padx=6, pady=8) ttk.Button(ctrl, text="■ Остановить", command=lambda: self.on_server("stop")).pack(side="left", padx=6) ttk.Button(ctrl, text="↻ Перезапустить", command=lambda: self.on_server("restart")).pack(side="left", padx=6) ttk.Button(ctrl, text="ℹ Статус", command=lambda: self.on_server("status")).pack(side="left", padx=6) note = ttk.Label(tab, foreground="#666", wraplength=900, justify="left", text="Примечание: запуск/остановка через systemctl требует прав sudo без пароля " "(NOPASSWD) или запускается резервный режим «ollama serve». " "Вывод команд отображается в журнале внизу.") note.pack(fill="x", padx=10, pady=4) # --------------------------- вкладка: рекомендации --------------------- # def _build_tab_recommend(self, nb): tab = ttk.Frame(nb) nb.add(tab, text="Рекомендации") hw = ttk.LabelFrame(tab, text="Железо сервера") hw.pack(fill="x", padx=6, pady=6) ttk.Label(hw, text="ОЗУ (ГБ):").grid(row=0, column=0, padx=4, pady=6, sticky="e") self.var_ram = tk.StringVar(value="16") ttk.Entry(hw, textvariable=self.var_ram, width=8).grid(row=0, column=1, padx=4) ttk.Label(hw, text="VRAM (ГБ):").grid(row=0, column=2, padx=4, sticky="e") self.var_vram = tk.StringVar(value="0") ttk.Entry(hw, textvariable=self.var_vram, width=8).grid(row=0, column=3, padx=4) ttk.Button(hw, text="Определить по SSH", command=self.on_detect_hw).grid(row=0, column=4, padx=8) self.var_hw_desc = tk.StringVar(value="железо не определено (введите вручную)") ttk.Label(hw, textvariable=self.var_hw_desc, foreground="#0a5").grid( row=0, column=5, padx=8, sticky="w") actions = ttk.Frame(tab) actions.pack(fill="x", padx=6, pady=4) self.var_only_fit = tk.BooleanVar(value=True) ttk.Checkbutton(actions, text="Показывать только модели, подходящие для железа", variable=self.var_only_fit).pack(side="left", padx=6) ttk.Button(actions, text="🔎 Найти модель", command=self.on_find_models).pack(side="left", padx=8) lst = ttk.LabelFrame(tab, text="Рекомендуемые модели") lst.pack(fill="both", expand=True, padx=6, pady=6) cols = ("name", "size", "ram", "vram", "fit", "tag") self.tree_rec = ttk.Treeview(lst, columns=cols, show="headings", height=14) for c, t, w in (("name", "Модель", 200), ("size", "Загрузка", 90), ("ram", "Мин. ОЗУ", 90), ("vram", "Реком. VRAM", 100), ("fit", "Совместимость", 150), ("tag", "Тип", 110)): self.tree_rec.heading(c, text=t) self.tree_rec.column(c, width=w, anchor="w") self.tree_rec.pack(side="left", fill="both", expand=True, padx=(6, 0), pady=6) sb = ttk.Scrollbar(lst, orient="vertical", command=self.tree_rec.yview) sb.pack(side="left", fill="y", pady=6) self.tree_rec.configure(yscrollcommand=sb.set) self.tree_rec.tag_configure("gpu", foreground="#0a5") self.tree_rec.tag_configure("cpu", foreground="#a60") self.tree_rec.tag_configure("no", foreground="#999") ttk.Button(tab, text="Установить выбранную", command=self.on_pull_recommended).pack(side="left", padx=10, pady=(0, 8)) # -------------------------------- журнал ------------------------------- # def _build_console(self): frame = ttk.LabelFrame(self.root, text="Журнал") frame.pack(fill="both", padx=8, pady=(4, 8)) self.console = scrolledtext.ScrolledText(frame, height=9, state="disabled", wrap="word") self.console.pack(fill="both", expand=True, padx=4, pady=4) for tag, color in (("info", "#222"), ("success", "#0a5"), ("warn", "#a60"), ("error", "#b00")): self.console.tag_configure(tag, foreground=color) # =============================== helpers ============================== # def log(self, msg, level="info"): def append(): self.console.configure(state="normal") ts = time.strftime("%H:%M:%S") self.console.insert("end", f"[{ts}] {msg}\n", level) self.console.see("end") self.console.configure(state="disabled") self.root.after(0, append) def run_bg(self, fn, on_done=None): def worker(): try: result = fn() if on_done: self.root.after(0, lambda: on_done(result)) except Exception as exc: msg = str(exc) self.root.after(0, lambda: self.log(f"Ошибка: {msg}", "error")) threading.Thread(target=worker, daemon=True).start() def _selected(self, tree, col=0): sel = tree.selection() if not sel: return None return tree.item(sel[0], "values")[col] @staticmethod def _fmt_size(num_bytes): try: num = float(num_bytes) except (TypeError, ValueError): return "?" for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): if num < 1024: return f"{num:.1f} {unit}" num /= 1024 return f"{num:.1f} ПБ" # ============================== действия ============================== # def on_connect(self): self.client.set_endpoint(self.var_host.get().strip(), self.var_port.get().strip()) def task(): return self.client.version() def done(version): self.var_status.set(f"● подключено (v{version})") self.lbl_status.configure(foreground="#0a5") self.log(f"Подключено к {self.client.base_url} (Ollama v{version}).", "success") self.refresh_models() self.refresh_running() self.log(f"Подключение к {self.client.base_url} …") self.run_bg(task, done) # ----- модели ----- # def refresh_models(self): def task(): return self.client.list_models() def done(models): self.tree_models.delete(*self.tree_models.get_children()) for m in sorted(models, key=lambda x: x.get("name", "")): det = m.get("details", {}) or {} self.tree_models.insert("", "end", values=( m.get("name", "?"), self._fmt_size(m.get("size")), det.get("parameter_size", "—"), det.get("quantization_level", "—"), (m.get("modified_at", "") or "")[:19].replace("T", " "), )) self.log(f"Установлено моделей: {len(models)}.") self.run_bg(task, done) def on_pull(self): name = self.var_pull.get().strip() if not name: messagebox.showwarning("Установка", "Введите имя модели, например: llama3.2:3b") return self._pull(name) def _pull(self, name): def worker(): self.log(f"Загрузка модели «{name}» …") try: for data in self.client.pull_stream(name): status = data.get("status", "") total = data.get("total") completed = data.get("completed") if total and completed: pct = completed / total * 100 self.root.after(0, lambda p=pct, s=status, t=total, c=completed: self._set_progress(p, f"{s}: {self._fmt_size(c)}/{self._fmt_size(t)}")) else: self.root.after(0, lambda s=status: self._set_progress(None, s)) self.log(f"Модель «{name}» установлена.", "success") self.root.after(0, self.refresh_models) except Exception as exc: self.log(f"Ошибка загрузки: {exc}", "error") finally: self.root.after(0, lambda: self._set_progress(0, "")) threading.Thread(target=worker, daemon=True).start() def _set_progress(self, pct, text): if pct is None: self.progress.configure(mode="indeterminate") self.progress.start(12) else: self.progress.stop() self.progress.configure(mode="determinate", value=pct) self.var_pull_status.set(text) def on_delete(self): name = self._selected(self.tree_models) if not name: messagebox.showwarning("Удаление", "Выберите модель в списке.") return if not messagebox.askyesno("Удаление", f"Удалить модель «{name}»?"): return def task(): self.client.delete(name) return name def done(n): self.log(f"Модель «{n}» удалена.", "success") self.refresh_models() self.run_bg(task, done) def on_show_details(self): name = self._selected(self.tree_models) if not name: messagebox.showwarning("Детали", "Выберите модель в списке.") return def task(): return name, self.client.show(name) def done(res): n, info = res det = info.get("details", {}) or {} params = info.get("parameters", "") or "" caps = info.get("capabilities", []) or [] lines = [ f"Модель: {n}", f"Семейство: {det.get('family', '—')}", f"Параметры: {det.get('parameter_size', '—')}", f"Квантизация: {det.get('quantization_level', '—')}", f"Формат: {det.get('format', '—')}", f"Возможности: {', '.join(caps) if caps else '—'}", "", "Параметры запуска:", params.strip() or "—", ] self._show_text_window(f"Детали: {n}", "\n".join(lines)) self.run_bg(task, done) def on_copy(self): src = self._selected(self.tree_models) if not src: messagebox.showwarning("Копирование", "Выберите исходную модель.") return dest = self._ask_string("Копирование модели", f"Новое имя для копии «{src}»:") if not dest: return def task(): self.client.copy(src, dest) return dest def done(d): self.log(f"Модель «{src}» скопирована в «{d}».", "success") self.refresh_models() self.run_bg(task, done) def on_create(self): win = tk.Toplevel(self.root) win.title("Создать модель из Modelfile") win.geometry("520x360") frm = ttk.Frame(win) frm.pack(fill="both", expand=True, padx=8, pady=8) ttk.Label(frm, text="Имя новой модели:").grid(row=0, column=0, sticky="w") e_name = ttk.Entry(frm, width=30) e_name.grid(row=0, column=1, sticky="w", pady=4) ttk.Label(frm, text="На основе (from):").grid(row=1, column=0, sticky="w") e_from = ttk.Entry(frm, width=30) e_from.grid(row=1, column=1, sticky="w", pady=4) ttk.Label(frm, text="System prompt:").grid(row=2, column=0, sticky="nw") t_sys = tk.Text(frm, width=40, height=8) t_sys.grid(row=2, column=1, sticky="w", pady=4) def do_create(): name = e_name.get().strip() base = e_from.get().strip() system = t_sys.get("1.0", "end").strip() if not name or not base: messagebox.showwarning("Создание", "Укажите имя и базовую модель (from).") return win.destroy() self._create(name, base, system) ttk.Button(frm, text="Создать", command=do_create).grid(row=3, column=1, sticky="e", pady=8) def _create(self, name, base, system): def worker(): self.log(f"Создание модели «{name}» на основе «{base}» …") try: for data in self.client.create_stream(name, modelfile_from=base, system=system or None): st = data.get("status", "") if st: self.root.after(0, lambda s=st: self.var_pull_status.set(s)) self.log(f"Модель «{name}» создана.", "success") self.root.after(0, self.refresh_models) except Exception as exc: self.log(f"Ошибка создания: {exc}", "error") finally: self.root.after(0, lambda: self.var_pull_status.set("")) threading.Thread(target=worker, daemon=True).start() def on_push(self): name = self._selected(self.tree_models) if not name: messagebox.showwarning("Push", "Выберите модель. Имя должно быть вида namespace/model:tag.") return if "/" not in name: if not messagebox.askyesno("Push", f"Имя «{name}» не похоже на namespace/model:tag. Всё равно отправить?"): return def worker(): self.log(f"Отправка модели «{name}» в реестр …") try: for data in self.client.push_stream(name): st = data.get("status", "") if st: self.root.after(0, lambda s=st: self.var_pull_status.set(s)) self.log(f"Модель «{name}» отправлена.", "success") except Exception as exc: self.log(f"Ошибка push: {exc}", "error") finally: self.root.after(0, lambda: self.var_pull_status.set("")) threading.Thread(target=worker, daemon=True).start() # ----- запущенные ----- # def refresh_running(self): def task(): return self.client.list_running() def done(models): self.tree_running.delete(*self.tree_running.get_children()) for m in models: self.tree_running.insert("", "end", values=( m.get("name", "?"), self._fmt_size(m.get("size")), self._fmt_size(m.get("size_vram")), (m.get("expires_at", "") or "")[:19].replace("T", " "), )) self.log(f"Запущено моделей: {len(models)}.") self.run_bg(task, done) def on_unload(self): name = self._selected(self.tree_running) if not name: messagebox.showwarning("Выгрузка", "Выберите модель в списке запущенных.") return def task(): self.client.unload(name) return name def done(n): self.log(f"Модель «{n}» выгружена из памяти.", "success") self.refresh_running() self.run_bg(task, done) # ----- сервер (SSH) ----- # def on_ssh_connect(self): if not HAS_PARAMIKO: messagebox.showerror("SSH", "Не установлен paramiko: pip install paramiko") return host = self.var_ssh_host.get().strip() port = self.var_ssh_port.get().strip() user = self.var_ssh_user.get().strip() pw = self.var_ssh_pass.get() key = self.var_ssh_key.get().strip() def task(): self.ssh.connect(host, port, user, pw, key) return self.ssh.info def done(info): self.log(f"SSH подключён: {info}.", "success") self.log(f"SSH-подключение к {user}@{host}:{port} …") self.run_bg(task, done) def on_server(self, action): if not self.ssh.connected: messagebox.showwarning("Сервер", "Сначала установите SSH-подключение.") return labels = {"start": "Запуск", "stop": "Остановка", "restart": "Перезапуск", "status": "Статус"} fn = {"start": self.ssh.start_server, "stop": self.ssh.stop_server, "restart": self.ssh.restart_server, "status": self.ssh.status_server}[action] def task(): return fn() def done(res): code, out, err = res text = (out or "") + (("\n" + err) if err.strip() else "") self.log(f"{labels[action]} (код {code}):\n{text.strip()}", "success" if code == 0 else "warn") self.log(f"{labels[action]} сервера …") self.run_bg(task, done) # ----- железо и рекомендации ----- # def on_detect_hw(self): if not self.ssh.connected: messagebox.showwarning("Железо", "Сначала установите SSH-подключение (вкладка «Сервер»).") return def task(): return self.ssh.detect_hardware() def done(res): ram, vram, desc = res self.var_ram.set(str(ram)) self.var_vram.set(str(vram)) self.var_hw_desc.set(desc) self.log(f"Железо определено: {desc}", "success") self.log("Определение железа по SSH …") self.run_bg(task, done) def on_find_models(self): try: ram = float(self.var_ram.get().replace(",", ".")) vram = float(self.var_vram.get().replace(",", ".")) except ValueError: messagebox.showwarning("Рекомендации", "ОЗУ и VRAM должны быть числами (ГБ).") return only_fit = self.var_only_fit.get() self.tree_rec.delete(*self.tree_rec.get_children()) shown = 0 # Сортировка: сначала самые крупные модели, которые помещаются (наиболее способные). for name, size_gb, min_ram, vram_rec, tag in sorted( MODEL_CATALOG, key=lambda x: -x[1]): fits_gpu = vram > 0 and vram_rec <= vram fits_ram = min_ram <= ram if fits_gpu: fit_text, fit_tag = "✓ GPU (быстро)", "gpu" elif fits_ram: fit_text, fit_tag = "✓ CPU/частично", "cpu" else: fit_text, fit_tag = "✗ не хватит памяти", "no" if only_fit and not fits_ram: continue self.tree_rec.insert("", "end", tags=(fit_tag,), values=( name, f"{size_gb:.1f} ГБ", f"{min_ram:.0f} ГБ", f"{vram_rec:.0f} ГБ", fit_text, tag, )) shown += 1 if only_fit: self.log(f"Найдено моделей под железо (ОЗУ {ram:g}ГБ / VRAM {vram:g}ГБ): {shown}.", "success") else: self.log(f"Показаны все модели каталога: {shown} " f"(зелёные — ускорятся на GPU, оранжевые — пойдут на CPU).") def on_pull_recommended(self): name = self._selected(self.tree_rec) if not name: messagebox.showwarning("Установка", "Выберите модель в списке рекомендаций.") return self.var_pull.set(name) self._pull(name) # ----- небольшие диалоги ----- # def _ask_string(self, title, prompt): win = tk.Toplevel(self.root) win.title(title) win.transient(self.root) win.grab_set() ttk.Label(win, text=prompt).pack(padx=12, pady=(12, 4)) var = tk.StringVar() entry = ttk.Entry(win, textvariable=var, width=36) entry.pack(padx=12, pady=4) entry.focus_set() result = {"value": None} def ok(): result["value"] = var.get().strip() win.destroy() bar = ttk.Frame(win) bar.pack(pady=8) ttk.Button(bar, text="OK", command=ok).pack(side="left", padx=4) ttk.Button(bar, text="Отмена", command=win.destroy).pack(side="left", padx=4) win.wait_window() return result["value"] def _show_text_window(self, title, text): win = tk.Toplevel(self.root) win.title(title) win.geometry("560x420") box = scrolledtext.ScrolledText(win, wrap="word") box.pack(fill="both", expand=True, padx=6, pady=6) box.insert("1.0", text) box.configure(state="disabled") def main(): root = tk.Tk() try: ttk.Style().theme_use("clam") except tk.TclError: pass OllamaManagerApp(root) root.mainloop() if __name__ == "__main__": main()