commit fe5125d7b02fa2b05e679c934dfc580650299e80 Author: dinlo Date: Sun May 31 18:45:36 2026 +0800 Initial commit Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2f398ec --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b812726 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Ollama Manager + +Графический менеджер (Python + Tkinter) для управления удалённым сервером Ollama +по адресу `192.168.1.118`. + +## Установка + +```bash +pip install -r requirements.txt +python ollama_manager.py +``` + +`tkinter` входит в стандартную поставку Python. На Linux при необходимости: +`sudo apt install python3-tk`. + +`paramiko` нужен только для вкладки **Сервер (SSH)** и кнопки **«Определить по SSH»**. +Без него управление моделями по HTTP работает в полном объёме. + +## Возможности + +### Вкладка «Модели» (по HTTP, порт 11434) +- Список установленных моделей: размер, число параметров, квантизация, дата. +- **Установить (pull)** — загрузка модели с индикатором прогресса. +- **Удалить**, **Копировать**, **Детали** (`/api/show`). +- **Создать из Modelfile** (`/api/create`, на основе базовой модели + system-prompt). +- **Push** — отправка модели в реестр (имя вида `namespace/model:tag`). + +### Вкладка «Запущенные» +- Список загруженных в память моделей (`/api/ps`): объём в RAM и VRAM. +- **Выгрузить из памяти** (`keep_alive=0`). + +### Вкладка «Сервер (SSH)» +- SSH-подключение к машине с Ollama (пароль или ключ). +- **Запустить / Остановить / Перезапустить / Статус** сервиса `ollama`. + Используется `systemctl` (через `sudo -n`), с резервом на `ollama serve` / `pkill`. + +> Управление процессом сервера невозможно через HTTP API Ollama — он не умеет +> сам себя запускать/останавливать. Поэтому эти операции выполняются по SSH. +> Для `systemctl` без пароля настройте `sudo NOPASSWD` для пользователя. + +### Вкладка «Рекомендации» — «Найти модель» +- Поля **ОЗУ** и **VRAM** заполняются вручную или кнопкой **«Определить по SSH»** + (читает `/proc/meminfo` и `nvidia-smi`, для Apple Silicon — единую память). +- Чекбокс **«Показывать только модели, подходящие для железа»**: + - включён → в списке остаются только модели, которые поместятся в ОЗУ; + - выключен → показывается весь каталог. +- Цветовая маркировка: 🟢 поместится в VRAM (быстро на GPU), 🟠 пойдёт на CPU/частично, + ⚪ памяти не хватит. +- **Установить выбранную** — сразу запускает `pull` выбранной модели. + +## Примечание о размерах моделей + +Каталог рекомендаций (`MODEL_CATALOG` в `ollama_manager.py`) содержит ориентировочные +размеры и требования к памяти для популярных моделей (квантизация Q4). Это оценка, +а не точная гарантия — список легко расширить, отредактировав словарь в коде. diff --git a/__pycache__/ollama_manager.cpython-312.pyc b/__pycache__/ollama_manager.cpython-312.pyc new file mode 100644 index 0000000..a5cc804 Binary files /dev/null and b/__pycache__/ollama_manager.cpython-312.pyc differ diff --git a/ollama_manager.py b/ollama_manager.py new file mode 100644 index 0000000..de7f328 --- /dev/null +++ b/ollama_manager.py @@ -0,0 +1,945 @@ +#!/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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..db0c04d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28 +paramiko>=3.0