Files
ollama-upravlenie/ollama_manager.py
T

946 lines
43 KiB
Python
Raw Normal View History

2026-05-31 18:45:36 +08:00
#!/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()