zeroquantz 0.1.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.
- zeroquantz/__init__.py +14 -0
- zeroquantz/__main__.py +8 -0
- zeroquantz/agent/__init__.py +16 -0
- zeroquantz/agent/dispatcher.py +520 -0
- zeroquantz/agent/intents.py +46 -0
- zeroquantz/agent/parser.py +255 -0
- zeroquantz/benchmark/__init__.py +7 -0
- zeroquantz/benchmark/latency.py +66 -0
- zeroquantz/benchmark/memory.py +41 -0
- zeroquantz/benchmark/quality.py +38 -0
- zeroquantz/benchmark/runner.py +151 -0
- zeroquantz/cli/__init__.py +7 -0
- zeroquantz/cli/app.py +98 -0
- zeroquantz/cli/commands.py +459 -0
- zeroquantz/cli/interactive.py +56 -0
- zeroquantz/core/__init__.py +7 -0
- zeroquantz/core/artifacts.py +179 -0
- zeroquantz/core/context.py +127 -0
- zeroquantz/core/events.py +30 -0
- zeroquantz/core/exceptions.py +105 -0
- zeroquantz/core/session.py +202 -0
- zeroquantz/core/subenv.py +202 -0
- zeroquantz/deploy/__init__.py +25 -0
- zeroquantz/deploy/assets.py +161 -0
- zeroquantz/deploy/launcher.py +80 -0
- zeroquantz/deploy/runtime_env.py +66 -0
- zeroquantz/deploy/targets.py +154 -0
- zeroquantz/export/__init__.py +8 -0
- zeroquantz/export/exporter.py +68 -0
- zeroquantz/export/report.py +203 -0
- zeroquantz/hardware/__init__.py +15 -0
- zeroquantz/hardware/capabilities.py +152 -0
- zeroquantz/hardware/detector.py +200 -0
- zeroquantz/hardware/gpu.py +31 -0
- zeroquantz/models/__init__.py +8 -0
- zeroquantz/models/architecture.py +168 -0
- zeroquantz/models/downloader.py +161 -0
- zeroquantz/models/hf_auth.py +105 -0
- zeroquantz/models/inspector.py +249 -0
- zeroquantz/models/metadata.py +108 -0
- zeroquantz/models/search.py +71 -0
- zeroquantz/optimization/__init__.py +22 -0
- zeroquantz/optimization/candidate.py +272 -0
- zeroquantz/optimization/constraints.py +70 -0
- zeroquantz/optimization/fit.py +203 -0
- zeroquantz/optimization/pareto.py +66 -0
- zeroquantz/optimization/planner.py +297 -0
- zeroquantz/optimization/recommender.py +149 -0
- zeroquantz/profiling/__init__.py +18 -0
- zeroquantz/profiling/calibration.py +74 -0
- zeroquantz/profiling/sensitivity.py +234 -0
- zeroquantz/quantization/__init__.py +17 -0
- zeroquantz/quantization/backends/__init__.py +8 -0
- zeroquantz/quantization/backends/bitsandbytes.py +210 -0
- zeroquantz/quantization/backends/torchao.py +198 -0
- zeroquantz/quantization/base.py +136 -0
- zeroquantz/quantization/catalog.py +321 -0
- zeroquantz/quantization/config.py +106 -0
- zeroquantz/quantization/gguf_pipeline.py +210 -0
- zeroquantz/quantization/isolated.py +248 -0
- zeroquantz/quantization/memory.py +133 -0
- zeroquantz/quantization/native.py +91 -0
- zeroquantz/quantization/registry.py +101 -0
- zeroquantz/render.py +341 -0
- zeroquantz/runtimes/__init__.py +18 -0
- zeroquantz/runtimes/base.py +64 -0
- zeroquantz/runtimes/compatibility.py +91 -0
- zeroquantz/runtimes/registry.py +70 -0
- zeroquantz/runtimes/transformers.py +53 -0
- zeroquantz/runtimes/vllm.py +83 -0
- zeroquantz/tui/__init__.py +13 -0
- zeroquantz/tui/app.py +77 -0
- zeroquantz/tui/banner.py +47 -0
- zeroquantz/tui/screens/__init__.py +25 -0
- zeroquantz/tui/screens/confirm.py +41 -0
- zeroquantz/tui/screens/execute.py +194 -0
- zeroquantz/tui/screens/model_select.py +206 -0
- zeroquantz/tui/screens/plan.py +177 -0
- zeroquantz/tui/screens/quantize_select.py +272 -0
- zeroquantz/tui/screens/settings.py +219 -0
- zeroquantz/tui/screens/token.py +94 -0
- zeroquantz/tui/screens/welcome.py +128 -0
- zeroquantz/tui/screens/workspace.py +175 -0
- zeroquantz/tui/styles/app.tcss +424 -0
- zeroquantz/tui/widgets/__init__.py +9 -0
- zeroquantz/tui/widgets/chip.py +36 -0
- zeroquantz/tui/widgets/sidebar.py +107 -0
- zeroquantz/tui/widgets/status_bar.py +43 -0
- zeroquantz/utils/__init__.py +8 -0
- zeroquantz/utils/config.py +46 -0
- zeroquantz/utils/env.py +78 -0
- zeroquantz/utils/logging.py +73 -0
- zeroquantz/utils/metrics.py +98 -0
- zeroquantz/utils/paths.py +57 -0
- zeroquantz/utils/units.py +134 -0
- zeroquantz/verification/__init__.py +17 -0
- zeroquantz/verification/logits.py +55 -0
- zeroquantz/verification/report.py +186 -0
- zeroquantz/verification/weights.py +44 -0
- zeroquantz/version.py +8 -0
- zeroquantz-0.1.0.dist-info/METADATA +72 -0
- zeroquantz-0.1.0.dist-info/RECORD +105 -0
- zeroquantz-0.1.0.dist-info/WHEEL +4 -0
- zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
- zeroquantz-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Settings: manage downloaded models, quantized outputs, and the HF token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import work
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.screen import Screen
|
|
15
|
+
from textual.widgets import DataTable, Footer, Static, TabbedContent, TabPane
|
|
16
|
+
|
|
17
|
+
from zeroquantz.core import artifacts
|
|
18
|
+
from zeroquantz.models import hf_auth
|
|
19
|
+
from zeroquantz.tui.screens.confirm import ConfirmScreen
|
|
20
|
+
from zeroquantz.tui.screens.token import TokenScreen
|
|
21
|
+
from zeroquantz.utils import units
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SettingsScreen(Screen):
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
Binding("escape", "back", "Back"),
|
|
27
|
+
Binding("1", "tab_downloaded", "Downloaded", show=False),
|
|
28
|
+
Binding("2", "tab_quantized", "Quantized", show=False),
|
|
29
|
+
Binding("3", "tab_token", "Token", show=False),
|
|
30
|
+
Binding("r", "refresh", "Refresh"),
|
|
31
|
+
Binding("d", "delete", "Delete"),
|
|
32
|
+
Binding("o", "open", "Open"),
|
|
33
|
+
Binding("e", "token_edit", "Set token"),
|
|
34
|
+
Binding("c", "token_clear", "Clear token"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._downloaded: list[artifacts.DownloadedModel] = []
|
|
40
|
+
self._quantized: list[artifacts.QuantizedArtifact] = []
|
|
41
|
+
|
|
42
|
+
def compose(self) -> ComposeResult:
|
|
43
|
+
with Vertical(id="settings-page"):
|
|
44
|
+
yield Static("Settings", classes="page-title")
|
|
45
|
+
with TabbedContent(id="settings-tabs", initial="tab-downloaded"):
|
|
46
|
+
with TabPane("Downloaded models", id="tab-downloaded"):
|
|
47
|
+
yield DataTable(id="dl-table", cursor_type="row", zebra_stripes=True)
|
|
48
|
+
with TabPane("Quantized models", id="tab-quantized"):
|
|
49
|
+
yield DataTable(id="q-table", cursor_type="row", zebra_stripes=True)
|
|
50
|
+
with TabPane("Hugging Face token", id="tab-token"):
|
|
51
|
+
yield Static("", id="tok-status")
|
|
52
|
+
yield Footer()
|
|
53
|
+
|
|
54
|
+
def on_mount(self) -> None:
|
|
55
|
+
self.query_one("#dl-table", DataTable).add_columns("Model", "Size", "Files")
|
|
56
|
+
self.query_one("#q-table", DataTable).add_columns("Name", "Base model", "Format", "Size")
|
|
57
|
+
self._refresh_quantized()
|
|
58
|
+
self._refresh_token()
|
|
59
|
+
self._refresh_downloaded()
|
|
60
|
+
|
|
61
|
+
# ---- refresh ------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
@work(thread=True, exclusive=True)
|
|
64
|
+
def _refresh_downloaded(self) -> None:
|
|
65
|
+
data = artifacts.list_downloaded_models()
|
|
66
|
+
self.app.call_from_thread(self._fill_downloaded, data)
|
|
67
|
+
|
|
68
|
+
def _fill_downloaded(self, data: list[artifacts.DownloadedModel]) -> None:
|
|
69
|
+
self._downloaded = data
|
|
70
|
+
table = self.query_one("#dl-table", DataTable)
|
|
71
|
+
table.clear()
|
|
72
|
+
total = 0
|
|
73
|
+
for m in data:
|
|
74
|
+
total += m.size_bytes
|
|
75
|
+
table.add_row(m.repo_id, units.humanize_bytes(m.size_bytes), str(m.nb_files))
|
|
76
|
+
if not data:
|
|
77
|
+
table.add_row("(cache empty)", "", "")
|
|
78
|
+
self._set_tab_title("tab-downloaded", f"Downloaded models · {units.humanize_bytes(total)}")
|
|
79
|
+
|
|
80
|
+
def _refresh_quantized(self) -> None:
|
|
81
|
+
self._quantized = artifacts.default_quantized_registry().list()
|
|
82
|
+
table = self.query_one("#q-table", DataTable)
|
|
83
|
+
table.clear()
|
|
84
|
+
total = 0
|
|
85
|
+
for a in self._quantized:
|
|
86
|
+
total += a.size_bytes
|
|
87
|
+
name = os.path.basename(a.path.rstrip("/\\"))
|
|
88
|
+
table.add_row(name, a.base_model or "?", a.format_id or "?", units.humanize_bytes(a.size_bytes))
|
|
89
|
+
if not self._quantized:
|
|
90
|
+
table.add_row("(none yet)", "", "", "")
|
|
91
|
+
self._set_tab_title("tab-quantized", f"Quantized models · {units.humanize_bytes(total)}")
|
|
92
|
+
|
|
93
|
+
def _refresh_token(self) -> None:
|
|
94
|
+
token = hf_auth.current_token()
|
|
95
|
+
status = self.query_one("#tok-status", Static)
|
|
96
|
+
if token:
|
|
97
|
+
status.update(Text.assemble(
|
|
98
|
+
("● ", "#3fb950"), (f"Signed in — token {hf_auth.masked(token)}\n", "#3fb950"),
|
|
99
|
+
("Gated models and higher Hub rate limits are enabled.", "#8b949e"),
|
|
100
|
+
))
|
|
101
|
+
else:
|
|
102
|
+
status.update(Text.assemble(
|
|
103
|
+
("○ ", "#8b949e"), ("No token set\n", "#8b949e"),
|
|
104
|
+
("Add one to access gated models (Llama, …) and raise rate limits.", "#8b949e"),
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
def _set_tab_title(self, pane_id: str, title: str) -> None:
|
|
108
|
+
try:
|
|
109
|
+
self.query_one("#settings-tabs", TabbedContent).get_tab(pane_id).label = title
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
# ---- tab switching ------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _active(self) -> str:
|
|
116
|
+
return self.query_one("#settings-tabs", TabbedContent).active
|
|
117
|
+
|
|
118
|
+
def action_tab_downloaded(self) -> None:
|
|
119
|
+
self.query_one("#settings-tabs", TabbedContent).active = "tab-downloaded"
|
|
120
|
+
|
|
121
|
+
def action_tab_quantized(self) -> None:
|
|
122
|
+
self.query_one("#settings-tabs", TabbedContent).active = "tab-quantized"
|
|
123
|
+
|
|
124
|
+
def action_tab_token(self) -> None:
|
|
125
|
+
self.query_one("#settings-tabs", TabbedContent).active = "tab-token"
|
|
126
|
+
|
|
127
|
+
# ---- actions ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def action_refresh(self) -> None:
|
|
130
|
+
active = self._active()
|
|
131
|
+
if active == "tab-downloaded":
|
|
132
|
+
self._refresh_downloaded()
|
|
133
|
+
elif active == "tab-quantized":
|
|
134
|
+
self._refresh_quantized()
|
|
135
|
+
else:
|
|
136
|
+
self._refresh_token()
|
|
137
|
+
|
|
138
|
+
def action_delete(self) -> None:
|
|
139
|
+
active = self._active()
|
|
140
|
+
if active == "tab-downloaded":
|
|
141
|
+
self._delete_downloaded()
|
|
142
|
+
elif active == "tab-quantized":
|
|
143
|
+
self._delete_quantized()
|
|
144
|
+
|
|
145
|
+
def _delete_downloaded(self) -> None:
|
|
146
|
+
row = self.query_one("#dl-table", DataTable).cursor_row
|
|
147
|
+
if row is None or row >= len(self._downloaded):
|
|
148
|
+
return
|
|
149
|
+
model = self._downloaded[row]
|
|
150
|
+
msg = f"Delete cached model '{model.repo_id}'?"
|
|
151
|
+
detail = f"Frees {units.humanize_bytes(model.size_bytes)} from the Hugging Face cache."
|
|
152
|
+
self.app.push_screen(
|
|
153
|
+
ConfirmScreen(msg, detail=detail),
|
|
154
|
+
lambda ok, r=model.repo_id: self._do_delete_downloaded(r) if ok else None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@work(thread=True, exclusive=True)
|
|
158
|
+
def _do_delete_downloaded(self, repo_id: str) -> None:
|
|
159
|
+
try:
|
|
160
|
+
artifacts.delete_downloaded_model(repo_id)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
data = artifacts.list_downloaded_models()
|
|
164
|
+
self.app.call_from_thread(self._fill_downloaded, data)
|
|
165
|
+
|
|
166
|
+
def _delete_quantized(self) -> None:
|
|
167
|
+
row = self.query_one("#q-table", DataTable).cursor_row
|
|
168
|
+
if row is None or row >= len(self._quantized):
|
|
169
|
+
return
|
|
170
|
+
artifact = self._quantized[row]
|
|
171
|
+
msg = f"Delete quantized model at '{artifact.path}'?"
|
|
172
|
+
detail = f"Permanently removes the directory ({units.humanize_bytes(artifact.size_bytes)})."
|
|
173
|
+
self.app.push_screen(
|
|
174
|
+
ConfirmScreen(msg, detail=detail),
|
|
175
|
+
lambda ok, p=artifact.path: self._do_delete_quantized(p) if ok else None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _do_delete_quantized(self, path: str) -> None:
|
|
179
|
+
artifacts.default_quantized_registry().delete(path)
|
|
180
|
+
self._refresh_quantized()
|
|
181
|
+
|
|
182
|
+
def action_open(self) -> None:
|
|
183
|
+
if self._active() != "tab-quantized":
|
|
184
|
+
return
|
|
185
|
+
row = self.query_one("#q-table", DataTable).cursor_row
|
|
186
|
+
if row is None or row >= len(self._quantized):
|
|
187
|
+
return
|
|
188
|
+
_open_folder(self._quantized[row].path)
|
|
189
|
+
|
|
190
|
+
def action_token_edit(self) -> None:
|
|
191
|
+
if self._active() == "tab-token":
|
|
192
|
+
self.app.push_screen(TokenScreen(), lambda _changed: self._refresh_token())
|
|
193
|
+
|
|
194
|
+
def action_token_clear(self) -> None:
|
|
195
|
+
if self._active() != "tab-token" or not hf_auth.current_token():
|
|
196
|
+
return
|
|
197
|
+
self.app.push_screen(
|
|
198
|
+
ConfirmScreen("Clear the saved Hugging Face token?"),
|
|
199
|
+
lambda ok: self._do_clear_token() if ok else None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _do_clear_token(self) -> None:
|
|
203
|
+
hf_auth.clear_token()
|
|
204
|
+
self._refresh_token()
|
|
205
|
+
|
|
206
|
+
def action_back(self) -> None:
|
|
207
|
+
self.app.pop_screen()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _open_folder(path: str) -> None:
|
|
211
|
+
try:
|
|
212
|
+
if sys.platform.startswith("win"):
|
|
213
|
+
os.startfile(path) # noqa: S606
|
|
214
|
+
elif sys.platform == "darwin":
|
|
215
|
+
subprocess.Popen(["open", path])
|
|
216
|
+
else:
|
|
217
|
+
subprocess.Popen(["xdg-open", path])
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A modal dialog for entering / clearing a Hugging Face token.
|
|
2
|
+
|
|
3
|
+
Keyboard-driven: Enter validates & saves, Ctrl+D clears, Esc closes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import on, work
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Vertical
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Input, Static
|
|
15
|
+
|
|
16
|
+
from zeroquantz.models import hf_auth
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TokenScreen(ModalScreen[bool]):
|
|
20
|
+
"""Enter, validate, and persist a Hugging Face access token."""
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("escape", "close", "Close"),
|
|
24
|
+
Binding("ctrl+d", "clear", "Clear token"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
with Vertical(id="token-dialog"):
|
|
29
|
+
yield Static("Hugging Face token", classes="dialog-title")
|
|
30
|
+
yield Static(
|
|
31
|
+
"Add a token to access gated models (Llama, etc.) and raise Hub rate limits.\n"
|
|
32
|
+
"Create one at https://huggingface.co/settings/tokens (read scope is enough).",
|
|
33
|
+
classes="dialog-sub",
|
|
34
|
+
)
|
|
35
|
+
yield Static("", id="token-status")
|
|
36
|
+
yield Input(placeholder="hf_…", password=True, id="token-input")
|
|
37
|
+
yield Static(
|
|
38
|
+
"[white]Enter[/white] save [white]Ctrl+D[/white] clear [white]Esc[/white] close",
|
|
39
|
+
id="token-hint",
|
|
40
|
+
classes="page-hint",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
self._refresh_status()
|
|
45
|
+
self.query_one("#token-input", Input).focus()
|
|
46
|
+
|
|
47
|
+
def _refresh_status(self) -> None:
|
|
48
|
+
token = hf_auth.current_token()
|
|
49
|
+
status = self.query_one("#token-status", Static)
|
|
50
|
+
if token:
|
|
51
|
+
status.update(Text.assemble(("● ", "#3fb950"), (f"token set ({hf_auth.masked(token)})", "#3fb950")))
|
|
52
|
+
else:
|
|
53
|
+
status.update(Text("○ no token set", style="#8b949e"))
|
|
54
|
+
|
|
55
|
+
# ---- actions ------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
@on(Input.Submitted, "#token-input")
|
|
58
|
+
def _submit(self, event: Input.Submitted) -> None:
|
|
59
|
+
token = event.value.strip()
|
|
60
|
+
if not token:
|
|
61
|
+
self.query_one("#token-status", Static).update(Text("Enter a token first.", style="#d29922"))
|
|
62
|
+
return
|
|
63
|
+
self.query_one("#token-status", Static).update(Text("checking token …", style="#d29922"))
|
|
64
|
+
self._validate(token)
|
|
65
|
+
|
|
66
|
+
@work(thread=True, exclusive=True)
|
|
67
|
+
def _validate(self, token: str) -> None:
|
|
68
|
+
try:
|
|
69
|
+
name = hf_auth.save_token(token)
|
|
70
|
+
self.app.call_from_thread(self._saved, name)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
message = exc.format() if hasattr(exc, "format") else str(exc)
|
|
73
|
+
self.app.call_from_thread(self._failed, message)
|
|
74
|
+
|
|
75
|
+
def _saved(self, name: str | None) -> None:
|
|
76
|
+
who = f" as {name}" if name else ""
|
|
77
|
+
self.query_one("#token-status", Static).update(
|
|
78
|
+
Text(f"✓ signed in{who} — gated models & higher limits enabled", style="#3fb950")
|
|
79
|
+
)
|
|
80
|
+
self.query_one("#token-input", Input).value = ""
|
|
81
|
+
self._changed = True
|
|
82
|
+
|
|
83
|
+
def _failed(self, message: str) -> None:
|
|
84
|
+
self.query_one("#token-status", Static).update(Text(f"✗ {message.splitlines()[0]}", style="#f85149"))
|
|
85
|
+
|
|
86
|
+
def action_clear(self) -> None:
|
|
87
|
+
hf_auth.clear_token()
|
|
88
|
+
self._changed = True
|
|
89
|
+
self.query_one("#token-input", Input).value = ""
|
|
90
|
+
self._refresh_status()
|
|
91
|
+
self.query_one("#token-status", Static).update(Text("token removed", style="#8b949e"))
|
|
92
|
+
|
|
93
|
+
def action_close(self) -> None:
|
|
94
|
+
self.dismiss(getattr(self, "_changed", False))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The landing screen: ZEROQUANTZ wordmark, hardware status, arrow-key menu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
|
|
13
|
+
from zeroquantz.models import hf_auth
|
|
14
|
+
from zeroquantz.tui.banner import banner_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WelcomeScreen(Screen):
|
|
18
|
+
"""First screen — mirrors the requested launcher aesthetic."""
|
|
19
|
+
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
with Vertical(id="welcome"):
|
|
22
|
+
yield Static("", classes="banner", id="banner")
|
|
23
|
+
yield Static(
|
|
24
|
+
"Interactive Model Optimization Environment", classes="tagline"
|
|
25
|
+
)
|
|
26
|
+
yield Static("", classes="status-line", id="status")
|
|
27
|
+
yield Static(
|
|
28
|
+
"What would you like to do? (Use arrow keys)", classes="prompt-line"
|
|
29
|
+
)
|
|
30
|
+
yield OptionList(id="menu")
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self._refresh_banner()
|
|
34
|
+
self._refresh_status()
|
|
35
|
+
self._build_menu()
|
|
36
|
+
self.query_one("#menu", OptionList).focus()
|
|
37
|
+
|
|
38
|
+
def on_resize(self) -> None:
|
|
39
|
+
self._refresh_banner()
|
|
40
|
+
|
|
41
|
+
# ---- content ------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _refresh_banner(self) -> None:
|
|
44
|
+
width = self.app.size.width or 80
|
|
45
|
+
self.query_one("#banner", Static).update(
|
|
46
|
+
Text(banner_text(width - 4), style="bold #c9d1d9", justify="center")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _refresh_status(self) -> None:
|
|
50
|
+
hw = self.app.ctx.hardware
|
|
51
|
+
text = Text()
|
|
52
|
+
if hw.cuda_available:
|
|
53
|
+
text.append("● ", style="#3fb950")
|
|
54
|
+
text.append(hw.gpu_name, style="#3fb950")
|
|
55
|
+
text.append(f" VRAM {hw.total_vram_gb:g} GB", style="#8b949e")
|
|
56
|
+
text.append(f" CUDA {hw.cuda_version or 'detected'}", style="#8b949e")
|
|
57
|
+
else:
|
|
58
|
+
text.append("⚠ No CUDA GPU detected — planning-only mode", style="#d29922")
|
|
59
|
+
if hf_auth.current_token():
|
|
60
|
+
text.append(" HF token ✓", style="#3fb950")
|
|
61
|
+
self.query_one("#status", Static).update(text)
|
|
62
|
+
|
|
63
|
+
def _build_menu(self) -> None:
|
|
64
|
+
menu = self.query_one("#menu", OptionList)
|
|
65
|
+
menu.clear_options()
|
|
66
|
+
self._items = [
|
|
67
|
+
("optimize", "Optimize a model", "guided: model → quantization → export", "●", "bold #58a6ff"),
|
|
68
|
+
("hardware", "Inspect hardware", "detected GPU & precision support", "●", "bold #58a6ff"),
|
|
69
|
+
("command", "Command mode & help", "slash commands, natural language, /help", "●", "bold #58a6ff"),
|
|
70
|
+
]
|
|
71
|
+
latest = self.app.ctx.repo.latest()
|
|
72
|
+
if latest is not None:
|
|
73
|
+
self._items.append(
|
|
74
|
+
("resume", "Resume last session", f"continue “{latest.name}”", "●", "bold #58a6ff")
|
|
75
|
+
)
|
|
76
|
+
self._items.append((
|
|
77
|
+
"settings", "Settings",
|
|
78
|
+
"manage downloaded / quantized models & HF token", "●", "bold #58a6ff",
|
|
79
|
+
))
|
|
80
|
+
self._items.append(("exit", "Exit", "", "○", "#8b949e"))
|
|
81
|
+
for idx, item in enumerate(self._items):
|
|
82
|
+
menu.add_option(Option(_option_prompt(item, pointed=idx == 0), id=item[0]))
|
|
83
|
+
|
|
84
|
+
@on(OptionList.OptionHighlighted)
|
|
85
|
+
def _highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
86
|
+
if not getattr(self, "_items", None):
|
|
87
|
+
return
|
|
88
|
+
menu = event.option_list
|
|
89
|
+
try: # move the → pointer to the highlighted row (graceful if API differs)
|
|
90
|
+
for idx, item in enumerate(self._items):
|
|
91
|
+
menu.replace_option_prompt_at_index(
|
|
92
|
+
idx, _option_prompt(item, pointed=idx == event.option_index)
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# ---- actions ------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@on(OptionList.OptionSelected)
|
|
100
|
+
def _selected(self, event: OptionList.OptionSelected) -> None:
|
|
101
|
+
choice = event.option.id
|
|
102
|
+
if choice == "exit":
|
|
103
|
+
self.app.exit()
|
|
104
|
+
elif choice == "hardware":
|
|
105
|
+
self.app.open_workspace("/hardware")
|
|
106
|
+
elif choice == "command":
|
|
107
|
+
self.app.open_workspace("/help") # enter command mode with help shown
|
|
108
|
+
elif choice == "settings":
|
|
109
|
+
self.app.push_settings()
|
|
110
|
+
elif choice == "resume":
|
|
111
|
+
self.app.resume_last_session()
|
|
112
|
+
else: # optimize -> guided wizard
|
|
113
|
+
self.app.start_wizard()
|
|
114
|
+
|
|
115
|
+
def on_screen_resume(self) -> None:
|
|
116
|
+
# Returning from Settings may have changed the token — refresh the chip.
|
|
117
|
+
self._refresh_status()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _option_prompt(item: tuple[str, str, str, str, str], *, pointed: bool) -> Text:
|
|
121
|
+
_id, label, desc, bullet, label_style = item
|
|
122
|
+
text = Text()
|
|
123
|
+
text.append("→ " if pointed else " ", style="bold #3fb950")
|
|
124
|
+
text.append(f"{bullet} ", style="#3fb950" if bullet == "●" else "#6e7681")
|
|
125
|
+
text.append(label, style=label_style)
|
|
126
|
+
if desc:
|
|
127
|
+
text.append(f" — {desc}", style="#6e7681")
|
|
128
|
+
return text
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The main workspace: conversation log, input, and the state sidebar.
|
|
2
|
+
|
|
3
|
+
User input and slash commands are parsed to intents and dispatched on a worker
|
|
4
|
+
thread (so a long quantize/benchmark never freezes the UI), with progress shown
|
|
5
|
+
live. Results render through the shared :mod:`zeroquantz.render` layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import work
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Horizontal
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
from textual.widgets import Input, RichLog, Static
|
|
17
|
+
|
|
18
|
+
from zeroquantz.agent.dispatcher import COMMANDS
|
|
19
|
+
from zeroquantz.agent.intents import IntentKind
|
|
20
|
+
from zeroquantz.agent.parser import IntentParser
|
|
21
|
+
from zeroquantz.render import render_result
|
|
22
|
+
from zeroquantz.tui.widgets import Sidebar, StatusBar
|
|
23
|
+
|
|
24
|
+
_SLASH_COMMANDS = [cmd.split()[0] for cmd, _ in COMMANDS]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkspaceScreen(Screen):
|
|
28
|
+
BINDINGS = [
|
|
29
|
+
Binding("ctrl+l", "clear", "Clear"),
|
|
30
|
+
Binding("ctrl+b", "toggle_sidebar", "Sidebar"),
|
|
31
|
+
Binding("ctrl+c", "quit", "Quit", priority=True),
|
|
32
|
+
Binding("escape", "home", "Home"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._history: list[str] = []
|
|
38
|
+
self._hist_idx = 0
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
yield StatusBar(id="topbar")
|
|
42
|
+
with Horizontal(id="body"):
|
|
43
|
+
yield RichLog(id="conversation", wrap=True, highlight=False, markup=False)
|
|
44
|
+
yield Sidebar(id="sidebar")
|
|
45
|
+
yield Static("", id="progress")
|
|
46
|
+
yield Input(
|
|
47
|
+
placeholder="Type a command or ask in plain English… (/help, /exit)",
|
|
48
|
+
id="prompt",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
log = self.query_one("#conversation", RichLog)
|
|
53
|
+
log.write(
|
|
54
|
+
Text.assemble(
|
|
55
|
+
("ZeroQuantz ready. ", "bold #3fb950"),
|
|
56
|
+
("Try ", "#8b949e"),
|
|
57
|
+
("/model Qwen/Qwen3-8B", "#58a6ff"),
|
|
58
|
+
(" or “fit under 8GB for vLLM”.", "#8b949e"),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
self._refresh_panels()
|
|
62
|
+
self.query_one("#prompt", Input).focus()
|
|
63
|
+
pending = getattr(self.app, "pending_command", None)
|
|
64
|
+
if pending:
|
|
65
|
+
self.app.pending_command = None
|
|
66
|
+
self._echo(pending)
|
|
67
|
+
self._dispatch(pending)
|
|
68
|
+
|
|
69
|
+
# ---- input handling -----------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
72
|
+
text = event.value.strip()
|
|
73
|
+
event.input.value = ""
|
|
74
|
+
if not text:
|
|
75
|
+
return
|
|
76
|
+
self._history.append(text)
|
|
77
|
+
self._hist_idx = len(self._history)
|
|
78
|
+
self._echo(text)
|
|
79
|
+
# EXIT is handled synchronously so the app closes immediately.
|
|
80
|
+
if IntentParser.parse(text).kind is IntentKind.EXIT:
|
|
81
|
+
self.app.exit()
|
|
82
|
+
return
|
|
83
|
+
self._dispatch(text)
|
|
84
|
+
|
|
85
|
+
def on_key(self, event) -> None: # noqa: ANN001
|
|
86
|
+
inp = self.query_one("#prompt", Input)
|
|
87
|
+
if not inp.has_focus:
|
|
88
|
+
return
|
|
89
|
+
if event.key == "up":
|
|
90
|
+
self._history_step(-1)
|
|
91
|
+
event.stop()
|
|
92
|
+
elif event.key == "down":
|
|
93
|
+
self._history_step(1)
|
|
94
|
+
event.stop()
|
|
95
|
+
elif event.key == "tab":
|
|
96
|
+
self._autocomplete()
|
|
97
|
+
event.stop()
|
|
98
|
+
event.prevent_default()
|
|
99
|
+
|
|
100
|
+
def _history_step(self, delta: int) -> None:
|
|
101
|
+
if not self._history:
|
|
102
|
+
return
|
|
103
|
+
self._hist_idx = max(0, min(len(self._history), self._hist_idx + delta))
|
|
104
|
+
inp = self.query_one("#prompt", Input)
|
|
105
|
+
inp.value = self._history[self._hist_idx] if self._hist_idx < len(self._history) else ""
|
|
106
|
+
inp.cursor_position = len(inp.value)
|
|
107
|
+
|
|
108
|
+
def _autocomplete(self) -> None:
|
|
109
|
+
inp = self.query_one("#prompt", Input)
|
|
110
|
+
value = inp.value
|
|
111
|
+
if not value.startswith("/"):
|
|
112
|
+
return
|
|
113
|
+
matches = [c for c in _SLASH_COMMANDS if c.startswith(value)]
|
|
114
|
+
if len(matches) == 1:
|
|
115
|
+
inp.value = matches[0] + " "
|
|
116
|
+
inp.cursor_position = len(inp.value)
|
|
117
|
+
elif len(matches) > 1:
|
|
118
|
+
self.query_one("#conversation", RichLog).write(
|
|
119
|
+
Text(" ".join(matches), style="#6e7681")
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# ---- dispatch -----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@work(thread=True, exclusive=True)
|
|
125
|
+
def _dispatch(self, text: str) -> None:
|
|
126
|
+
intent = IntentParser.parse(text)
|
|
127
|
+
|
|
128
|
+
def progress(stage: str, fraction: float) -> None:
|
|
129
|
+
self.app.call_from_thread(self._set_progress, stage, fraction)
|
|
130
|
+
|
|
131
|
+
result = self.app.dispatcher.dispatch(intent, self.app.ctx, progress)
|
|
132
|
+
self.app.call_from_thread(self._show_result, result)
|
|
133
|
+
|
|
134
|
+
def _show_result(self, result) -> None: # noqa: ANN001
|
|
135
|
+
self._set_progress(None, 0.0)
|
|
136
|
+
self.query_one("#conversation", RichLog).write(render_result(result))
|
|
137
|
+
self._refresh_panels()
|
|
138
|
+
if result.should_exit:
|
|
139
|
+
self.app.exit()
|
|
140
|
+
|
|
141
|
+
def _set_progress(self, stage: str | None, fraction: float) -> None:
|
|
142
|
+
prog = self.query_one("#progress", Static)
|
|
143
|
+
if stage is None:
|
|
144
|
+
prog.remove_class("active")
|
|
145
|
+
prog.update("")
|
|
146
|
+
return
|
|
147
|
+
filled = int(round(fraction * 20))
|
|
148
|
+
bar = "█" * filled + "░" * (20 - filled)
|
|
149
|
+
prog.update(Text(f" {bar} {stage} ({fraction*100:.0f}%)", style="#d29922"))
|
|
150
|
+
prog.add_class("active")
|
|
151
|
+
|
|
152
|
+
# ---- helpers ------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _echo(self, text: str) -> None:
|
|
155
|
+
self.query_one("#conversation", RichLog).write(
|
|
156
|
+
Text.assemble(("› ", "bold #3fb950"), (text, "bold white"))
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _refresh_panels(self) -> None:
|
|
160
|
+
self.query_one("#topbar", StatusBar).update_context(self.app.ctx)
|
|
161
|
+
self.query_one("#sidebar", Sidebar).update_context(self.app.ctx)
|
|
162
|
+
|
|
163
|
+
# ---- actions ------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def action_clear(self) -> None:
|
|
166
|
+
self.query_one("#conversation", RichLog).clear()
|
|
167
|
+
|
|
168
|
+
def action_toggle_sidebar(self) -> None:
|
|
169
|
+
self.query_one("#sidebar", Sidebar).toggle_class("hidden")
|
|
170
|
+
|
|
171
|
+
def action_home(self) -> None:
|
|
172
|
+
self.app.pop_screen()
|
|
173
|
+
|
|
174
|
+
def action_quit(self) -> None:
|
|
175
|
+
self.app.exit()
|