w1x-cli 3.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- w1x/__init__.py +5 -0
- w1x/app.py +754 -0
- w1x/config.py +189 -0
- w1x/engine.py +565 -0
- w1x/memory.py +272 -0
- w1x/models.py +458 -0
- w1x/puller.py +955 -0
- w1x/terminal.py +661 -0
- w1x/testpy.py +325 -0
- w1x/tools.py +897 -0
- w1x_cli-3.5.0.dist-info/METADATA +178 -0
- w1x_cli-3.5.0.dist-info/RECORD +16 -0
- w1x_cli-3.5.0.dist-info/WHEEL +5 -0
- w1x_cli-3.5.0.dist-info/entry_points.txt +2 -0
- w1x_cli-3.5.0.dist-info/licenses/LICENSE +21 -0
- w1x_cli-3.5.0.dist-info/top_level.txt +1 -0
w1x/puller.py
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""W1x: Кроссплатформенный каталог моделей Ollama (Windows, macOS, Linux) с окном прокрутки (viewport), фоновой загрузкой и адаптацией под GPU/Apple Silicon/RAM."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import shutil
|
|
7
|
+
import ctypes
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from typing import List, Optional, Dict, Any
|
|
11
|
+
from ollama import pull
|
|
12
|
+
|
|
13
|
+
from rich.progress import (
|
|
14
|
+
Progress, SpinnerColumn, TextColumn,
|
|
15
|
+
BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn,
|
|
16
|
+
)
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from .terminal import console, session, HTML
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Определение параметров железа (Windows / macOS Apple Silicon / Linux)
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def detect_hardware() -> Dict[str, Any]:
|
|
29
|
+
hw = {"gpu_name": "Не определена", "vram_gb": 0.0, "ram_gb": 8.0}
|
|
30
|
+
|
|
31
|
+
# 1. Определение оперативной памяти (RAM)
|
|
32
|
+
if sys.platform == "win32":
|
|
33
|
+
try:
|
|
34
|
+
class _MEM(ctypes.Structure):
|
|
35
|
+
_fields_ = [
|
|
36
|
+
("dwLength", ctypes.c_uint32), ("dwMemoryLoad", ctypes.c_uint32),
|
|
37
|
+
("ullTotalPhys", ctypes.c_uint64), ("ullAvailPhys", ctypes.c_uint64),
|
|
38
|
+
("ullTotalPageFile", ctypes.c_uint64), ("ullAvailPageFile", ctypes.c_uint64),
|
|
39
|
+
("ullTotalVirtual", ctypes.c_uint64), ("ullAvailVirtual", ctypes.c_uint64),
|
|
40
|
+
("sullAvailExtendedVirtual", ctypes.c_uint64),
|
|
41
|
+
]
|
|
42
|
+
stat = _MEM()
|
|
43
|
+
stat.dwLength = ctypes.sizeof(_MEM)
|
|
44
|
+
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
|
45
|
+
hw["ram_gb"] = round(stat.ullTotalPhys / (1024 ** 3), 1)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
elif sys.platform == "darwin": # macOS (Apple Silicon Unified Memory)
|
|
50
|
+
try:
|
|
51
|
+
mem_raw = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True, stderr=subprocess.DEVNULL).strip()
|
|
52
|
+
if mem_raw.isdigit():
|
|
53
|
+
hw["ram_gb"] = round(int(mem_raw) / (1024 ** 3), 1)
|
|
54
|
+
# На Mac с Apple Silicon видеопамять объединена с оперативной (Unified Memory)
|
|
55
|
+
hw["gpu_name"] = "Apple Silicon (Unified Memory)"
|
|
56
|
+
hw["vram_gb"] = hw["ram_gb"]
|
|
57
|
+
return hw
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
else: # Linux
|
|
62
|
+
try:
|
|
63
|
+
with open("/proc/meminfo", "r", encoding="utf-8") as f:
|
|
64
|
+
for line in f:
|
|
65
|
+
if line.startswith("MemTotal:"):
|
|
66
|
+
kb = int(line.split()[1])
|
|
67
|
+
hw["ram_gb"] = round(kb / (1024 ** 2), 1)
|
|
68
|
+
break
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# 2. Определение дискретной GPU через nvidia-smi (Windows & Linux)
|
|
73
|
+
try:
|
|
74
|
+
out = subprocess.check_output(
|
|
75
|
+
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
|
|
76
|
+
text=True, stderr=subprocess.DEVNULL,
|
|
77
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0,
|
|
78
|
+
).strip()
|
|
79
|
+
if out:
|
|
80
|
+
gpu_name, vram_mb = [x.strip() for x in out.splitlines()[0].split(",")]
|
|
81
|
+
hw["gpu_name"] = gpu_name
|
|
82
|
+
hw["vram_gb"] = round(float(vram_mb) / 1024, 1)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return hw
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Каталог моделей Ollama
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
MODEL_CATALOG = [
|
|
94
|
+
# ── 1B–4B: Легкие и быстрые (полностью в VRAM) ──────────────────────────
|
|
95
|
+
{
|
|
96
|
+
"category": "light",
|
|
97
|
+
"name": "qwen3.5:4b",
|
|
98
|
+
"size_gb": 3.2,
|
|
99
|
+
"vram_min": 3.5,
|
|
100
|
+
"note": "Qwen 3.5 4B — умная и сбалансированная модель. Отличный универсальный помощник",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"category": "light",
|
|
104
|
+
"name": "gemma3:4b",
|
|
105
|
+
"size_gb": 3.1,
|
|
106
|
+
"vram_min": 3.5,
|
|
107
|
+
"note": "Google Gemma 3 4B — четкое следование инструкциям, логика и качественный код",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"category": "light",
|
|
111
|
+
"name": "phi4-mini:latest",
|
|
112
|
+
"size_gb": 2.3,
|
|
113
|
+
"vram_min": 2.8,
|
|
114
|
+
"note": "Microsoft Phi-4 Mini 3.8B — рекорды по математике и STEM-задачам в малом весе",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"category": "light",
|
|
118
|
+
"name": "llama3.2:3b",
|
|
119
|
+
"size_gb": 1.9,
|
|
120
|
+
"vram_min": 2.5,
|
|
121
|
+
"note": "Meta Llama 3.2 3B — компактная и шустрая, мгновенные ответы на любом ПК",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"category": "light",
|
|
125
|
+
"name": "ministral-3:3b",
|
|
126
|
+
"size_gb": 2.8,
|
|
127
|
+
"vram_min": 3.2,
|
|
128
|
+
"note": "Mistral AI Ministral 3B — оптимизирована под работу с инструментами и вызовами",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"category": "light",
|
|
132
|
+
"name": "smollm2:1.7b",
|
|
133
|
+
"size_gb": 1.1,
|
|
134
|
+
"vram_min": 1.8,
|
|
135
|
+
"note": "Hugging Face SmolLM2 1.7B — ультралегкая модель с молниеносной генерацией",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"category": "light",
|
|
139
|
+
"name": "nemotron-3-nano:4b",
|
|
140
|
+
"size_gb": 2.6,
|
|
141
|
+
"vram_min": 3.2,
|
|
142
|
+
"note": "NVIDIA Nemotron 3 Nano 4B — высокая точность структурирования и синтеза ответов",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"category": "light",
|
|
146
|
+
"name": "granite4.1:3b",
|
|
147
|
+
"size_gb": 2.0,
|
|
148
|
+
"vram_min": 2.6,
|
|
149
|
+
"note": "IBM Granite 3.1 3B — корпоративная модель для документации и точных фактов",
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
# ── 7B–9B: Средние (универсальные рабочие лошадки) ───────────────────────
|
|
153
|
+
{
|
|
154
|
+
"category": "medium",
|
|
155
|
+
"name": "deepseek-r1:7b",
|
|
156
|
+
"size_gb": 4.4,
|
|
157
|
+
"vram_min": 6.3,
|
|
158
|
+
"note": "DeepSeek R1 7B — цепочки рассуждений (CoT). Пошагово решает логические задачи",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"category": "medium",
|
|
162
|
+
"name": "qwen2.5:7b",
|
|
163
|
+
"size_gb": 4.4,
|
|
164
|
+
"vram_min": 6.6,
|
|
165
|
+
"note": "Alibaba Qwen 2.5 7B — великолепный русский язык, аналитика, эрудиция и диалоги",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"category": "medium",
|
|
169
|
+
"name": "qwen2.5-coder:7b",
|
|
170
|
+
"size_gb": 4.7,
|
|
171
|
+
"vram_min": 6.6,
|
|
172
|
+
"note": "Qwen 2.5 Coder 7B — лидер среди 7B по написанию кода, рефакторингу и поиску багов",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"category": "medium",
|
|
176
|
+
"name": "llama3.1:8b",
|
|
177
|
+
"size_gb": 4.7,
|
|
178
|
+
"vram_min": 6.8,
|
|
179
|
+
"note": "Meta Llama 3.1 8B — золотой стандарт открытого ИИ с контекстом до 128k токенов",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"category": "medium",
|
|
183
|
+
"name": "qwen3.5:9b",
|
|
184
|
+
"size_gb": 6.1,
|
|
185
|
+
"vram_min": 7.5,
|
|
186
|
+
"note": "Qwen 3.5 9B — топовый интеллект в среднем классе, превосходит большинство 7B",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"category": "medium",
|
|
190
|
+
"name": "mistral:7b",
|
|
191
|
+
"size_gb": 4.1,
|
|
192
|
+
"vram_min": 6.2,
|
|
193
|
+
"note": "Mistral 7B v0.3 — классическая быстрая европейская модель общего назначения",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"category": "medium",
|
|
197
|
+
"name": "hermes3:8b",
|
|
198
|
+
"size_gb": 4.9,
|
|
199
|
+
"vram_min": 7.0,
|
|
200
|
+
"note": "Nous Hermes 3 8B — лучшая для живого общения, ролевых диалогов и креатива",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"category": "medium",
|
|
204
|
+
"name": "gemma2:9b",
|
|
205
|
+
"size_gb": 5.5,
|
|
206
|
+
"vram_min": 7.2,
|
|
207
|
+
"note": "Google Gemma 2 9B — сильная аналитическая модель с высокой плотностью знаний",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"category": "medium",
|
|
211
|
+
"name": "starcoder2:7b",
|
|
212
|
+
"size_gb": 4.1,
|
|
213
|
+
"vram_min": 6.2,
|
|
214
|
+
"note": "BigCode StarCoder2 7B — обучена на 600+ языках программирования и GitHub коде",
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
# ── 12B–22B: Продвинутые и профессиональные ──────────────────────────────
|
|
218
|
+
{
|
|
219
|
+
"category": "heavy",
|
|
220
|
+
"name": "mistral-nemo:12b",
|
|
221
|
+
"size_gb": 7.1,
|
|
222
|
+
"vram_min": 9.0,
|
|
223
|
+
"note": "Mistral NeMo 12B (NVIDIA + Mistral) — 128k контекст, отличная многоязычность",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"category": "heavy",
|
|
227
|
+
"name": "phi4:latest",
|
|
228
|
+
"size_gb": 9.1,
|
|
229
|
+
"vram_min": 10.5,
|
|
230
|
+
"note": "Microsoft Phi-4 14B — передовая модель для сложных математических и научных задач",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"category": "heavy",
|
|
234
|
+
"name": "qwen2.5:14b",
|
|
235
|
+
"size_gb": 9.0,
|
|
236
|
+
"vram_min": 10.5,
|
|
237
|
+
"note": "Qwen 2.5 14B — профессиональный уровень рассуждений и анализа больших текстов",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"category": "heavy",
|
|
241
|
+
"name": "qwen2.5-coder:14b",
|
|
242
|
+
"size_gb": 9.0,
|
|
243
|
+
"vram_min": 10.5,
|
|
244
|
+
"note": "Qwen 2.5 Coder 14B — уровень Senior-разработчика: сложные алгоритмы и архитектура",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"category": "heavy",
|
|
248
|
+
"name": "deepseek-r1:14b",
|
|
249
|
+
"size_gb": 9.0,
|
|
250
|
+
"vram_min": 10.5,
|
|
251
|
+
"note": "DeepSeek R1 14B — глубокие цепочки рассуждений для сложных головоломок и кода",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"category": "heavy",
|
|
255
|
+
"name": "codestral:22b",
|
|
256
|
+
"size_gb": 13.0,
|
|
257
|
+
"vram_min": 15.0,
|
|
258
|
+
"note": "Mistral Codestral 22B — мощная специализированная модель для программирования",
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
# ── 27B–70B: Флагманские тяжеловесы (красная зона) ───────────────────────
|
|
262
|
+
{
|
|
263
|
+
"category": "flagship",
|
|
264
|
+
"name": "qwen2.5:32b",
|
|
265
|
+
"size_gb": 20.0,
|
|
266
|
+
"vram_min": 21.0,
|
|
267
|
+
"note": "Qwen 2.5 32B — флагманское качество (уровень GPT-4o). Требует 24 GB памяти",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"category": "flagship",
|
|
271
|
+
"name": "deepseek-r1:32b",
|
|
272
|
+
"size_gb": 20.0,
|
|
273
|
+
"vram_min": 21.0,
|
|
274
|
+
"note": "DeepSeek R1 32B — мощнейшая открытая модель для логики и доказательств",
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"category": "flagship",
|
|
278
|
+
"name": "gemma3:27b",
|
|
279
|
+
"size_gb": 17.0,
|
|
280
|
+
"vram_min": 18.5,
|
|
281
|
+
"note": "Google Gemma 3 27B — флагман от Google для анализа огромных документов",
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"category": "flagship",
|
|
285
|
+
"name": "command-r:latest",
|
|
286
|
+
"size_gb": 20.0,
|
|
287
|
+
"vram_min": 22.0,
|
|
288
|
+
"note": "Cohere Command R 35B — чемпион по поиску по документам (RAG) и интеграциям",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"category": "flagship",
|
|
292
|
+
"name": "llama3.3:70b",
|
|
293
|
+
"size_gb": 43.0,
|
|
294
|
+
"vram_min": 45.0,
|
|
295
|
+
"note": "Meta Llama 3.3 70B — супертяжеловес. Требует 64+ GB RAM или несколько GPU",
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
# ── Зрение и Мультимодальность (Vision) ──────────────────────────────────
|
|
299
|
+
{
|
|
300
|
+
"category": "vision",
|
|
301
|
+
"name": "llava:7b",
|
|
302
|
+
"size_gb": 4.7,
|
|
303
|
+
"vram_min": 6.5,
|
|
304
|
+
"note": "LLaVA 1.6 7B — распознавание изображений, чтение скриншотов и графиков",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"category": "vision",
|
|
308
|
+
"name": "minicpm-v:latest",
|
|
309
|
+
"size_gb": 5.5,
|
|
310
|
+
"vram_min": 7.0,
|
|
311
|
+
"note": "MiniCPM-V 2.6 — высокая детализация OCR, распознает текст с фото и диаграммы",
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
# ── Без цензуры (Abliterated) ─────────────────────────────────────────────
|
|
315
|
+
{
|
|
316
|
+
"category": "uncensored",
|
|
317
|
+
"name": "huihui_ai/qwen3.5-abliterated:4b",
|
|
318
|
+
"size_gb": 3.1,
|
|
319
|
+
"vram_min": 3.5,
|
|
320
|
+
"note": "Qwen 3.5 4B Abliterated — сняты корпоративные фильтры, свободные ответы",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"category": "uncensored",
|
|
324
|
+
"name": "huihui_ai/qwen2.5-abliterate:7b-instruct-q4_K_M",
|
|
325
|
+
"size_gb": 4.4,
|
|
326
|
+
"vram_min": 6.6,
|
|
327
|
+
"note": "Qwen 2.5 7B Abliterated — 7B без цензуры, прямые ответы на любые темы",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"category": "uncensored",
|
|
331
|
+
"name": "huihui_ai/deepseek-r1-abliterated:8b",
|
|
332
|
+
"size_gb": 4.9,
|
|
333
|
+
"vram_min": 7.0,
|
|
334
|
+
"note": "DeepSeek R1 8B Abliterated — рассуждения без цензуры и ограничений",
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
# ── Ввод вручную ─────────────────────────────────────────────────────────
|
|
338
|
+
{
|
|
339
|
+
"category": "custom",
|
|
340
|
+
"name": "Ввести своё имя модели...",
|
|
341
|
+
"size_gb": 0,
|
|
342
|
+
"vram_min": 0,
|
|
343
|
+
"note": "Любой тег из библиотеки Ollama, например: solar-pro, codellama, dolphin3",
|
|
344
|
+
},
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def classify_model_fit(vram_min: float, size_gb: float, hw: Dict[str, Any]) -> str:
|
|
349
|
+
"""Определяет категорию нагрузки: green (легко), orange (в RAM), red (не потянет)."""
|
|
350
|
+
if size_gb <= 0 or vram_min <= 0:
|
|
351
|
+
return "custom"
|
|
352
|
+
|
|
353
|
+
vram_gb = hw.get("vram_gb", 0.0)
|
|
354
|
+
ram_gb = hw.get("ram_gb", 8.0)
|
|
355
|
+
|
|
356
|
+
# На Apple Silicon вся память является общей (Unified Memory)
|
|
357
|
+
if "Apple Silicon" in hw.get("gpu_name", ""):
|
|
358
|
+
eff_unified = max(0.0, ram_gb - 2.5)
|
|
359
|
+
if vram_min <= eff_unified:
|
|
360
|
+
return "green"
|
|
361
|
+
return "red"
|
|
362
|
+
|
|
363
|
+
eff_vram = max(0.0, vram_gb - 1.0)
|
|
364
|
+
eff_total = eff_vram + max(0.0, ram_gb - 3.5)
|
|
365
|
+
|
|
366
|
+
if eff_vram > 0 and vram_min <= eff_vram:
|
|
367
|
+
return "green"
|
|
368
|
+
elif vram_min <= eff_total:
|
|
369
|
+
return "orange"
|
|
370
|
+
else:
|
|
371
|
+
return "red"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Фоновый менеджер загрузки и очереди
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
class DownloadTask:
|
|
379
|
+
def __init__(self, model_name: str):
|
|
380
|
+
self.model_name = model_name
|
|
381
|
+
self.status = "queued"
|
|
382
|
+
self.completed_bytes = 0
|
|
383
|
+
self.total_bytes = 0
|
|
384
|
+
self.percentage = 0.0
|
|
385
|
+
self.speed = "0.0 MB/s"
|
|
386
|
+
self.status_msg = "В очереди"
|
|
387
|
+
self.error_msg = ""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class AsyncPullManager:
|
|
391
|
+
def __init__(self):
|
|
392
|
+
self.tasks: Dict[str, DownloadTask] = {}
|
|
393
|
+
self.queue: List[str] = []
|
|
394
|
+
self._lock = threading.Lock()
|
|
395
|
+
self._worker_thread: Optional[threading.Thread] = None
|
|
396
|
+
self._installed_cache: Optional[set] = None
|
|
397
|
+
|
|
398
|
+
def get_installed(self) -> set:
|
|
399
|
+
if self._installed_cache is None:
|
|
400
|
+
try:
|
|
401
|
+
from .models import installed_models
|
|
402
|
+
self._installed_cache = set(installed_models())
|
|
403
|
+
except Exception:
|
|
404
|
+
self._installed_cache = set()
|
|
405
|
+
return self._installed_cache
|
|
406
|
+
|
|
407
|
+
def refresh_installed(self):
|
|
408
|
+
try:
|
|
409
|
+
from .models import installed_models
|
|
410
|
+
self._installed_cache = set(installed_models())
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
def enqueue(self, model_name: str) -> str:
|
|
415
|
+
clean = model_name.strip()
|
|
416
|
+
if not clean:
|
|
417
|
+
return "empty"
|
|
418
|
+
with self._lock:
|
|
419
|
+
if clean in self.get_installed():
|
|
420
|
+
return "already_installed"
|
|
421
|
+
if clean in self.tasks:
|
|
422
|
+
task = self.tasks[clean]
|
|
423
|
+
if task.status in ("queued", "downloading"):
|
|
424
|
+
return "already_in_progress"
|
|
425
|
+
if task.status == "completed":
|
|
426
|
+
return "already_completed"
|
|
427
|
+
|
|
428
|
+
task = DownloadTask(clean)
|
|
429
|
+
self.tasks[clean] = task
|
|
430
|
+
self.queue.append(clean)
|
|
431
|
+
self._ensure_worker()
|
|
432
|
+
return "enqueued"
|
|
433
|
+
|
|
434
|
+
def get_task(self, model_name: str) -> Optional[DownloadTask]:
|
|
435
|
+
with self._lock:
|
|
436
|
+
return self.tasks.get(model_name)
|
|
437
|
+
|
|
438
|
+
def get_queue_position(self, model_name: str) -> int:
|
|
439
|
+
with self._lock:
|
|
440
|
+
if model_name in self.queue:
|
|
441
|
+
return self.queue.index(model_name) + 1
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
def active_and_pending_count(self) -> tuple[int, int]:
|
|
445
|
+
with self._lock:
|
|
446
|
+
active = sum(1 for t in self.tasks.values() if t.status == "downloading")
|
|
447
|
+
pending = sum(1 for t in self.tasks.values() if t.status == "queued")
|
|
448
|
+
return active, pending
|
|
449
|
+
|
|
450
|
+
def _ensure_worker(self):
|
|
451
|
+
if self._worker_thread is None or not self._worker_thread.is_alive():
|
|
452
|
+
self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
|
|
453
|
+
self._worker_thread.start()
|
|
454
|
+
|
|
455
|
+
def _worker_loop(self):
|
|
456
|
+
while True:
|
|
457
|
+
current_model = None
|
|
458
|
+
with self._lock:
|
|
459
|
+
if self.queue:
|
|
460
|
+
current_model = self.queue.pop(0)
|
|
461
|
+
if not current_model:
|
|
462
|
+
break
|
|
463
|
+
|
|
464
|
+
with self._lock:
|
|
465
|
+
task = self.tasks.get(current_model)
|
|
466
|
+
if not task:
|
|
467
|
+
continue
|
|
468
|
+
task.status = "downloading"
|
|
469
|
+
task.status_msg = "Подключение..."
|
|
470
|
+
|
|
471
|
+
last_calc_time = time.time()
|
|
472
|
+
last_bytes = 0
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
for chunk in pull(current_model, stream=True):
|
|
476
|
+
status = getattr(chunk, "status", "") or (chunk.get("status", "") if isinstance(chunk, dict) else "")
|
|
477
|
+
completed = getattr(chunk, "completed", 0) or (chunk.get("completed", 0) if isinstance(chunk, dict) else 0)
|
|
478
|
+
total = getattr(chunk, "total", 0) or (chunk.get("total", 0) if isinstance(chunk, dict) else 0)
|
|
479
|
+
|
|
480
|
+
now = time.time()
|
|
481
|
+
dt = now - last_calc_time
|
|
482
|
+
if dt >= 0.4:
|
|
483
|
+
speed_bps = (completed - last_bytes) / dt if dt > 0 else 0
|
|
484
|
+
speed_mb = speed_bps / (1024 * 1024)
|
|
485
|
+
with self._lock:
|
|
486
|
+
task.speed = f"{speed_mb:.1f} MB/s"
|
|
487
|
+
last_calc_time = now
|
|
488
|
+
last_bytes = completed
|
|
489
|
+
|
|
490
|
+
with self._lock:
|
|
491
|
+
task.completed_bytes = completed
|
|
492
|
+
task.total_bytes = total
|
|
493
|
+
if total > 0:
|
|
494
|
+
task.percentage = (completed / total) * 100.0
|
|
495
|
+
task.status_msg = status
|
|
496
|
+
|
|
497
|
+
with self._lock:
|
|
498
|
+
task.status = "completed"
|
|
499
|
+
task.percentage = 100.0
|
|
500
|
+
task.status_msg = "Установлена"
|
|
501
|
+
if self._installed_cache is not None:
|
|
502
|
+
self._installed_cache.add(current_model)
|
|
503
|
+
|
|
504
|
+
except Exception as err:
|
|
505
|
+
with self._lock:
|
|
506
|
+
task.status = "error"
|
|
507
|
+
task.error_msg = str(err)
|
|
508
|
+
task.status_msg = f"Ошибка: {err}"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
GLOBAL_PULL_MANAGER = AsyncPullManager()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
# Отрисовка каталога с окном прокрутки (Viewport Scrolling)
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
def _render_catalog(
|
|
519
|
+
items: List[Dict[str, Any]],
|
|
520
|
+
cursor: int,
|
|
521
|
+
scroll_offset: int,
|
|
522
|
+
page_size: int,
|
|
523
|
+
hw: Dict[str, Any],
|
|
524
|
+
notification: str = ""
|
|
525
|
+
) -> Text:
|
|
526
|
+
vram_gb = hw["vram_gb"]
|
|
527
|
+
gpu_name = hw["gpu_name"]
|
|
528
|
+
ram_gb = hw["ram_gb"]
|
|
529
|
+
is_apple_silicon = "Apple Silicon" in gpu_name
|
|
530
|
+
effective_vram = max(0.0, vram_gb - 1.0) if not is_apple_silicon else vram_gb
|
|
531
|
+
installed_set = GLOBAL_PULL_MANAGER.get_installed()
|
|
532
|
+
total = len(items)
|
|
533
|
+
|
|
534
|
+
t = Text()
|
|
535
|
+
|
|
536
|
+
# Шапка информации
|
|
537
|
+
if is_apple_silicon:
|
|
538
|
+
t.append(f" Платформа: {gpu_name} Память (Unified): {ram_gb} GB\n", style="dim")
|
|
539
|
+
else:
|
|
540
|
+
t.append(f" Видеокарта: {gpu_name} VRAM: {vram_gb} GB (доступно ~{effective_vram:.0f} GB) RAM: {ram_gb} GB\n", style="dim")
|
|
541
|
+
t.append(" " + chr(0x2500) * 86 + "\n", style="dim")
|
|
542
|
+
|
|
543
|
+
# Заголовок колонок + счетчик позиции
|
|
544
|
+
t.append(f" {'№':>2} {'Модель':<38} {'Вес':>8} {'Потребление памяти':>18} {'Статус':<15} [{cursor+1}/{total}]\n", style="dim")
|
|
545
|
+
t.append(" " + chr(0x2500) * 86 + "\n", style="dim")
|
|
546
|
+
|
|
547
|
+
# Индикатор скрытых строк сверху
|
|
548
|
+
if scroll_offset > 0:
|
|
549
|
+
t.append(f" ▲ ... еще {scroll_offset} выше (листайте ↑) ...\n", style="dim cyan")
|
|
550
|
+
|
|
551
|
+
# Видимый срез элементов
|
|
552
|
+
visible_end = min(total, scroll_offset + page_size)
|
|
553
|
+
for i in range(scroll_offset, visible_end):
|
|
554
|
+
item = items[i]
|
|
555
|
+
name = item["name"]
|
|
556
|
+
size = item["size_gb"]
|
|
557
|
+
vram_min = item["vram_min"]
|
|
558
|
+
is_cursor = (i == cursor)
|
|
559
|
+
is_custom = (item.get("category") == "custom")
|
|
560
|
+
fit = classify_model_fit(vram_min, size, hw)
|
|
561
|
+
|
|
562
|
+
# Колонки
|
|
563
|
+
idx_str = f"{i+1:>2}."
|
|
564
|
+
disp_name = name if len(name) <= 38 else (name[:35] + "...")
|
|
565
|
+
size_str = f"{size:.1f} GB" if size > 0 else " -- "
|
|
566
|
+
vram_str = f"~{vram_min:.1f} GB" if vram_min > 0 else " -- "
|
|
567
|
+
|
|
568
|
+
# Статус
|
|
569
|
+
task = GLOBAL_PULL_MANAGER.get_task(name)
|
|
570
|
+
is_installed = name in installed_set or (task and task.status == "completed")
|
|
571
|
+
is_downloading = (task and task.status == "downloading")
|
|
572
|
+
is_queued = (task and task.status == "queued")
|
|
573
|
+
|
|
574
|
+
if is_downloading:
|
|
575
|
+
status_text = f"Загрузка {task.percentage:>2.0f}% ({task.speed})"
|
|
576
|
+
status_style = "bold cyan"
|
|
577
|
+
elif is_queued:
|
|
578
|
+
pos = GLOBAL_PULL_MANAGER.get_queue_position(name)
|
|
579
|
+
status_text = f"В очереди #{pos}" if pos > 0 else "В очереди"
|
|
580
|
+
status_style = "bold yellow"
|
|
581
|
+
elif is_installed:
|
|
582
|
+
status_text = "Установлена"
|
|
583
|
+
status_style = "bold green"
|
|
584
|
+
elif is_custom:
|
|
585
|
+
status_text = "Ввести тег"
|
|
586
|
+
status_style = "dim"
|
|
587
|
+
elif fit == "green":
|
|
588
|
+
status_text = "Легко (VRAM)" if not is_apple_silicon else "Легко (Memory)"
|
|
589
|
+
status_style = "green"
|
|
590
|
+
elif fit == "orange":
|
|
591
|
+
status_text = "В RAM (медленнее)"
|
|
592
|
+
status_style = "rgb(255,170,0)"
|
|
593
|
+
else:
|
|
594
|
+
status_text = "Не потянет"
|
|
595
|
+
status_style = "bold red"
|
|
596
|
+
|
|
597
|
+
row_content = f" {idx_str} {disp_name:<38} {size_str:>8} {vram_str:>18} "
|
|
598
|
+
|
|
599
|
+
if is_cursor:
|
|
600
|
+
# Подсветка строки курсора
|
|
601
|
+
t.append(row_content, style="bold black on bright_white")
|
|
602
|
+
t.append(f"{status_text:<18}\n", style=f"{status_style} on bright_white")
|
|
603
|
+
|
|
604
|
+
desc = item["note"]
|
|
605
|
+
t.append(f" ▸ {desc}\n", style="cyan")
|
|
606
|
+
if fit == "orange" and not is_installed and not is_downloading and not is_queued:
|
|
607
|
+
t.append(
|
|
608
|
+
f" • Потребует ~{vram_min:.1f} GB (VRAM доступно ~{effective_vram:.0f} GB) — часть уйдет в оперативную память (RAM)\n",
|
|
609
|
+
style="rgb(255,170,0)"
|
|
610
|
+
)
|
|
611
|
+
elif fit == "red" and not is_installed and not is_downloading and not is_queued:
|
|
612
|
+
t.append(
|
|
613
|
+
f" ! Не рекомендуется: модели нужно ~{vram_min:.1f} GB, на ПК доступно ~{ram_gb:.0f} GB памяти\n",
|
|
614
|
+
style="bold red"
|
|
615
|
+
)
|
|
616
|
+
else:
|
|
617
|
+
t.append(row_content)
|
|
618
|
+
t.append(f"{status_text:<18}\n", style=status_style)
|
|
619
|
+
|
|
620
|
+
# Индикатор скрытых строк снизу
|
|
621
|
+
if visible_end < total:
|
|
622
|
+
hidden_below = total - visible_end
|
|
623
|
+
t.append(f" ▼ ... еще {hidden_below} ниже (листайте ↓) ...\n", style="dim cyan")
|
|
624
|
+
|
|
625
|
+
# Подвал
|
|
626
|
+
t.append(" " + chr(0x2500) * 86 + "\n", style="dim")
|
|
627
|
+
|
|
628
|
+
active_cnt, pending_cnt = GLOBAL_PULL_MANAGER.active_and_pending_count()
|
|
629
|
+
if active_cnt > 0 or pending_cnt > 0:
|
|
630
|
+
t.append(f" ▶ Загрузка: {active_cnt} качается | В очереди: {pending_cnt} ", style="bold cyan")
|
|
631
|
+
|
|
632
|
+
if notification:
|
|
633
|
+
t.append(f" {notification}\n", style="bold yellow")
|
|
634
|
+
else:
|
|
635
|
+
t.append("\n")
|
|
636
|
+
|
|
637
|
+
t.append(" Стрелки ↑↓ / PgUp PgDn — выбор Enter — скачать / в очередь q / Esc — выход\n", style="dim")
|
|
638
|
+
return t
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
# Скачивание отдельной модели (для вызова через CLI / команды)
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
def pull_model(model_name: str) -> bool:
|
|
646
|
+
clean_name = model_name.strip()
|
|
647
|
+
if not clean_name:
|
|
648
|
+
console.print("[red]Имя модели не может быть пустым.[/red]")
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
console.print()
|
|
652
|
+
console.print(f"[cyan]Загрузка:[/cyan] [bold]{clean_name}[/bold]")
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
with Progress(
|
|
656
|
+
SpinnerColumn(),
|
|
657
|
+
TextColumn("[bold cyan]{task.fields[model_name]}[/bold cyan]"),
|
|
658
|
+
BarColumn(bar_width=36),
|
|
659
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
660
|
+
DownloadColumn(),
|
|
661
|
+
TransferSpeedColumn(),
|
|
662
|
+
TimeRemainingColumn(),
|
|
663
|
+
TextColumn("[dim]{task.description}[/dim]"),
|
|
664
|
+
console=console,
|
|
665
|
+
) as progress:
|
|
666
|
+
task_id = progress.add_task(
|
|
667
|
+
description="Подключение...", total=100, completed=0,
|
|
668
|
+
model_name=clean_name,
|
|
669
|
+
)
|
|
670
|
+
for chunk in pull(clean_name, stream=True):
|
|
671
|
+
status = getattr(chunk, "status", "") or (chunk.get("status", "") if isinstance(chunk, dict) else "")
|
|
672
|
+
completed = getattr(chunk, "completed", 0) or (chunk.get("completed", 0) if isinstance(chunk, dict) else 0)
|
|
673
|
+
total = getattr(chunk, "total", 0) or (chunk.get("total", 0) if isinstance(chunk, dict) else 0)
|
|
674
|
+
if total > 0:
|
|
675
|
+
progress.update(task_id, total=total, completed=completed, description=status)
|
|
676
|
+
else:
|
|
677
|
+
progress.update(task_id, description=status)
|
|
678
|
+
|
|
679
|
+
GLOBAL_PULL_MANAGER.refresh_installed()
|
|
680
|
+
console.print(f"[bold green]OK -- {clean_name} готова к работе[/bold green]\n")
|
|
681
|
+
return True
|
|
682
|
+
|
|
683
|
+
except KeyboardInterrupt:
|
|
684
|
+
console.print(f"\n[yellow]Загрузка {clean_name} прервана.[/yellow]")
|
|
685
|
+
return False
|
|
686
|
+
except Exception as error:
|
|
687
|
+
console.print(f"\n[red]Ошибка: {error}[/red]")
|
|
688
|
+
return False
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
# ---------------------------------------------------------------------------
|
|
692
|
+
# Очередь загрузки (для CLI w1x pull model1 model2 ...)
|
|
693
|
+
# ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
class PullQueue:
|
|
696
|
+
def __init__(self):
|
|
697
|
+
self._queue: List[str] = []
|
|
698
|
+
self._lock = threading.Lock()
|
|
699
|
+
|
|
700
|
+
def add(self, model_names: List[str]):
|
|
701
|
+
with self._lock:
|
|
702
|
+
for name in model_names:
|
|
703
|
+
clean = name.strip()
|
|
704
|
+
if clean and clean not in self._queue:
|
|
705
|
+
self._queue.append(clean)
|
|
706
|
+
|
|
707
|
+
def is_empty(self) -> bool:
|
|
708
|
+
with self._lock:
|
|
709
|
+
return len(self._queue) == 0
|
|
710
|
+
|
|
711
|
+
def pending_list(self) -> List[str]:
|
|
712
|
+
with self._lock:
|
|
713
|
+
return list(self._queue)
|
|
714
|
+
|
|
715
|
+
def run_all(self):
|
|
716
|
+
if self.is_empty():
|
|
717
|
+
console.print("[yellow]Очередь пуста.[/yellow]")
|
|
718
|
+
return
|
|
719
|
+
with self._lock:
|
|
720
|
+
total = len(self._queue)
|
|
721
|
+
items = list(self._queue)
|
|
722
|
+
console.print(f"\n[cyan]Очередь загрузки:[/cyan] {total} моделей")
|
|
723
|
+
for n in items:
|
|
724
|
+
console.print(f" - {n}")
|
|
725
|
+
console.print()
|
|
726
|
+
done = 0
|
|
727
|
+
while True:
|
|
728
|
+
current = None
|
|
729
|
+
with self._lock:
|
|
730
|
+
if self._queue:
|
|
731
|
+
current = self._queue.pop(0)
|
|
732
|
+
if current is None:
|
|
733
|
+
break
|
|
734
|
+
done += 1
|
|
735
|
+
console.print(f"[cyan]▶ [{done}/{total}][/cyan] {current}")
|
|
736
|
+
pull_model(current)
|
|
737
|
+
console.print(f"[bold green]Готово: {done}/{total} скачано[/bold green]\n")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
GLOBAL_PULL_QUEUE = PullQueue()
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
# Кроссплатформенное считывание клавиш (Windows / Linux / macOS)
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
def _poll_key_windows():
|
|
748
|
+
import msvcrt
|
|
749
|
+
if not msvcrt.kbhit():
|
|
750
|
+
return None
|
|
751
|
+
key = msvcrt.getch()
|
|
752
|
+
if key in (b"\x00", b"\xe0"):
|
|
753
|
+
ext = msvcrt.getch()
|
|
754
|
+
if ext == b"H": return "UP"
|
|
755
|
+
if ext == b"P": return "DOWN"
|
|
756
|
+
if ext == b"I": return "PGUP"
|
|
757
|
+
if ext == b"Q": return "PGDN"
|
|
758
|
+
if ext == b"G": return "HOME"
|
|
759
|
+
if ext == b"O": return "END"
|
|
760
|
+
return None
|
|
761
|
+
if key in (b"\r", b"\n"): return "ENTER"
|
|
762
|
+
if key in (b"q", b"Q", b"\x1b"): return "EXIT"
|
|
763
|
+
if key.isdigit(): return key.decode("ascii", errors="ignore")
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _poll_key_unix():
|
|
768
|
+
import select, termios, tty
|
|
769
|
+
fd = sys.stdin.fileno()
|
|
770
|
+
old_settings = termios.tcgetattr(fd)
|
|
771
|
+
try:
|
|
772
|
+
tty.setraw(fd)
|
|
773
|
+
rlist, _, _ = select.select([sys.stdin], [], [], 0.04)
|
|
774
|
+
if not rlist:
|
|
775
|
+
return None
|
|
776
|
+
ch = sys.stdin.read(1)
|
|
777
|
+
if ch == "\x1b":
|
|
778
|
+
r2, _, _ = select.select([sys.stdin], [], [], 0.04)
|
|
779
|
+
if r2:
|
|
780
|
+
ch2 = sys.stdin.read(1)
|
|
781
|
+
if ch2 == "[":
|
|
782
|
+
ch3 = sys.stdin.read(1)
|
|
783
|
+
if ch3 == "A": return "UP"
|
|
784
|
+
if ch3 == "B": return "DOWN"
|
|
785
|
+
if ch3 == "5":
|
|
786
|
+
sys.stdin.read(1)
|
|
787
|
+
return "PGUP"
|
|
788
|
+
if ch3 == "6":
|
|
789
|
+
sys.stdin.read(1)
|
|
790
|
+
return "PGDN"
|
|
791
|
+
if ch3 == "H": return "HOME"
|
|
792
|
+
if ch3 == "F": return "END"
|
|
793
|
+
return "EXIT"
|
|
794
|
+
if ch in ("\r", "\n"): return "ENTER"
|
|
795
|
+
if ch in ("q", "Q"): return "EXIT"
|
|
796
|
+
if ch.isdigit(): return ch
|
|
797
|
+
return None
|
|
798
|
+
finally:
|
|
799
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def poll_key_crossplatform():
|
|
803
|
+
if os.name == "nt":
|
|
804
|
+
return _poll_key_windows()
|
|
805
|
+
return _poll_key_unix()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
# ---------------------------------------------------------------------------
|
|
809
|
+
# Интерактивный каталог с окном прокрутки (Viewport)
|
|
810
|
+
# ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
def interactive_model_catalog() -> Optional[str]:
|
|
813
|
+
hw = detect_hardware()
|
|
814
|
+
items = MODEL_CATALOG
|
|
815
|
+
cursor = 0
|
|
816
|
+
scroll_offset = 0
|
|
817
|
+
total = len(items)
|
|
818
|
+
notification = ""
|
|
819
|
+
last_selected_model = None
|
|
820
|
+
|
|
821
|
+
term_height = shutil.get_terminal_size((80, 25)).lines
|
|
822
|
+
page_size = max(6, min(12, term_height - 11))
|
|
823
|
+
|
|
824
|
+
console.print()
|
|
825
|
+
with Live(_render_catalog(items, cursor, scroll_offset, page_size, hw, notification),
|
|
826
|
+
console=console, auto_refresh=False, transient=True) as live:
|
|
827
|
+
while True:
|
|
828
|
+
# Следование камеры за курсором
|
|
829
|
+
if cursor < scroll_offset:
|
|
830
|
+
scroll_offset = cursor
|
|
831
|
+
elif cursor >= scroll_offset + page_size:
|
|
832
|
+
scroll_offset = cursor - page_size + 1
|
|
833
|
+
|
|
834
|
+
live.update(_render_catalog(items, cursor, scroll_offset, page_size, hw, notification), refresh=True)
|
|
835
|
+
|
|
836
|
+
action = poll_key_crossplatform()
|
|
837
|
+
if action:
|
|
838
|
+
notification = ""
|
|
839
|
+
if action == "UP":
|
|
840
|
+
cursor = (cursor - 1) % total
|
|
841
|
+
elif action == "DOWN":
|
|
842
|
+
cursor = (cursor + 1) % total
|
|
843
|
+
elif action == "PGUP":
|
|
844
|
+
cursor = max(0, cursor - page_size)
|
|
845
|
+
elif action == "PGDN":
|
|
846
|
+
cursor = min(total - 1, cursor + page_size)
|
|
847
|
+
elif action == "HOME":
|
|
848
|
+
cursor = 0
|
|
849
|
+
elif action == "END":
|
|
850
|
+
cursor = total - 1
|
|
851
|
+
elif action == "ENTER":
|
|
852
|
+
chosen = items[cursor]
|
|
853
|
+
if chosen.get("category") == "custom":
|
|
854
|
+
live.stop()
|
|
855
|
+
try:
|
|
856
|
+
raw = session.prompt(
|
|
857
|
+
HTML("<b><ansigreen>Тег модели из Ollama › </ansigreen></b>")
|
|
858
|
+
).strip()
|
|
859
|
+
except (KeyboardInterrupt, EOFError):
|
|
860
|
+
raw = ""
|
|
861
|
+
if raw:
|
|
862
|
+
res = GLOBAL_PULL_MANAGER.enqueue(raw)
|
|
863
|
+
last_selected_model = raw
|
|
864
|
+
notification = f"Добавлена в загрузку: {raw}"
|
|
865
|
+
live.start()
|
|
866
|
+
else:
|
|
867
|
+
model_name = chosen["name"]
|
|
868
|
+
fit = classify_model_fit(chosen["vram_min"], chosen["size_gb"], hw)
|
|
869
|
+
|
|
870
|
+
if fit == "red":
|
|
871
|
+
live.stop()
|
|
872
|
+
console.print()
|
|
873
|
+
console.print(
|
|
874
|
+
f"[bold red]⚠️ Внимание:[/bold red] Модель [bold]{model_name}[/bold] требует ~{chosen['vram_min']:.1f} GB памяти.\n"
|
|
875
|
+
f"На вашем ПК доступно ~{hw['vram_gb']:.0f} GB VRAM + {hw['ram_gb']:.0f} GB RAM.\n"
|
|
876
|
+
"[yellow]Модель может вызвать зависание системы или не запуститься.[/yellow]"
|
|
877
|
+
)
|
|
878
|
+
try:
|
|
879
|
+
ans = session.prompt(
|
|
880
|
+
HTML("<b><ansired>Всё равно скачать? (y/n) › </ansired></b>")
|
|
881
|
+
).strip().lower()
|
|
882
|
+
except (KeyboardInterrupt, EOFError):
|
|
883
|
+
ans = "n"
|
|
884
|
+
live.start()
|
|
885
|
+
if ans not in ("y", "yes", "д", "да"):
|
|
886
|
+
notification = f"Загрузка {model_name} отменена"
|
|
887
|
+
continue
|
|
888
|
+
|
|
889
|
+
last_selected_model = model_name
|
|
890
|
+
res = GLOBAL_PULL_MANAGER.enqueue(model_name)
|
|
891
|
+
if res == "already_installed":
|
|
892
|
+
notification = f"✓ {model_name} уже установлена"
|
|
893
|
+
elif res == "already_in_progress":
|
|
894
|
+
notification = f"⏳ {model_name} уже качается или в очереди"
|
|
895
|
+
elif res == "enqueued":
|
|
896
|
+
notification = f"▶ {model_name} добавлена в очередь загрузки"
|
|
897
|
+
|
|
898
|
+
elif action == "EXIT":
|
|
899
|
+
break
|
|
900
|
+
elif action.isdigit():
|
|
901
|
+
n = int(action)
|
|
902
|
+
if 1 <= n <= total:
|
|
903
|
+
cursor = n - 1
|
|
904
|
+
|
|
905
|
+
time.sleep(0.04)
|
|
906
|
+
|
|
907
|
+
return last_selected_model
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# ---------------------------------------------------------------------------
|
|
911
|
+
# Мастер первого запуска
|
|
912
|
+
# ---------------------------------------------------------------------------
|
|
913
|
+
|
|
914
|
+
def first_time_setup_wizard() -> Optional[str]:
|
|
915
|
+
console.print()
|
|
916
|
+
console.print(
|
|
917
|
+
Panel(
|
|
918
|
+
"[bold]Добро пожаловать в W1x![/bold]\n"
|
|
919
|
+
"В вашей Ollama пока нет моделей.\n"
|
|
920
|
+
"Выберите стартовую модель -- W1x скачает её и сохранит как модель по умолчанию.",
|
|
921
|
+
title="Первый запуск W1x",
|
|
922
|
+
border_style="green",
|
|
923
|
+
padding=(1, 2),
|
|
924
|
+
)
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
selected = interactive_model_catalog()
|
|
928
|
+
if not selected:
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
# Ожидаем завершения загрузки если она запущена через менеджер
|
|
932
|
+
task = GLOBAL_PULL_MANAGER.get_task(selected)
|
|
933
|
+
if task and task.status in ("queued", "downloading"):
|
|
934
|
+
console.print(f"\n[cyan]Ожидание завершения загрузки {selected}...[/cyan]")
|
|
935
|
+
while task.status in ("queued", "downloading"):
|
|
936
|
+
time.sleep(0.5)
|
|
937
|
+
|
|
938
|
+
if selected:
|
|
939
|
+
from .config import load_user_config, save_user_config
|
|
940
|
+
cfg = load_user_config()
|
|
941
|
+
cfg["default_model"] = selected
|
|
942
|
+
save_user_config(cfg)
|
|
943
|
+
console.print(
|
|
944
|
+
Panel(
|
|
945
|
+
f"[bold green]{selected}[/bold green] установлена как модель по умолчанию.\n"
|
|
946
|
+
f"Контекст: {cfg.get('context_tokens', 8192)} токенов "
|
|
947
|
+
f"VRAM keep-alive: {cfg.get('keep_alive_minutes', 30)} мин.\n\n"
|
|
948
|
+
"[dim]Настройки: /config[/dim]",
|
|
949
|
+
title="W1x готов",
|
|
950
|
+
border_style="green",
|
|
951
|
+
padding=(1, 2),
|
|
952
|
+
)
|
|
953
|
+
)
|
|
954
|
+
return selected
|
|
955
|
+
return None
|