Files
ollama-upravlenie/ollama_manager.py
T
dinlo fe5125d7b0 Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:45:36 +08:00

946 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()