fe5125d7b0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
946 lines
43 KiB
Python
946 lines
43 KiB
Python
#!/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()
|