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/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