From fe5125d7b02fa2b05e679c934dfc580650299e80 Mon Sep 17 00:00:00 2001 From: dinlo Date: Sun, 31 May 2026 18:45:36 +0800 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/settings.local.json | 8 + README.md | 55 ++ __pycache__/ollama_manager.cpython-312.pyc | Bin 0 -> 67918 bytes ollama_manager.py | 945 +++++++++++++++++++++ requirements.txt | 2 + 5 files changed, 1010 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 README.md create mode 100644 __pycache__/ollama_manager.cpython-312.pyc create mode 100644 ollama_manager.py create mode 100644 requirements.txt 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 0000000000000000000000000000000000000000..a5cc804061ffc867c5d11aec990f7f3d421fbef2 GIT binary patch literal 67918 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 literal 0 HcmV?d00001 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