dragon-cli 0.2.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.
- dragon_cli/__init__.py +5 -0
- dragon_cli/__main__.py +4 -0
- dragon_cli/_cuda.py +42 -0
- dragon_cli/app.py +123 -0
- dragon_cli/banner.py +96 -0
- dragon_cli/chat/__init__.py +4 -0
- dragon_cli/chat/commands.py +175 -0
- dragon_cli/chat/session.py +97 -0
- dragon_cli/chat/streaming.py +18 -0
- dragon_cli/cli.py +147 -0
- dragon_cli/config.py +28 -0
- dragon_cli/hardware.py +361 -0
- dragon_cli/mcp/__init__.py +10 -0
- dragon_cli/mcp/manager.py +148 -0
- dragon_cli/mcp/tool_call.py +17 -0
- dragon_cli/menu.py +1140 -0
- dragon_cli/model_hub.py +252 -0
- dragon_cli/models/__init__.py +11 -0
- dragon_cli/models/catalog.py +205 -0
- dragon_cli/models/loader.py +187 -0
- dragon_cli/recommendation.py +295 -0
- dragon_cli/runtime.py +252 -0
- dragon_cli/server.py +144 -0
- dragon_cli/setup_llm.py +197 -0
- dragon_cli/storage/__init__.py +3 -0
- dragon_cli/storage/conversations.py +134 -0
- dragon_cli/storage/database.py +32 -0
- dragon_cli/web.py +112 -0
- dragon_cli-0.2.0.dist-info/METADATA +147 -0
- dragon_cli-0.2.0.dist-info/RECORD +34 -0
- dragon_cli-0.2.0.dist-info/WHEEL +5 -0
- dragon_cli-0.2.0.dist-info/entry_points.txt +3 -0
- dragon_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- dragon_cli-0.2.0.dist-info/top_level.txt +1 -0
dragon_cli/menu.py
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
"""Interactive, fancy (rich-based) main menu for Dragon CLI.
|
|
2
|
+
|
|
3
|
+
This module is a *presentation layer*: a modern, keyboard-navigable menu that
|
|
4
|
+
surfaces the local-LLM toolkit — chat, model listing, hardware-based
|
|
5
|
+
recommendations, the model hub, conversation history, and the HTTP server.
|
|
6
|
+
|
|
7
|
+
It deliberately stays a thin shell over the existing, fully-working modules
|
|
8
|
+
(``hardware``, ``recommendation``, ``runtime``, ``model_hub``, ``models``,
|
|
9
|
+
``chat``, ``storage``, ``app``). Everything the menu does can still be driven
|
|
10
|
+
from plain argparse flags (``dragon-cli --serve``, etc.), so the menu never
|
|
11
|
+
becomes a second, divergent code path.
|
|
12
|
+
|
|
13
|
+
Navigation:
|
|
14
|
+
* Arrow keys (Up/Down) ........ move the highlight
|
|
15
|
+
* Enter ........................ select the highlighted option
|
|
16
|
+
* 1..7 ........................ jump straight to an option
|
|
17
|
+
* / ........................... open the quick-command line
|
|
18
|
+
* r ........................... refresh the hardware profile
|
|
19
|
+
* q / Esc ..................... quit
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import copy
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from urllib.parse import unquote, urlparse
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from rich.align import Align
|
|
34
|
+
from rich.console import Console, Group
|
|
35
|
+
from rich.live import Live
|
|
36
|
+
from rich.panel import Panel
|
|
37
|
+
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
38
|
+
from rich.table import Table
|
|
39
|
+
from rich.text import Text
|
|
40
|
+
|
|
41
|
+
from . import __version__, app
|
|
42
|
+
from .config import DB_FILE, MODELS_DIR
|
|
43
|
+
from .hardware import detect_hardware
|
|
44
|
+
from .models import MODEL_CATALOG, add_model_to_catalog
|
|
45
|
+
from .recommendation import ModelRecommender
|
|
46
|
+
from .runtime import RuntimeConfig
|
|
47
|
+
|
|
48
|
+
# Palette — kept in one place so the look stays consistent. These are rich
|
|
49
|
+
# style/color fragments used inside markup, e.g. f"[{ACCENT}]text[/]".
|
|
50
|
+
ACCENT = "bright_cyan"
|
|
51
|
+
DIM = "grey58"
|
|
52
|
+
GOOD = "bright_green"
|
|
53
|
+
WARN = "bright_yellow"
|
|
54
|
+
BAD = "bright_red"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
# Keyboard input (POSIX + Windows) #
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
def _read_key() -> str:
|
|
61
|
+
"""Read a single keypress, decoding arrow-key escape sequences.
|
|
62
|
+
|
|
63
|
+
Returns canonical tokens: ``"up"``, ``"down"``, ``"left"``, ``"right"``,
|
|
64
|
+
``"enter"``, ``"esc"``, or the literal character that was pressed.
|
|
65
|
+
"""
|
|
66
|
+
# Windows: use the console API.
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
try:
|
|
69
|
+
import msvcrt
|
|
70
|
+
|
|
71
|
+
ch = msvcrt.getwch()
|
|
72
|
+
if ch in ("\x00", "\xe0"): # special-key prefix
|
|
73
|
+
code = msvcrt.getwch()
|
|
74
|
+
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(code, ch)
|
|
75
|
+
if ch in ("\r", "\n"):
|
|
76
|
+
return "enter"
|
|
77
|
+
return ch
|
|
78
|
+
except Exception:
|
|
79
|
+
return "enter"
|
|
80
|
+
|
|
81
|
+
# POSIX: switch the tty to cbreak for a raw read, then restore.
|
|
82
|
+
import select
|
|
83
|
+
import termios
|
|
84
|
+
import tty
|
|
85
|
+
|
|
86
|
+
fd = sys.stdin.fileno()
|
|
87
|
+
if not sys.stdin.isatty():
|
|
88
|
+
# Non-interactive stdin (piped/redirected): read a whole line and
|
|
89
|
+
# collapse it to a single canonical key so the menu stays usable.
|
|
90
|
+
line = sys.stdin.readline()
|
|
91
|
+
if not line: # EOF (e.g. Ctrl-D, or a closed pipe)
|
|
92
|
+
return "esc"
|
|
93
|
+
line = line.strip().lower()
|
|
94
|
+
if not line:
|
|
95
|
+
return "enter"
|
|
96
|
+
return line[0]
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
old = termios.tcgetattr(fd)
|
|
100
|
+
except termios.error:
|
|
101
|
+
return "esc" # stdin stopped being a tty mid-session: bail out
|
|
102
|
+
try:
|
|
103
|
+
tty.setcbreak(fd)
|
|
104
|
+
ch = sys.stdin.read(1)
|
|
105
|
+
if not ch: # EOF on the tty
|
|
106
|
+
return "esc"
|
|
107
|
+
if ch == "\x1b":
|
|
108
|
+
# Either a bare Esc or an escape sequence like ``\x1b[A`` (up).
|
|
109
|
+
# Probe for more bytes without blocking forever.
|
|
110
|
+
more: List[str] = []
|
|
111
|
+
for _ in range(2):
|
|
112
|
+
r, _, _ = select.select([fd], [], [], 0.05)
|
|
113
|
+
if not r:
|
|
114
|
+
break
|
|
115
|
+
more.append(sys.stdin.read(1))
|
|
116
|
+
if more and more[0] == "[":
|
|
117
|
+
return {"A": "up", "B": "down", "C": "right", "D": "left"}.get(
|
|
118
|
+
more[1] if len(more) > 1 else "", "esc"
|
|
119
|
+
)
|
|
120
|
+
return "esc"
|
|
121
|
+
if ch in ("\r", "\n"):
|
|
122
|
+
return "enter"
|
|
123
|
+
return ch
|
|
124
|
+
finally:
|
|
125
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# Menu model #
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
@dataclass
|
|
132
|
+
class MenuOption:
|
|
133
|
+
key: str # numeric shortcut, e.g. "1"
|
|
134
|
+
label: str # menu title
|
|
135
|
+
description: str # one-line help text
|
|
136
|
+
handler: Callable[[], bool] # False => quit the menu, True => stay
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class InteractiveMenu:
|
|
140
|
+
"""Owns the menu loop, cached hardware profile, and all action handlers."""
|
|
141
|
+
|
|
142
|
+
# (key, label, description) — handler is bound by attribute name below.
|
|
143
|
+
MENU_ITEMS: List[Tuple[str, str, str]] = [
|
|
144
|
+
("1", "Chat with model", "Talk to a local model with streaming"),
|
|
145
|
+
("2", "List models", "Browse local and remote models in the catalog"),
|
|
146
|
+
("3", "Auto recommendation", "Pick the best model for your hardware"),
|
|
147
|
+
("4", "Connect to hub", "Search and download models from Hugging Face"),
|
|
148
|
+
("5", "Manage history", "List, resume, export saved conversations"),
|
|
149
|
+
("6", "Serve HTTP", "Expose the model via an OpenAI-compatible API"),
|
|
150
|
+
("7", "Manage MCP servers", "Add, remove, and test MCP connectors"),
|
|
151
|
+
("8", "Quit", "Exit Dragon CLI"),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
# Slash shortcuts -> option key (or a special target).
|
|
155
|
+
QUICK_COMMANDS: Dict[str, str] = {
|
|
156
|
+
"/chat": "1", "/models": "2", "/rec": "3", "/recommend": "3",
|
|
157
|
+
"/hub": "4", "/history": "5", "/serve": "6", "/mcp": "7",
|
|
158
|
+
"/quit": "8", "/exit": "8", "/profile": "p", "/refresh": "r",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def __init__(self, args: Any, console: Optional[Console] = None) -> None:
|
|
162
|
+
self.args = args
|
|
163
|
+
self.console = console or Console()
|
|
164
|
+
self.hardware: Dict[str, Any] = detect_hardware()
|
|
165
|
+
self.recommender = ModelRecommender(self.hardware)
|
|
166
|
+
self.runtime = RuntimeConfig(hardware_profile=self.hardware)
|
|
167
|
+
self.selected = 0
|
|
168
|
+
|
|
169
|
+
# -- small helpers ------------------------------------------------------ #
|
|
170
|
+
def _pause(self, msg: str = "[Enter to return to menu]") -> None:
|
|
171
|
+
"""Block until Enter, used between an action and returning to the menu."""
|
|
172
|
+
self.console.input(f"[{DIM}]{msg}[/]")
|
|
173
|
+
|
|
174
|
+
def _splash(self) -> None:
|
|
175
|
+
"""Show the mascot icon + title once, before the live menu starts."""
|
|
176
|
+
from .banner import ICON
|
|
177
|
+
|
|
178
|
+
lines = ICON.splitlines()
|
|
179
|
+
art_width = max((len(ln) for ln in lines), default=0)
|
|
180
|
+
# Block-center: pad every line by the same amount so the picture keeps
|
|
181
|
+
# its shape (rich's Align.center would mangle multi-line ASCII art).
|
|
182
|
+
pad = max(0, (self.console.width - art_width) // 2)
|
|
183
|
+
art = "\n".join(" " * pad + ln for ln in lines)
|
|
184
|
+
self.console.print(Text(art, style=ACCENT))
|
|
185
|
+
self.console.print(Align.center(Text.assemble(
|
|
186
|
+
(" DRAGON CLI ", f"bold white on {ACCENT}"),
|
|
187
|
+
(f" v{__version__} ", DIM),
|
|
188
|
+
(" 100% local LLM + MCP ", f"bold {GOOD}"),
|
|
189
|
+
)))
|
|
190
|
+
self.console.print()
|
|
191
|
+
|
|
192
|
+
# -- public entry point ------------------------------------------------- #
|
|
193
|
+
def run(self) -> None:
|
|
194
|
+
"""Main loop: show the menu, dispatch actions until the user quits."""
|
|
195
|
+
self._splash()
|
|
196
|
+
self.console.show_cursor(False)
|
|
197
|
+
try:
|
|
198
|
+
while True:
|
|
199
|
+
choice = self._main_menu()
|
|
200
|
+
if choice is None: # user pressed q / Esc
|
|
201
|
+
break
|
|
202
|
+
if choice.handler() is False:
|
|
203
|
+
break
|
|
204
|
+
except (KeyboardInterrupt, EOFError):
|
|
205
|
+
pass
|
|
206
|
+
finally:
|
|
207
|
+
self.console.show_cursor(True)
|
|
208
|
+
self.console.print(f"\n[{ACCENT}]Dragon asleep.[/] See you next time!\n")
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------ #
|
|
211
|
+
# Rendering #
|
|
212
|
+
# ------------------------------------------------------------------ #
|
|
213
|
+
def _header(self) -> Panel:
|
|
214
|
+
title = Text()
|
|
215
|
+
title.append(" DRAGON CLI ", style=f"bold white on {ACCENT}")
|
|
216
|
+
title.append(f" v{__version__} ", style=DIM)
|
|
217
|
+
title.append(" 100% local ", style=f"bold {GOOD}")
|
|
218
|
+
return Panel(Align.center(title), border_style=ACCENT, padding=(0, 1))
|
|
219
|
+
|
|
220
|
+
def _status_grid(self) -> Table:
|
|
221
|
+
hw = self.hardware
|
|
222
|
+
grid = Table.grid(padding=(0, 2))
|
|
223
|
+
grid.add_column(style=f"bold {DIM}", no_wrap=True)
|
|
224
|
+
grid.add_column()
|
|
225
|
+
|
|
226
|
+
backend = self.runtime.decide_execution_mode().upper()
|
|
227
|
+
backend_style = GOOD if backend == "GPU" else (WARN if backend == "HYBRID" else ACCENT)
|
|
228
|
+
|
|
229
|
+
grid.add_row("System", str(hw.get("os", "?")))
|
|
230
|
+
grid.add_row("CPU", f"{hw.get('cpu_model', '?')} ({hw.get('cpu_count', '?')} cores)")
|
|
231
|
+
|
|
232
|
+
ram_total = hw.get("ram_total_gb", 0) or 0
|
|
233
|
+
ram_avail = hw.get("ram_available_gb", 0) or 0
|
|
234
|
+
grid.add_row("RAM", f"{ram_avail:.1f} GB free / {ram_total:.1f} GB total")
|
|
235
|
+
|
|
236
|
+
if hw.get("gpu_available"):
|
|
237
|
+
grid.add_row(
|
|
238
|
+
"GPU",
|
|
239
|
+
Text(f"{hw.get('gpu_name', '?')} ({hw.get('gpu_vram_gb', 0):.1f} GB VRAM)", style=GOOD),
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
grid.add_row("GPU", Text("not detected (CPU mode)", style=DIM))
|
|
243
|
+
|
|
244
|
+
grid.add_row("Backend", Text(backend, style=backend_style))
|
|
245
|
+
|
|
246
|
+
best = self.recommender.recommend_best()
|
|
247
|
+
if best:
|
|
248
|
+
score = best.get("compatibility_score", 0)
|
|
249
|
+
grid.add_row("Recommended", f"{best['name']} [{DIM}]({score:.0%})[/]")
|
|
250
|
+
else:
|
|
251
|
+
grid.add_row("Recommended", f"[{DIM}]none compatible[/]")
|
|
252
|
+
return grid
|
|
253
|
+
|
|
254
|
+
def _status_panel(self) -> Panel:
|
|
255
|
+
return Panel(
|
|
256
|
+
self._status_grid(),
|
|
257
|
+
title=f"[bold {ACCENT}]System status[/]",
|
|
258
|
+
border_style=DIM,
|
|
259
|
+
padding=(1, 2),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _menu_panel(self) -> Panel:
|
|
263
|
+
table = Table.grid(padding=(0, 2))
|
|
264
|
+
table.add_column(style="bold", width=3)
|
|
265
|
+
table.add_column(no_wrap=True)
|
|
266
|
+
table.add_column()
|
|
267
|
+
|
|
268
|
+
for i, (key, label, desc) in enumerate(self.MENU_ITEMS):
|
|
269
|
+
if i == self.selected:
|
|
270
|
+
marker = f"[bold {ACCENT}]›[/]"
|
|
271
|
+
label_txt = f"[bold white on {ACCENT}] {label} [/]"
|
|
272
|
+
desc_txt = f"[{DIM}]{desc}[/]"
|
|
273
|
+
else:
|
|
274
|
+
marker = " "
|
|
275
|
+
label_txt = f"[bold]{label}[/]"
|
|
276
|
+
desc_txt = f"[{DIM}]{desc}[/]"
|
|
277
|
+
table.add_row(f"[{DIM}]{key}[/]", f"{marker} {label_txt}", desc_txt)
|
|
278
|
+
return Panel(
|
|
279
|
+
Align.center(table),
|
|
280
|
+
title=f"[bold {ACCENT}]Main menu[/]",
|
|
281
|
+
border_style=ACCENT,
|
|
282
|
+
padding=(1, 2),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _footer(self) -> Text:
|
|
286
|
+
return Align.center(Text.assemble(
|
|
287
|
+
("Arrows", f"bold {ACCENT}"), (" move ", DIM),
|
|
288
|
+
("Enter", f"bold {ACCENT}"), (" select ", DIM),
|
|
289
|
+
("1-8", f"bold {ACCENT}"), (" shortcut ", DIM),
|
|
290
|
+
("/", f"bold {ACCENT}"), (" commands ", DIM),
|
|
291
|
+
("r", f"bold {ACCENT}"), (" refresh ", DIM),
|
|
292
|
+
("q", f"bold {ACCENT}"), (" quit", DIM),
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
def _frame(self) -> Group:
|
|
296
|
+
return Group(
|
|
297
|
+
self._header(),
|
|
298
|
+
Text(), # spacer
|
|
299
|
+
self._status_panel(),
|
|
300
|
+
Text(),
|
|
301
|
+
self._menu_panel(),
|
|
302
|
+
Text(),
|
|
303
|
+
self._footer(),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _main_menu(self) -> Optional[MenuOption]:
|
|
307
|
+
"""Render the live menu and return the option the user picked."""
|
|
308
|
+
options = [
|
|
309
|
+
MenuOption(k, l, d, getattr(self, f"_action_{k}"))
|
|
310
|
+
for k, l, d in self.MENU_ITEMS
|
|
311
|
+
]
|
|
312
|
+
self.selected = max(0, min(self.selected, len(options) - 1))
|
|
313
|
+
with Live(self._frame(), console=self.console, refresh_per_second=12, screen=False) as live:
|
|
314
|
+
while True:
|
|
315
|
+
key = _read_key()
|
|
316
|
+
if key in ("up", "k"):
|
|
317
|
+
self.selected = (self.selected - 1) % len(options)
|
|
318
|
+
elif key in ("down", "j"):
|
|
319
|
+
self.selected = (self.selected + 1) % len(options)
|
|
320
|
+
elif key == "enter":
|
|
321
|
+
return options[self.selected]
|
|
322
|
+
elif key in ("q", "Q", "esc"):
|
|
323
|
+
return None
|
|
324
|
+
elif key == "r":
|
|
325
|
+
self._refresh()
|
|
326
|
+
elif key == "/":
|
|
327
|
+
target = self._quick_command()
|
|
328
|
+
if target in (None, "p", "r"):
|
|
329
|
+
if target == "p":
|
|
330
|
+
self._show_profile()
|
|
331
|
+
elif target == "r":
|
|
332
|
+
self._refresh()
|
|
333
|
+
else:
|
|
334
|
+
idx = int(target) - 1
|
|
335
|
+
if 0 <= idx < len(options):
|
|
336
|
+
self.selected = idx
|
|
337
|
+
return options[idx]
|
|
338
|
+
elif key.isdigit() and 1 <= int(key) <= len(options):
|
|
339
|
+
self.selected = int(key) - 1
|
|
340
|
+
return options[self.selected]
|
|
341
|
+
live.update(self._frame())
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------ #
|
|
344
|
+
# Action handlers #
|
|
345
|
+
# ------------------------------------------------------------------ #
|
|
346
|
+
def _refresh(self) -> None:
|
|
347
|
+
"""Re-detect hardware and rebuild the recommender/runtime."""
|
|
348
|
+
self.hardware = detect_hardware()
|
|
349
|
+
self.recommender = ModelRecommender(self.hardware)
|
|
350
|
+
self.runtime = RuntimeConfig(hardware_profile=self.hardware)
|
|
351
|
+
|
|
352
|
+
def _quick_command(self) -> Optional[str]:
|
|
353
|
+
"""Pop a quick-command line and resolve it to a target option key."""
|
|
354
|
+
self.console.show_cursor(True)
|
|
355
|
+
cmd = Prompt.ask(
|
|
356
|
+
f"\n[{ACCENT}]Quick command[/] [{DIM}](/help to list)[/]",
|
|
357
|
+
console=self.console, default="",
|
|
358
|
+
).strip().lower()
|
|
359
|
+
self.console.show_cursor(False)
|
|
360
|
+
if not cmd:
|
|
361
|
+
return None
|
|
362
|
+
if cmd in ("/help", "/?"):
|
|
363
|
+
cmds = " ".join(sorted(self.QUICK_COMMANDS))
|
|
364
|
+
self.console.print(Panel(
|
|
365
|
+
cmds, title=f"[{ACCENT}]Quick commands[/]", border_style=DIM))
|
|
366
|
+
self._pause("[Enter to continue]")
|
|
367
|
+
return None
|
|
368
|
+
target = self.QUICK_COMMANDS.get(cmd)
|
|
369
|
+
if target is None:
|
|
370
|
+
self.console.print(f"[{BAD}]Unknown command:[/] {cmd}")
|
|
371
|
+
self._pause("[Enter to continue]")
|
|
372
|
+
return target
|
|
373
|
+
|
|
374
|
+
def _require_llm(self) -> bool:
|
|
375
|
+
"""Check that llama-cpp-python is importable, else show how to install."""
|
|
376
|
+
try:
|
|
377
|
+
from .models.loader import _preload_cuda_runtime
|
|
378
|
+
_preload_cuda_runtime()
|
|
379
|
+
import llama_cpp # noqa: F401
|
|
380
|
+
return True
|
|
381
|
+
except ImportError:
|
|
382
|
+
self.console.print(Panel(
|
|
383
|
+
"[bold red]llama-cpp-python is not installed.[/]\n\n"
|
|
384
|
+
"Install the inference backend with:\n"
|
|
385
|
+
f" [{ACCENT}]dragon-cli-install-llm[/]\n\n"
|
|
386
|
+
"(detects your OS/GPU and uses the most suitable prebuilt wheel)",
|
|
387
|
+
title=f"[{BAD}]Missing dependency[/]", border_style=BAD))
|
|
388
|
+
self._pause()
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
def _run_app(self, **overrides: Any) -> None:
|
|
392
|
+
"""Run the async ``app.run`` orchestration with a copy of the args."""
|
|
393
|
+
a = copy.copy(self.args)
|
|
394
|
+
for k, v in overrides.items():
|
|
395
|
+
setattr(a, k, v)
|
|
396
|
+
try:
|
|
397
|
+
asyncio.run(app.run(a))
|
|
398
|
+
except (KeyboardInterrupt, EOFError):
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
def _ask_gpu(self) -> Dict[str, Any]:
|
|
402
|
+
"""Ask how to run the model on the backend; return ``_run_app`` overrides.
|
|
403
|
+
|
|
404
|
+
Mirrors the ``--gpu``/``--cpu``/``--n-gpu-layers`` CLI flags. When no
|
|
405
|
+
GPU is detected the choice is skipped and CPU is forced.
|
|
406
|
+
"""
|
|
407
|
+
if not self.hardware.get("gpu_available"):
|
|
408
|
+
return {"cpu": True, "gpu": False, "n_gpu_layers": -1}
|
|
409
|
+
choice = Prompt.ask(
|
|
410
|
+
f"[{ACCENT}]Backend[/] [{DIM}](how to run the model)[/]",
|
|
411
|
+
choices=["auto", "gpu", "cpu"], default="auto", console=self.console,
|
|
412
|
+
)
|
|
413
|
+
return {
|
|
414
|
+
"cpu": choice == "cpu",
|
|
415
|
+
"gpu": choice == "gpu",
|
|
416
|
+
"n_gpu_layers": -1,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# 1 - Chat with model ---------------------------------------------- #
|
|
420
|
+
def _action_1(self) -> bool:
|
|
421
|
+
if not self._require_llm():
|
|
422
|
+
return True
|
|
423
|
+
pick = self._pick_model(title="Choose a model to chat with")
|
|
424
|
+
if pick is None:
|
|
425
|
+
return True
|
|
426
|
+
idx, _model = pick
|
|
427
|
+
self._run_app(serve=False, model=idx, **self._ask_gpu())
|
|
428
|
+
self._pause()
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
# 2 - List models -------------------------------------------------- #
|
|
432
|
+
def _action_2(self) -> bool:
|
|
433
|
+
self._show_models_table()
|
|
434
|
+
self._pause()
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
# 3 - Auto recommendation ------------------------------------------ #
|
|
438
|
+
def _action_3(self) -> bool:
|
|
439
|
+
self._show_recommendations()
|
|
440
|
+
pick = IntPrompt.ask(
|
|
441
|
+
f"\n[{ACCENT}]Use which model number?[/] [{DIM}](0 = back)[/]",
|
|
442
|
+
console=self.console, default=0,
|
|
443
|
+
)
|
|
444
|
+
if pick == 0:
|
|
445
|
+
return True
|
|
446
|
+
if not (1 <= pick <= len(MODEL_CATALOG)):
|
|
447
|
+
self.console.print(f"[{BAD}]Number out of catalog range.[/]")
|
|
448
|
+
self._pause()
|
|
449
|
+
return True
|
|
450
|
+
if not self._require_llm():
|
|
451
|
+
return True
|
|
452
|
+
self._run_app(serve=False, model=pick)
|
|
453
|
+
self._pause()
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
# 4 - Connect to hub ----------------------------------------------- #
|
|
457
|
+
def _action_4(self) -> bool:
|
|
458
|
+
self._show_models_table(title="Connect to hub — catalog")
|
|
459
|
+
choice_raw = Prompt.ask(
|
|
460
|
+
f"\n[{ACCENT}]Hub action[/] [{DIM}](catalog # download, 's' search/add, 'hf://' add direct, Enter = back)[/]",
|
|
461
|
+
console=self.console, default="",
|
|
462
|
+
).strip()
|
|
463
|
+
choice = choice_raw.lower()
|
|
464
|
+
if choice in ("", "0"):
|
|
465
|
+
return True
|
|
466
|
+
if choice in ("s", "search"):
|
|
467
|
+
model = self._hub_search()
|
|
468
|
+
if model:
|
|
469
|
+
idx, created = self._add_model_to_catalog(model)
|
|
470
|
+
action = "Added" if created else "Already in catalog"
|
|
471
|
+
self.console.print(f"[{GOOD}]{action}[/] as catalog #{idx}: {model['name']}")
|
|
472
|
+
if Confirm.ask("Download this model now?", console=self.console, default=True):
|
|
473
|
+
self._download_model_entry(model)
|
|
474
|
+
elif choice.isdigit() and 1 <= int(choice) <= len(MODEL_CATALOG):
|
|
475
|
+
self._download_model(int(choice))
|
|
476
|
+
else:
|
|
477
|
+
model = self._build_model_from_hf_reference(choice_raw)
|
|
478
|
+
if model:
|
|
479
|
+
idx, created = self._add_model_to_catalog(model)
|
|
480
|
+
action = "Added" if created else "Already in catalog"
|
|
481
|
+
self.console.print(f"[{GOOD}]{action}[/] as catalog #{idx}: {model['name']}")
|
|
482
|
+
if Confirm.ask("Download this model now?", console=self.console, default=True):
|
|
483
|
+
self._download_model_entry(model)
|
|
484
|
+
else:
|
|
485
|
+
self.console.print(f"[{BAD}]Invalid option.[/]")
|
|
486
|
+
self._pause()
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
# 5 - Manage history ----------------------------------------------- #
|
|
490
|
+
def _action_5(self) -> bool:
|
|
491
|
+
self._manage_history()
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
# 6 - Serve HTTP --------------------------------------------------- #
|
|
495
|
+
def _action_6(self) -> bool:
|
|
496
|
+
if not self._require_llm():
|
|
497
|
+
return True
|
|
498
|
+
host = Prompt.ask("Host", console=self.console, default=getattr(self.args, "host", "127.0.0.1"))
|
|
499
|
+
port = IntPrompt.ask("Port", console=self.console, default=getattr(self.args, "port", 8000))
|
|
500
|
+
gpu = self._ask_gpu()
|
|
501
|
+
pick = self._pick_model(title=f"Model to serve at http://{host}:{port}/v1")
|
|
502
|
+
if pick is None:
|
|
503
|
+
return True
|
|
504
|
+
idx, _model = pick
|
|
505
|
+
self._run_app(serve=True, model=idx, host=host, port=port, **gpu)
|
|
506
|
+
self._pause()
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
# 7 - Manage MCP servers ------------------------------------------- #
|
|
510
|
+
def _action_7(self) -> bool:
|
|
511
|
+
self._manage_mcp()
|
|
512
|
+
return True
|
|
513
|
+
|
|
514
|
+
# 8 - Quit --------------------------------------------------------- #
|
|
515
|
+
def _action_8(self) -> bool:
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
# ------------------------------------------------------------------ #
|
|
519
|
+
# MCP management #
|
|
520
|
+
# ------------------------------------------------------------------ #
|
|
521
|
+
def _manage_mcp(self) -> None:
|
|
522
|
+
from .mcp import MCP_PRESETS, load_mcp_config, save_mcp_config
|
|
523
|
+
|
|
524
|
+
config = load_mcp_config()
|
|
525
|
+
servers = dict(config.get("mcpServers", {}))
|
|
526
|
+
|
|
527
|
+
while True:
|
|
528
|
+
self.console.print(Panel(
|
|
529
|
+
f"Config file: [{ACCENT}]{MCP_CONFIG_FILE}[/]",
|
|
530
|
+
title=f"[bold {ACCENT}]MCP servers[/]", border_style=ACCENT))
|
|
531
|
+
|
|
532
|
+
if not servers:
|
|
533
|
+
self.console.print(f"[{DIM}]No MCP servers configured.[/]")
|
|
534
|
+
else:
|
|
535
|
+
table = Table(border_style=DIM, expand=True)
|
|
536
|
+
table.add_column("Name", style=f"bold {ACCENT}")
|
|
537
|
+
table.add_column("Command", overflow="fold")
|
|
538
|
+
table.add_column("Args", overflow="fold")
|
|
539
|
+
for name, cfg in servers.items():
|
|
540
|
+
table.add_row(name, cfg.get("command", "?"),
|
|
541
|
+
" ".join(str(a) for a in cfg.get("args", [])))
|
|
542
|
+
self.console.print(table)
|
|
543
|
+
|
|
544
|
+
action = Prompt.ask(
|
|
545
|
+
f"\n[{ACCENT}]Action[/] [{DIM}](add, remove, test, back)[/]",
|
|
546
|
+
console=self.console, default="",
|
|
547
|
+
).strip().lower()
|
|
548
|
+
if not action or action in ("back", "b", "q"):
|
|
549
|
+
return
|
|
550
|
+
if action in ("add", "a"):
|
|
551
|
+
self._mcp_add(servers, MCP_PRESETS)
|
|
552
|
+
save_mcp_config(servers)
|
|
553
|
+
elif action in ("remove", "r", "delete", "d"):
|
|
554
|
+
self._mcp_remove(servers)
|
|
555
|
+
save_mcp_config(servers)
|
|
556
|
+
elif action in ("test", "t"):
|
|
557
|
+
self._mcp_test(servers)
|
|
558
|
+
else:
|
|
559
|
+
self.console.print(f"[{BAD}]Unknown action.[/]")
|
|
560
|
+
self._pause("[Enter to continue]")
|
|
561
|
+
self.console.print()
|
|
562
|
+
|
|
563
|
+
def _mcp_add(self, servers: dict, presets: dict) -> None:
|
|
564
|
+
preset_names = sorted(presets)
|
|
565
|
+
self.console.print(f"[{DIM}]Presets:[/] {', '.join(preset_names)} "
|
|
566
|
+
f"(or 'custom' for your own command)")
|
|
567
|
+
choice = Prompt.ask(
|
|
568
|
+
f"[{ACCENT}]Server to add[/]", console=self.console, default="",
|
|
569
|
+
).strip().lower()
|
|
570
|
+
if not choice:
|
|
571
|
+
return
|
|
572
|
+
if choice in presets:
|
|
573
|
+
name = choice
|
|
574
|
+
if name in servers and not Confirm.ask(
|
|
575
|
+
f"'{name}' already exists. Overwrite with preset?",
|
|
576
|
+
console=self.console, default=False):
|
|
577
|
+
return
|
|
578
|
+
servers[name] = {
|
|
579
|
+
"command": presets[choice]["command"],
|
|
580
|
+
"args": list(presets[choice]["args"]),
|
|
581
|
+
}
|
|
582
|
+
self.console.print(f"[{GOOD}]Added preset '{name}':[/] {presets[choice]['description']}")
|
|
583
|
+
return
|
|
584
|
+
if choice in ("custom", "c"):
|
|
585
|
+
name = Prompt.ask(f"[{ACCENT}]Name for this server[/]", console=self.console).strip()
|
|
586
|
+
if not name:
|
|
587
|
+
return
|
|
588
|
+
if name in servers and not Confirm.ask(
|
|
589
|
+
f"'{name}' exists. Overwrite?", console=self.console, default=False):
|
|
590
|
+
return
|
|
591
|
+
command = Prompt.ask(f"[{ACCENT}]Command[/] [{DIM}](e.g. npx, uvx, python)[/]",
|
|
592
|
+
console=self.console, default="uvx")
|
|
593
|
+
args_raw = Prompt.ask(
|
|
594
|
+
f"[{ACCENT}]Arguments[/] [{DIM}](space-separated)[/]",
|
|
595
|
+
console=self.console, default="",
|
|
596
|
+
)
|
|
597
|
+
servers[name] = {"command": command, "args": args_raw.split()}
|
|
598
|
+
self.console.print(f"[{GOOD}]Added '{name}'.[/]")
|
|
599
|
+
return
|
|
600
|
+
self.console.print(f"[{BAD}]Unknown choice '{choice}'.[/]")
|
|
601
|
+
|
|
602
|
+
def _mcp_remove(self, servers: dict) -> None:
|
|
603
|
+
if not servers:
|
|
604
|
+
self.console.print(f"[{DIM}]Nothing to remove.[/]")
|
|
605
|
+
return
|
|
606
|
+
name = Prompt.ask(
|
|
607
|
+
f"[{ACCENT}]Server name to remove[/] [{DIM}](one of: {', '.join(servers)})[/]",
|
|
608
|
+
console=self.console, default="",
|
|
609
|
+
).strip()
|
|
610
|
+
if name in servers:
|
|
611
|
+
del servers[name]
|
|
612
|
+
self.console.print(f"[{GOOD}]Removed '{name}'.[/]")
|
|
613
|
+
else:
|
|
614
|
+
self.console.print(f"[{BAD}]No server named '{name}'.[/]")
|
|
615
|
+
|
|
616
|
+
def _mcp_test(self, servers: dict) -> None:
|
|
617
|
+
import asyncio
|
|
618
|
+
|
|
619
|
+
if not servers:
|
|
620
|
+
self.console.print(f"[{DIM}]No servers to test.[/]")
|
|
621
|
+
return
|
|
622
|
+
name = Prompt.ask(
|
|
623
|
+
f"[{ACCENT}]Server to test[/] [{DIM}](Enter = all)[/]",
|
|
624
|
+
console=self.console, default="",
|
|
625
|
+
).strip()
|
|
626
|
+
targets = {name: servers[name]} if name in servers else servers
|
|
627
|
+
asyncio.run(self._mcp_test_targets(targets))
|
|
628
|
+
|
|
629
|
+
async def _mcp_test_targets(self, targets: dict) -> None:
|
|
630
|
+
from contextlib import AsyncExitStack
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
from mcp import ClientSession, StdioServerParameters
|
|
634
|
+
from mcp.client.stdio import stdio_client
|
|
635
|
+
except ImportError:
|
|
636
|
+
self.console.print(f"[{BAD}]Package 'mcp' is not installed. "
|
|
637
|
+
f"Run: pip install mcp[/]")
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
stack = AsyncExitStack()
|
|
641
|
+
await stack.__aenter__()
|
|
642
|
+
try:
|
|
643
|
+
for name, cfg in targets.items():
|
|
644
|
+
try:
|
|
645
|
+
params = StdioServerParameters(
|
|
646
|
+
command=cfg["command"], args=cfg.get("args", []), env=cfg.get("env"),
|
|
647
|
+
)
|
|
648
|
+
read, write = await stack.enter_async_context(stdio_client(params))
|
|
649
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
650
|
+
await session.initialize()
|
|
651
|
+
result = await session.list_tools()
|
|
652
|
+
self.console.print(
|
|
653
|
+
f"[{GOOD}]\u2713 {name}[/] connected ({len(result.tools)} tools)")
|
|
654
|
+
except Exception as exc: # noqa: BLE001
|
|
655
|
+
self.console.print(f"[{BAD}]\u2717 {name}[/] failed: {exc}")
|
|
656
|
+
finally:
|
|
657
|
+
await stack.aclose()
|
|
658
|
+
|
|
659
|
+
# ------------------------------------------------------------------ #
|
|
660
|
+
# Rich helper views #
|
|
661
|
+
# ------------------------------------------------------------------ #
|
|
662
|
+
def _pick_model(self, title: str) -> Optional[Tuple[int, dict]]:
|
|
663
|
+
"""Render the catalog and ask for a number. Returns (1-based idx, model)."""
|
|
664
|
+
self._show_models_table(title=title)
|
|
665
|
+
choice = IntPrompt.ask(
|
|
666
|
+
f"\n[{ACCENT}]Model number[/] [{DIM}](0 = back)[/]",
|
|
667
|
+
console=self.console, default=0,
|
|
668
|
+
)
|
|
669
|
+
if choice == 0 or not (1 <= choice <= len(MODEL_CATALOG)):
|
|
670
|
+
if choice != 0:
|
|
671
|
+
self.console.print(f"[{BAD}]Number out of catalog range.[/]")
|
|
672
|
+
self._pause("[Enter]")
|
|
673
|
+
return None
|
|
674
|
+
return choice, MODEL_CATALOG[choice - 1]
|
|
675
|
+
|
|
676
|
+
def _show_models_table(self, title: str = "Models — local and remote") -> None:
|
|
677
|
+
from .models.catalog import available_ram_gb
|
|
678
|
+
|
|
679
|
+
ram = available_ram_gb()
|
|
680
|
+
vram = self.hardware.get("gpu_vram_gb", 0) or 0
|
|
681
|
+
has_gpu = self.hardware.get("gpu_available", False)
|
|
682
|
+
table = Table(title=f"[bold {ACCENT}]{title}[/]", border_style=ACCENT, expand=True)
|
|
683
|
+
table.add_column("#", style=DIM, width=3)
|
|
684
|
+
table.add_column("Model", overflow="fold")
|
|
685
|
+
table.add_column("Min RAM", justify="right")
|
|
686
|
+
table.add_column("Context", justify="right")
|
|
687
|
+
table.add_column("GPU", justify="center")
|
|
688
|
+
table.add_column("Source")
|
|
689
|
+
|
|
690
|
+
for i, m in enumerate(MODEL_CATALOG, 1):
|
|
691
|
+
downloaded = (MODELS_DIR / m["filename"]).exists()
|
|
692
|
+
source = f"[{GOOD}]local[/]" if downloaded else f"[{DIM}]remote[/]"
|
|
693
|
+
ram_warn = f" [{BAD}]![/]" if 0 < ram < m["ram_min_gb"] else ""
|
|
694
|
+
need = m.get("vram_min_gb")
|
|
695
|
+
if not has_gpu or not need:
|
|
696
|
+
gpu_cell = f"[{DIM}]-[/]"
|
|
697
|
+
elif vram >= need:
|
|
698
|
+
gpu_cell = f"[{GOOD}]\u2713 {need:.0f}G[/]"
|
|
699
|
+
elif vram >= need * 0.5:
|
|
700
|
+
gpu_cell = f"[{WARN}]~ {need:.0f}G[/]"
|
|
701
|
+
else:
|
|
702
|
+
gpu_cell = f"[{BAD}]\u2717 {need:.0f}G[/]"
|
|
703
|
+
table.add_row(
|
|
704
|
+
str(i), m["name"],
|
|
705
|
+
f"{m['ram_min_gb']} GB{ram_warn}", f"{m['ctx']}", gpu_cell, source,
|
|
706
|
+
)
|
|
707
|
+
self.console.print(table)
|
|
708
|
+
gpu_line = (f" | GPU VRAM: {vram:.1f} GB free"
|
|
709
|
+
if has_gpu else " | GPU: none")
|
|
710
|
+
self.console.print(f"[{DIM}]Free RAM detected: {ram:.1f} GB{gpu_line} "
|
|
711
|
+
f"(\u2713 fits GPU fully, ~ partial, \u2717 too large)[/]\n")
|
|
712
|
+
|
|
713
|
+
def _show_recommendations(self) -> None:
|
|
714
|
+
compatible = self.recommender.get_compatible_models()
|
|
715
|
+
if not compatible:
|
|
716
|
+
self.console.print(Panel(
|
|
717
|
+
f"[{BAD}]No models compatible with your hardware.[/]",
|
|
718
|
+
border_style=BAD))
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
table = Table(
|
|
722
|
+
title=f"[bold {ACCENT}]Auto recommendation[/]",
|
|
723
|
+
border_style=ACCENT, expand=True,
|
|
724
|
+
)
|
|
725
|
+
table.add_column("#", style=DIM, width=3)
|
|
726
|
+
table.add_column("Model", overflow="fold")
|
|
727
|
+
table.add_column("Fit")
|
|
728
|
+
table.add_column("Min RAM", justify="right")
|
|
729
|
+
table.add_column("Backend", justify="center")
|
|
730
|
+
|
|
731
|
+
for i, m in enumerate(compatible, 1):
|
|
732
|
+
score = m.get("compatibility_score", 0)
|
|
733
|
+
bar_len = 10
|
|
734
|
+
filled = int(round(score * bar_len))
|
|
735
|
+
color = GOOD if score >= 0.7 else (WARN if score >= 0.4 else BAD)
|
|
736
|
+
bar = f"[{color}]{'█' * filled}[/][{DIM}]{'░' * (bar_len - filled)}[/] {score:.0%}"
|
|
737
|
+
backend = f"[{GOOD}]GPU[/]" if m.get("can_use_gpu") else f"[{ACCENT}]CPU[/]"
|
|
738
|
+
tag = f" [{GOOD}][recommended][/]" if m.get("recommended") else ""
|
|
739
|
+
table.add_row(str(i), m["name"] + tag, bar, f"{m.get('ram_min_gb', '?')} GB", backend)
|
|
740
|
+
|
|
741
|
+
self.console.print(table)
|
|
742
|
+
best = compatible[0]
|
|
743
|
+
self.console.print(Panel(
|
|
744
|
+
self.recommender.get_recommendation_reason(best),
|
|
745
|
+
title=f"[{ACCENT}]Why {best['name']}?[/]", border_style=DIM))
|
|
746
|
+
self.console.print(f"[{DIM}]Use the number from the table above (1 = best option).[/]\n")
|
|
747
|
+
|
|
748
|
+
def _download_model(self, idx: int) -> None:
|
|
749
|
+
self._download_model_entry(MODEL_CATALOG[idx - 1])
|
|
750
|
+
|
|
751
|
+
def _download_model_entry(self, model: dict) -> None:
|
|
752
|
+
from .models.loader import download_model
|
|
753
|
+
|
|
754
|
+
dest = MODELS_DIR / model["filename"]
|
|
755
|
+
if dest.exists():
|
|
756
|
+
self.console.print(f"[{GOOD}]Model already saved:[/] {dest}")
|
|
757
|
+
return
|
|
758
|
+
self.console.print(f"\n[{ACCENT}]Downloading[/] {model['name']} ...")
|
|
759
|
+
with self.console.status(f"[{ACCENT}]Downloading {model['filename']}[/]", spinner="dots"):
|
|
760
|
+
try:
|
|
761
|
+
path = download_model(model)
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
self.console.print(f"[{BAD}]Download failed:[/] {exc}")
|
|
764
|
+
return
|
|
765
|
+
self.console.print(f"[{GOOD}]Saved at[/] {path}")
|
|
766
|
+
|
|
767
|
+
def _hub_search(self) -> Optional[dict]:
|
|
768
|
+
from .model_hub import ModelHub
|
|
769
|
+
|
|
770
|
+
query = Prompt.ask(
|
|
771
|
+
f"[{ACCENT}]Search Hugging Face[/] [{DIM}](query, owner/repo, hf://, or HF URL)[/]",
|
|
772
|
+
console=self.console, default="GGUF",
|
|
773
|
+
).strip()
|
|
774
|
+
direct_model = self._build_model_from_hf_reference(query)
|
|
775
|
+
if direct_model is not None:
|
|
776
|
+
return direct_model
|
|
777
|
+
|
|
778
|
+
hub = ModelHub()
|
|
779
|
+
with self.console.status(f"[{ACCENT}]Searching '{query}'...[/]", spinner="dots"):
|
|
780
|
+
try:
|
|
781
|
+
results = hub.fetch_huggingface_models(search_query=query, limit=15)
|
|
782
|
+
except Exception as exc:
|
|
783
|
+
self.console.print(f"[{BAD}]Search error:[/] {exc}")
|
|
784
|
+
return None
|
|
785
|
+
if not results:
|
|
786
|
+
self.console.print(
|
|
787
|
+
f"[{DIM}]No results.[/] Hugging Face search matches repositories, not always exact file names."
|
|
788
|
+
)
|
|
789
|
+
if query.lower().endswith(".gguf"):
|
|
790
|
+
self.console.print(
|
|
791
|
+
f"[{DIM}]Tip:[/] use direct reference, e.g. "
|
|
792
|
+
f"hf://owner/repo/{query} or paste the full HF resolve URL."
|
|
793
|
+
)
|
|
794
|
+
return None
|
|
795
|
+
table = Table(title=f"[bold {ACCENT}]Hugging Face: '{query}'[/]", border_style=ACCENT)
|
|
796
|
+
table.add_column("#", style=DIM, width=3)
|
|
797
|
+
table.add_column("Repository", overflow="fold")
|
|
798
|
+
table.add_column("Downloads", justify="right")
|
|
799
|
+
table.add_column("Likes", justify="right")
|
|
800
|
+
for i, m in enumerate(results, 1):
|
|
801
|
+
table.add_row(
|
|
802
|
+
str(i),
|
|
803
|
+
m.get("name", m.get("repo_id", "?")),
|
|
804
|
+
f"{m.get('downloads', 0):,}",
|
|
805
|
+
f"{m.get('likes', 0):,}",
|
|
806
|
+
)
|
|
807
|
+
self.console.print(table)
|
|
808
|
+
repo_choice = IntPrompt.ask(
|
|
809
|
+
f"\n[{ACCENT}]Repository number[/] [{DIM}](0 = back)[/]",
|
|
810
|
+
console=self.console,
|
|
811
|
+
default=0,
|
|
812
|
+
)
|
|
813
|
+
if repo_choice == 0 or not (1 <= repo_choice <= len(results)):
|
|
814
|
+
return None
|
|
815
|
+
|
|
816
|
+
selected = results[repo_choice - 1]
|
|
817
|
+
repo_id = selected.get("repo_id") or selected.get("name")
|
|
818
|
+
if not repo_id:
|
|
819
|
+
self.console.print(f"[{BAD}]Selected result has no repo id.[/]")
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
return self._build_model_from_repo(repo_id)
|
|
823
|
+
|
|
824
|
+
def _build_model_from_repo(self, repo_id: str) -> Optional[dict]:
|
|
825
|
+
from .model_hub import ModelHub
|
|
826
|
+
|
|
827
|
+
hub = ModelHub()
|
|
828
|
+
with self.console.status(f"[{ACCENT}]Loading GGUF files from {repo_id}...[/]", spinner="dots"):
|
|
829
|
+
files = hub.get_model_files(repo_id)
|
|
830
|
+
if not files:
|
|
831
|
+
self.console.print(f"[{BAD}]No .gguf files found in[/] {repo_id}")
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
file_table = Table(title=f"[bold {ACCENT}]GGUF files — {repo_id}[/]", border_style=ACCENT)
|
|
835
|
+
file_table.add_column("#", style=DIM, width=3)
|
|
836
|
+
file_table.add_column("Filename", overflow="fold")
|
|
837
|
+
file_table.add_column("Quant", justify="center")
|
|
838
|
+
for i, fn in enumerate(files, 1):
|
|
839
|
+
file_table.add_row(str(i), fn, self._extract_quantization(fn))
|
|
840
|
+
self.console.print(file_table)
|
|
841
|
+
|
|
842
|
+
recommended_idx = self._pick_recommended_file_index(files)
|
|
843
|
+
self.console.print(
|
|
844
|
+
f"[{DIM}]Tip:[/] recommended file #{recommended_idx} for general local use."
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
file_choice = IntPrompt.ask(
|
|
848
|
+
f"\n[{ACCENT}]File number[/] [{DIM}](0 = back)[/]",
|
|
849
|
+
console=self.console,
|
|
850
|
+
default=recommended_idx,
|
|
851
|
+
)
|
|
852
|
+
if file_choice == 0 or not (1 <= file_choice <= len(files)):
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
filename = files[file_choice - 1]
|
|
856
|
+
return self._build_model_entry(repo_id, filename)
|
|
857
|
+
|
|
858
|
+
def _build_model_entry(self, repo_id: str, filename: str) -> dict:
|
|
859
|
+
default_name = f"{repo_id.split('/')[-1]} ({self._extract_quantization(filename)}) — hub"
|
|
860
|
+
model_name = Prompt.ask(
|
|
861
|
+
f"[{ACCENT}]Catalog display name[/]",
|
|
862
|
+
console=self.console,
|
|
863
|
+
default=default_name,
|
|
864
|
+
).strip() or default_name
|
|
865
|
+
|
|
866
|
+
params_b = self._estimate_params_billions(f"{repo_id} {filename}")
|
|
867
|
+
ram_default = self._estimate_ram_default_gb(params_b, filename)
|
|
868
|
+
vram_default = max(1.0, round(params_b * 0.7, 1))
|
|
869
|
+
layers_default = self._estimate_layer_count(params_b)
|
|
870
|
+
|
|
871
|
+
ram_min = IntPrompt.ask("Min RAM (GB)", console=self.console, default=ram_default)
|
|
872
|
+
ctx = IntPrompt.ask("Context window", console=self.console, default=8192)
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
"name": model_name,
|
|
876
|
+
"repo_id": repo_id,
|
|
877
|
+
"filename": filename,
|
|
878
|
+
"ram_min_gb": max(1, ram_min),
|
|
879
|
+
"vram_min_gb": vram_default,
|
|
880
|
+
"n_layers": layers_default,
|
|
881
|
+
"ctx": max(512, ctx),
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
def _add_model_to_catalog(self, model: dict) -> Tuple[int, bool]:
|
|
885
|
+
return add_model_to_catalog(model, persist=True)
|
|
886
|
+
|
|
887
|
+
@staticmethod
|
|
888
|
+
def _estimate_params_billions(text: str) -> float:
|
|
889
|
+
match = re.search(r"(\d+(?:\.\d+)?)\s*[bB]", text)
|
|
890
|
+
if not match:
|
|
891
|
+
return 7.0
|
|
892
|
+
try:
|
|
893
|
+
return float(match.group(1))
|
|
894
|
+
except ValueError:
|
|
895
|
+
return 7.0
|
|
896
|
+
|
|
897
|
+
@staticmethod
|
|
898
|
+
def _estimate_layer_count(params_b: float) -> int:
|
|
899
|
+
if params_b <= 2:
|
|
900
|
+
return 24
|
|
901
|
+
if params_b <= 4:
|
|
902
|
+
return 28
|
|
903
|
+
if params_b <= 8:
|
|
904
|
+
return 32
|
|
905
|
+
if params_b <= 14:
|
|
906
|
+
return 40
|
|
907
|
+
return 48
|
|
908
|
+
|
|
909
|
+
@staticmethod
|
|
910
|
+
def _extract_quantization(filename: str) -> str:
|
|
911
|
+
upper = filename.upper()
|
|
912
|
+
quant_match = re.search(r"Q\d(?:_[A-Z0-9]+)*", upper)
|
|
913
|
+
return quant_match.group(0) if quant_match else "unknown"
|
|
914
|
+
|
|
915
|
+
def _build_model_from_hf_uri(self, uri: str) -> Optional[dict]:
|
|
916
|
+
"""Build a catalog entry from ``hf://repo_id/filename.gguf``."""
|
|
917
|
+
raw = uri.strip()
|
|
918
|
+
if not raw.lower().startswith("hf://"):
|
|
919
|
+
self.console.print(f"[{BAD}]Invalid Hugging Face URI.[/]")
|
|
920
|
+
return None
|
|
921
|
+
payload = raw[5:]
|
|
922
|
+
if "/" not in payload:
|
|
923
|
+
self.console.print(f"[{BAD}]Expected format:[/] hf://owner/repo/file.gguf")
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
parts = [p for p in payload.split("/") if p]
|
|
927
|
+
if len(parts) < 3:
|
|
928
|
+
self.console.print(f"[{BAD}]Expected format:[/] hf://owner/repo/file.gguf")
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
owner = parts[0]
|
|
932
|
+
repo = parts[1]
|
|
933
|
+
filename = "/".join(parts[2:])
|
|
934
|
+
if not filename.lower().endswith(".gguf"):
|
|
935
|
+
self.console.print(f"[{BAD}]Only .gguf files are supported here.[/]")
|
|
936
|
+
return None
|
|
937
|
+
|
|
938
|
+
repo_id = f"{owner}/{repo}"
|
|
939
|
+
return self._build_model_entry(repo_id, filename)
|
|
940
|
+
|
|
941
|
+
def _build_model_from_hf_reference(self, raw: str) -> Optional[dict]:
|
|
942
|
+
"""Accept hf:// URIs or Hugging Face resolve/blob URLs and build a model.
|
|
943
|
+
|
|
944
|
+
Returns None when ``raw`` doesn't look like a direct model reference.
|
|
945
|
+
"""
|
|
946
|
+
text = raw.strip()
|
|
947
|
+
if not text:
|
|
948
|
+
return None
|
|
949
|
+
|
|
950
|
+
if text.lower().startswith("hf://"):
|
|
951
|
+
return self._build_model_from_hf_uri(text)
|
|
952
|
+
|
|
953
|
+
if text.lower().startswith("http://") or text.lower().startswith("https://"):
|
|
954
|
+
parsed = urlparse(text)
|
|
955
|
+
if "huggingface.co" not in (parsed.netloc or "").lower():
|
|
956
|
+
return None
|
|
957
|
+
parts = [unquote(p) for p in parsed.path.split("/") if p]
|
|
958
|
+
if len(parts) >= 2:
|
|
959
|
+
owner, repo = parts[0], parts[1]
|
|
960
|
+
if len(parts) >= 5:
|
|
961
|
+
marker = parts[2].lower()
|
|
962
|
+
if marker in ("resolve", "blob"):
|
|
963
|
+
filename = "/".join(parts[4:])
|
|
964
|
+
if filename.lower().endswith(".gguf"):
|
|
965
|
+
return self._build_model_from_hf_uri(f"hf://{owner}/{repo}/{filename}")
|
|
966
|
+
return self._build_model_from_repo(f"{owner}/{repo}")
|
|
967
|
+
|
|
968
|
+
snippet_repo = self._extract_repo_id_from_text(text)
|
|
969
|
+
if snippet_repo:
|
|
970
|
+
return self._build_model_from_repo(snippet_repo)
|
|
971
|
+
|
|
972
|
+
if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", text):
|
|
973
|
+
return self._build_model_from_repo(text)
|
|
974
|
+
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
@staticmethod
|
|
978
|
+
def _extract_repo_id_from_text(text: str) -> Optional[str]:
|
|
979
|
+
quoted = re.findall(r"['\"]([^'\"]+)['\"]", text)
|
|
980
|
+
for token in quoted:
|
|
981
|
+
if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", token):
|
|
982
|
+
return token
|
|
983
|
+
inline = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", text)
|
|
984
|
+
if inline:
|
|
985
|
+
return inline.group(1)
|
|
986
|
+
return None
|
|
987
|
+
|
|
988
|
+
@staticmethod
|
|
989
|
+
def _estimate_ram_default_gb(params_b: float, filename: str) -> int:
|
|
990
|
+
quant = filename.upper()
|
|
991
|
+
# Conservative but closer to practical defaults for GGUF quantizations.
|
|
992
|
+
if "Q2" in quant or "IQ2" in quant:
|
|
993
|
+
factor = 0.45
|
|
994
|
+
elif "Q3" in quant or "IQ3" in quant:
|
|
995
|
+
factor = 0.6
|
|
996
|
+
elif "Q4" in quant or "IQ4" in quant:
|
|
997
|
+
factor = 0.8
|
|
998
|
+
elif "Q5" in quant:
|
|
999
|
+
factor = 1.0
|
|
1000
|
+
elif "Q6" in quant:
|
|
1001
|
+
factor = 1.15
|
|
1002
|
+
elif "Q8" in quant:
|
|
1003
|
+
factor = 1.45
|
|
1004
|
+
else:
|
|
1005
|
+
factor = 0.9
|
|
1006
|
+
return max(2, int(round(max(1.5, params_b * factor))))
|
|
1007
|
+
|
|
1008
|
+
def _pick_recommended_file_index(self, files: List[str]) -> int:
|
|
1009
|
+
ranked = sorted(
|
|
1010
|
+
enumerate(files, start=1),
|
|
1011
|
+
key=lambda item: self._file_rank(item[1]),
|
|
1012
|
+
)
|
|
1013
|
+
return ranked[0][0] if ranked else 1
|
|
1014
|
+
|
|
1015
|
+
@staticmethod
|
|
1016
|
+
def _file_rank(filename: str) -> Tuple[int, int, str]:
|
|
1017
|
+
upper = filename.upper()
|
|
1018
|
+
# Prefer common local-friendly quantizations first.
|
|
1019
|
+
quant_priority = [
|
|
1020
|
+
"Q4_K_M", "Q4_K_S", "Q4_0", "Q5_K_M", "Q5_0", "Q3_K_M", "Q8_0",
|
|
1021
|
+
]
|
|
1022
|
+
for i, q in enumerate(quant_priority):
|
|
1023
|
+
if q in upper:
|
|
1024
|
+
return (i, len(filename), upper)
|
|
1025
|
+
return (len(quant_priority), len(filename), upper)
|
|
1026
|
+
|
|
1027
|
+
def _show_profile(self) -> None:
|
|
1028
|
+
hw = self.hardware
|
|
1029
|
+
grid = Table.grid(padding=(0, 2))
|
|
1030
|
+
grid.add_column(style=f"bold {DIM}")
|
|
1031
|
+
grid.add_column()
|
|
1032
|
+
for label, value in (
|
|
1033
|
+
("System", hw.get("os", "?")),
|
|
1034
|
+
("CPU", hw.get("cpu_model", "?")),
|
|
1035
|
+
("Cores (logical)", hw.get("cpu_count", "?")),
|
|
1036
|
+
("Total RAM", f"{hw.get('ram_total_gb', 0):.1f} GB"),
|
|
1037
|
+
("Free RAM", f"{hw.get('ram_available_gb', 0):.1f} GB"),
|
|
1038
|
+
("Free disk", f"{hw.get('disk_free_gb', 0):.1f} GB"),
|
|
1039
|
+
("GPU", hw.get("gpu_name", "not detected")),
|
|
1040
|
+
("VRAM", f"{hw.get('gpu_vram_gb', 0):.1f} GB"),
|
|
1041
|
+
):
|
|
1042
|
+
grid.add_row(label, str(value))
|
|
1043
|
+
self.console.print(Panel(
|
|
1044
|
+
grid, title=f"[bold {ACCENT}]Hardware profile[/]", border_style=ACCENT))
|
|
1045
|
+
self._pause("[Enter to continue]")
|
|
1046
|
+
|
|
1047
|
+
def _manage_history(self) -> None:
|
|
1048
|
+
from pathlib import Path
|
|
1049
|
+
|
|
1050
|
+
from .storage import ConversationStore
|
|
1051
|
+
|
|
1052
|
+
store = ConversationStore(Path(getattr(self.args, "db", DB_FILE)))
|
|
1053
|
+
try:
|
|
1054
|
+
convs = store.list()
|
|
1055
|
+
if not convs:
|
|
1056
|
+
self.console.print(Panel(
|
|
1057
|
+
f"[{DIM}]No saved conversations yet.[/]", border_style=DIM))
|
|
1058
|
+
self._pause()
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
table = Table(title=f"[bold {ACCENT}]Saved conversations[/]", border_style=ACCENT, expand=True)
|
|
1062
|
+
table.add_column("ID", style=DIM, width=5)
|
|
1063
|
+
table.add_column("Title", overflow="fold")
|
|
1064
|
+
table.add_column("Model", overflow="fold")
|
|
1065
|
+
table.add_column("Msgs", justify="right")
|
|
1066
|
+
table.add_column("Updated")
|
|
1067
|
+
for c in convs:
|
|
1068
|
+
table.add_row(
|
|
1069
|
+
f"#{c.id}", c.title, c.model_name or "-",
|
|
1070
|
+
str(c.message_count), c.updated_at,
|
|
1071
|
+
)
|
|
1072
|
+
self.console.print(table)
|
|
1073
|
+
|
|
1074
|
+
choice = Prompt.ask(
|
|
1075
|
+
f"\n[{ACCENT}]Action[/] [{DIM}](resume <id>, delete <id>, export <id>, Enter = back)[/]",
|
|
1076
|
+
console=self.console, default="",
|
|
1077
|
+
).strip()
|
|
1078
|
+
if not choice:
|
|
1079
|
+
return
|
|
1080
|
+
parts = choice.split()
|
|
1081
|
+
cmd = parts[0].lower()
|
|
1082
|
+
if cmd in ("resume", "r") and len(parts) == 2 and parts[1].isdigit():
|
|
1083
|
+
self._resume_conversation(store, int(parts[1]))
|
|
1084
|
+
elif cmd in ("delete", "d") and len(parts) == 2 and parts[1].isdigit():
|
|
1085
|
+
self._delete_conversation(store, int(parts[1]))
|
|
1086
|
+
elif cmd in ("export", "e") and len(parts) >= 2 and parts[1].isdigit():
|
|
1087
|
+
fmt = parts[2] if len(parts) > 2 and parts[2] in ("md", "json") else "md"
|
|
1088
|
+
self._export_conversation(store, int(parts[1]), fmt)
|
|
1089
|
+
else:
|
|
1090
|
+
self.console.print(
|
|
1091
|
+
f"[{BAD}]Usage:[/] resume <id> | delete <id> | export <id> [md|json]")
|
|
1092
|
+
finally:
|
|
1093
|
+
store.close()
|
|
1094
|
+
self._pause()
|
|
1095
|
+
|
|
1096
|
+
def _resume_conversation(self, store: Any, conv_id: int) -> None:
|
|
1097
|
+
if store.get(conv_id) is None:
|
|
1098
|
+
self.console.print(f"[{BAD}]Conversation #{conv_id} not found.[/]")
|
|
1099
|
+
return
|
|
1100
|
+
if not self._require_llm():
|
|
1101
|
+
return
|
|
1102
|
+
pick = self._pick_model(title="Model to resume the conversation")
|
|
1103
|
+
if pick is None:
|
|
1104
|
+
return
|
|
1105
|
+
idx, _model = pick
|
|
1106
|
+
self._run_app(serve=False, model=idx, resume=conv_id)
|
|
1107
|
+
|
|
1108
|
+
def _delete_conversation(self, store: Any, conv_id: int) -> None:
|
|
1109
|
+
if not Confirm.ask(f"Delete conversation #{conv_id}?", console=self.console, default=False):
|
|
1110
|
+
self.console.print(f"[{DIM}]Cancelled.[/]")
|
|
1111
|
+
return
|
|
1112
|
+
if store.delete(conv_id):
|
|
1113
|
+
self.console.print(f"[{GOOD}]Conversation #{conv_id} deleted.[/]")
|
|
1114
|
+
else:
|
|
1115
|
+
self.console.print(f"[{BAD}]Conversation #{conv_id} not found.[/]")
|
|
1116
|
+
|
|
1117
|
+
def _export_conversation(self, store: Any, conv_id: int, fmt: str) -> None:
|
|
1118
|
+
from pathlib import Path
|
|
1119
|
+
|
|
1120
|
+
path = Path(f"conversation_{conv_id}.{fmt}")
|
|
1121
|
+
result = store.export(conv_id, path, fmt)
|
|
1122
|
+
if result is None:
|
|
1123
|
+
self.console.print(f"[{BAD}]Conversation #{conv_id} not found.[/]")
|
|
1124
|
+
else:
|
|
1125
|
+
self.console.print(f"[{GOOD}]Exported to[/] {result.resolve()}")
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def run(args: Any) -> None:
|
|
1129
|
+
"""Entry point called from ``cli.py`` when no action flag is given."""
|
|
1130
|
+
InteractiveMenu(args).run()
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
if __name__ == "__main__":
|
|
1134
|
+
import argparse
|
|
1135
|
+
|
|
1136
|
+
run(argparse.Namespace(
|
|
1137
|
+
db=str(DB_FILE), host="127.0.0.1", port=8000,
|
|
1138
|
+
no_mcp=False, serve=False, model=None, resume=None,
|
|
1139
|
+
gpu=False, cpu=False, n_gpu_layers=-1,
|
|
1140
|
+
))
|