localcoder 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.
- localcoder/__init__.py +2 -0
- localcoder/__main__.py +2 -0
- localcoder/agent.py +35 -0
- localcoder/backends.py +2470 -0
- localcoder/bench.py +335 -0
- localcoder/cli.py +827 -0
- localcoder/gemma4coder_display.py +583 -0
- localcoder/setup.py +321 -0
- localcoder/tui.py +276 -0
- localcoder/voice.py +187 -0
- localcoder-0.1.0.dist-info/METADATA +187 -0
- localcoder-0.1.0.dist-info/RECORD +15 -0
- localcoder-0.1.0.dist-info/WHEEL +4 -0
- localcoder-0.1.0.dist-info/entry_points.txt +2 -0
- localcoder-0.1.0.dist-info/licenses/LICENSE +4 -0
localcoder/setup.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Interactive setup wizard — runs on first launch or `localcoder --setup`."""
|
|
2
|
+
import json, os, sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from localcoder.backends import (
|
|
11
|
+
BACKENDS, MODELS, CONFIG_DIR, discover_all, get_system_ram_gb,
|
|
12
|
+
check_backend_installed, install_backend, download_model_hf,
|
|
13
|
+
download_model_ollama, find_model_file, start_llama_server,
|
|
14
|
+
start_ollama_serve, check_backend_running,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config():
|
|
22
|
+
if CONFIG_FILE.exists():
|
|
23
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_config(cfg):
|
|
28
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wizard():
|
|
33
|
+
"""Interactive first-run setup wizard."""
|
|
34
|
+
console.print()
|
|
35
|
+
title = Text()
|
|
36
|
+
title.append("◆ ", style="bold magenta")
|
|
37
|
+
title.append("localcoder setup", style="bold white")
|
|
38
|
+
console.print(Panel(
|
|
39
|
+
"[bold]Welcome! Let's get your local AI coding agent running.[/]\n"
|
|
40
|
+
"[dim]This wizard will install a backend, download a model, and start serving.[/]",
|
|
41
|
+
title=title, title_align="left",
|
|
42
|
+
border_style="magenta", padding=(1, 2),
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
ram = get_system_ram_gb()
|
|
46
|
+
console.print(f"\n [dim]System RAM:[/] [bold]{ram} GB[/]")
|
|
47
|
+
|
|
48
|
+
# ── Step 1: Detect backends ──
|
|
49
|
+
console.print(f"\n [bold magenta]Step 1:[/] Checking backends...\n")
|
|
50
|
+
discovery = discover_all()
|
|
51
|
+
|
|
52
|
+
table = Table(show_header=True, header_style="bold cyan", padding=(0, 2))
|
|
53
|
+
table.add_column("Backend")
|
|
54
|
+
table.add_column("Installed")
|
|
55
|
+
table.add_column("Running")
|
|
56
|
+
table.add_column("Models")
|
|
57
|
+
|
|
58
|
+
for d in discovery:
|
|
59
|
+
installed = "[green]✓[/]" if d["installed"] else "[red]✗[/]"
|
|
60
|
+
running = f"[green]:{d['port']}[/]" if d["running"] else "[dim]—[/]"
|
|
61
|
+
models = ", ".join(d["models"][:3]) if d["models"] else "[dim]none[/]"
|
|
62
|
+
table.add_row(d["name"], installed, running, models)
|
|
63
|
+
|
|
64
|
+
console.print(table)
|
|
65
|
+
|
|
66
|
+
# ── Step 2: Install backend if needed ──
|
|
67
|
+
any_installed = any(d["installed"] for d in discovery)
|
|
68
|
+
if not any_installed:
|
|
69
|
+
console.print(f"\n [bold magenta]Step 2:[/] No backend found. Install one:\n")
|
|
70
|
+
console.print(f" [bold]1.[/] llama.cpp via Unsloth [dim](recommended for 26B, best speed)[/]")
|
|
71
|
+
console.print(f" [bold]2.[/] Ollama [dim](easiest, good for E4B/E2B)[/]")
|
|
72
|
+
console.print(f" [bold]3.[/] Both")
|
|
73
|
+
console.print()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
choice = input(" Choose (1/2/3): ").strip()
|
|
77
|
+
except (EOFError, KeyboardInterrupt):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
if choice in ("1", "3"):
|
|
81
|
+
install_backend("llamacpp")
|
|
82
|
+
if choice in ("2", "3"):
|
|
83
|
+
install_backend("ollama")
|
|
84
|
+
|
|
85
|
+
# Re-discover
|
|
86
|
+
discovery = discover_all()
|
|
87
|
+
|
|
88
|
+
# ── Step 3: Choose model ──
|
|
89
|
+
console.print(f"\n [bold magenta]Step 3:[/] Choose a model:\n")
|
|
90
|
+
|
|
91
|
+
recommended = []
|
|
92
|
+
for mid, m in MODELS.items():
|
|
93
|
+
fits = ram >= m["ram_required"]
|
|
94
|
+
rec = " [green](recommended)[/]" if fits and mid == "gemma4-26b" and ram >= 24 else ""
|
|
95
|
+
if not fits:
|
|
96
|
+
rec = " [red](needs {m['ram_required']}GB+)[/]"
|
|
97
|
+
recommended.append((mid, m, fits, rec))
|
|
98
|
+
|
|
99
|
+
for i, (mid, m, fits, rec) in enumerate(recommended):
|
|
100
|
+
style = "bold" if fits else "dim"
|
|
101
|
+
console.print(f" [{style}]{i+1}. {m['name']}[/{style}] [dim]({m['size_gb']}GB, {m['description']})[/]{rec}")
|
|
102
|
+
|
|
103
|
+
console.print()
|
|
104
|
+
try:
|
|
105
|
+
choice = input(" Choose model (1-4): ").strip()
|
|
106
|
+
except (EOFError, KeyboardInterrupt):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
idx = int(choice) - 1 if choice.isdigit() else 0
|
|
110
|
+
if idx < 0 or idx >= len(recommended):
|
|
111
|
+
idx = 0
|
|
112
|
+
|
|
113
|
+
model_id, model_info, _, _ = recommended[idx]
|
|
114
|
+
console.print(f"\n [green]Selected: {model_info['name']}[/]")
|
|
115
|
+
|
|
116
|
+
# ── Step 4: Download model ──
|
|
117
|
+
console.print(f"\n [bold magenta]Step 4:[/] Downloading model...\n")
|
|
118
|
+
|
|
119
|
+
model_file = find_model_file(model_id)
|
|
120
|
+
if model_file:
|
|
121
|
+
console.print(f" [green]✓ Model already downloaded: {os.path.basename(model_file)}[/]")
|
|
122
|
+
else:
|
|
123
|
+
if model_info.get("backend") == "llamacpp" and model_info.get("hf_repo"):
|
|
124
|
+
download_model_hf(model_id)
|
|
125
|
+
elif model_info.get("ollama_tag"):
|
|
126
|
+
# Ensure Ollama is running
|
|
127
|
+
if not check_backend_running("ollama"):
|
|
128
|
+
start_ollama_serve()
|
|
129
|
+
download_model_ollama(model_id)
|
|
130
|
+
|
|
131
|
+
# ── Step 5: Determine backend + API URL ──
|
|
132
|
+
backend_id = model_info.get("backend", "ollama")
|
|
133
|
+
port = BACKENDS[backend_id]["default_port"]
|
|
134
|
+
api_url = f"http://127.0.0.1:{port}/v1"
|
|
135
|
+
|
|
136
|
+
# For llama.cpp models, start the server
|
|
137
|
+
if backend_id == "llamacpp":
|
|
138
|
+
if not check_backend_running("llamacpp"):
|
|
139
|
+
console.print(f"\n [bold magenta]Step 5:[/] Starting llama-server...\n")
|
|
140
|
+
proc = start_llama_server(model_id, port)
|
|
141
|
+
if not proc:
|
|
142
|
+
console.print(f" [yellow]Falling back to Ollama...[/]")
|
|
143
|
+
backend_id = "ollama"
|
|
144
|
+
port = 11434
|
|
145
|
+
api_url = f"http://127.0.0.1:{port}/v1"
|
|
146
|
+
if model_info.get("ollama_tag"):
|
|
147
|
+
start_ollama_serve()
|
|
148
|
+
download_model_ollama(model_id)
|
|
149
|
+
else:
|
|
150
|
+
console.print(f"\n [green]✓ llama-server already running on :{port}[/]")
|
|
151
|
+
else:
|
|
152
|
+
if not check_backend_running("ollama"):
|
|
153
|
+
console.print(f"\n [bold magenta]Step 5:[/] Starting Ollama...\n")
|
|
154
|
+
start_ollama_serve()
|
|
155
|
+
|
|
156
|
+
# ── Step 6: Save config ──
|
|
157
|
+
model_name = model_info.get("ollama_tag", model_id)
|
|
158
|
+
if backend_id == "llamacpp":
|
|
159
|
+
# Get actual model name from server
|
|
160
|
+
from localcoder.backends import get_running_models
|
|
161
|
+
running = get_running_models("llamacpp")
|
|
162
|
+
if running:
|
|
163
|
+
model_name = running[0]
|
|
164
|
+
|
|
165
|
+
cfg = {
|
|
166
|
+
"model": model_name,
|
|
167
|
+
"api_base": api_url,
|
|
168
|
+
"backend": backend_id,
|
|
169
|
+
"model_id": model_id,
|
|
170
|
+
"setup_complete": True,
|
|
171
|
+
}
|
|
172
|
+
save_config(cfg)
|
|
173
|
+
|
|
174
|
+
console.print(f"\n [bold magenta]Step 6:[/] Configuration saved.\n")
|
|
175
|
+
|
|
176
|
+
# ── Step 7: Configure OpenCode / OpenClaw (optional) ──
|
|
177
|
+
console.print(f" [bold magenta]Step 7:[/] Configure other tools?\n")
|
|
178
|
+
|
|
179
|
+
import shutil
|
|
180
|
+
has_opencode = shutil.which("opencode")
|
|
181
|
+
has_openclaw = shutil.which("openclaw")
|
|
182
|
+
|
|
183
|
+
if has_opencode or has_openclaw:
|
|
184
|
+
tools_found = []
|
|
185
|
+
if has_opencode:
|
|
186
|
+
tools_found.append("OpenCode")
|
|
187
|
+
if has_openclaw:
|
|
188
|
+
tools_found.append("OpenClaw")
|
|
189
|
+
console.print(f" [green]Found:[/] {', '.join(tools_found)}")
|
|
190
|
+
console.print(f" [dim]Auto-configure them to use your local model?[/]")
|
|
191
|
+
console.print(f" [bold]1.[/] Yes — configure all [dim](recommended)[/]")
|
|
192
|
+
console.print(f" [bold]2.[/] No — skip")
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
ans = input("\n Choose (1/2): ").strip()
|
|
196
|
+
except (EOFError, KeyboardInterrupt):
|
|
197
|
+
ans = "2"
|
|
198
|
+
|
|
199
|
+
if ans == "1":
|
|
200
|
+
_configure_opencode(api_url, model_name, model_id, model_info)
|
|
201
|
+
_configure_openclaw(api_url, model_name, model_id, model_info)
|
|
202
|
+
else:
|
|
203
|
+
console.print(f" [dim]No OpenCode or OpenClaw found. Install with:[/]")
|
|
204
|
+
console.print(f" [dim]curl -fsSL https://opencode.ai/install | bash[/]")
|
|
205
|
+
console.print(f" [dim]brew install openclaw[/]")
|
|
206
|
+
|
|
207
|
+
# ── Done ──
|
|
208
|
+
console.print()
|
|
209
|
+
console.print(Panel(
|
|
210
|
+
Text.assemble(
|
|
211
|
+
("Setup complete! ", "bold green"),
|
|
212
|
+
("Run ", "dim"), ("localcoder", "bold cyan"), (" to start.\n\n", "dim"),
|
|
213
|
+
("Model: ", "dim"), (f"{model_info['name']}\n", "bold cyan"),
|
|
214
|
+
("Backend: ", "dim"), (f"{BACKENDS[backend_id]['name']} (:{port})\n", "green"),
|
|
215
|
+
("API: ", "dim"), (f"{api_url}\n", "dim"),
|
|
216
|
+
),
|
|
217
|
+
border_style="green", padding=(1, 2),
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
return cfg
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _configure_opencode(api_url, model_name, model_id, model_info):
|
|
224
|
+
"""Auto-configure OpenCode to use the local model."""
|
|
225
|
+
import shutil
|
|
226
|
+
if not shutil.which("opencode"):
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
config_path = Path.home() / ".config/opencode/opencode.json"
|
|
230
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
# Load existing or create new
|
|
233
|
+
existing = {}
|
|
234
|
+
if config_path.exists():
|
|
235
|
+
try:
|
|
236
|
+
existing = json.loads(config_path.read_text())
|
|
237
|
+
except:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
# Add/update llamacpp provider
|
|
241
|
+
if "provider" not in existing:
|
|
242
|
+
existing["provider"] = {}
|
|
243
|
+
|
|
244
|
+
existing["provider"]["llamacpp"] = {
|
|
245
|
+
"name": "llama.cpp (local)",
|
|
246
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
247
|
+
"options": {"baseURL": api_url},
|
|
248
|
+
"models": {
|
|
249
|
+
model_id: {
|
|
250
|
+
"name": model_info.get("name", model_name),
|
|
251
|
+
"tool_call": True,
|
|
252
|
+
"reasoning": False,
|
|
253
|
+
"modalities": {"input": ["text", "image"], "output": ["text"]},
|
|
254
|
+
"limit": {"context": 131072, "output": 8192},
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
existing["$schema"] = "https://opencode.ai/config.json"
|
|
259
|
+
existing["model"] = f"llamacpp/{model_id}"
|
|
260
|
+
|
|
261
|
+
config_path.write_text(json.dumps(existing, indent=2))
|
|
262
|
+
console.print(f" [green]✓ OpenCode configured[/] [dim]({config_path})[/]")
|
|
263
|
+
console.print(f" [dim]Model: llamacpp/{model_id} → {api_url}[/]")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _configure_openclaw(api_url, model_name, model_id, model_info):
|
|
267
|
+
"""Auto-configure OpenClaw to use the local model."""
|
|
268
|
+
import shutil
|
|
269
|
+
if not shutil.which("openclaw"):
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
config_path = Path.home() / ".openclaw/openclaw.json"
|
|
273
|
+
if not config_path.exists():
|
|
274
|
+
console.print(f" [dim]OpenClaw config not found — run 'openclaw' first to initialize[/]")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
cfg = json.loads(config_path.read_text())
|
|
279
|
+
except:
|
|
280
|
+
console.print(f" [yellow]Could not parse OpenClaw config[/]")
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Add/update llamacpp provider
|
|
284
|
+
if "models" not in cfg:
|
|
285
|
+
cfg["models"] = {"mode": "merge", "providers": {}}
|
|
286
|
+
if "providers" not in cfg["models"]:
|
|
287
|
+
cfg["models"]["providers"] = {}
|
|
288
|
+
|
|
289
|
+
cfg["models"]["providers"]["llamacpp"] = {
|
|
290
|
+
"api": "openai-completions",
|
|
291
|
+
"baseUrl": api_url,
|
|
292
|
+
"apiKey": "dummy",
|
|
293
|
+
"models": [{
|
|
294
|
+
"id": model_name,
|
|
295
|
+
"name": model_info.get("name", model_name),
|
|
296
|
+
"reasoning": False,
|
|
297
|
+
"contextWindow": 131072,
|
|
298
|
+
"maxTokens": 8192,
|
|
299
|
+
}],
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# Set as default model
|
|
303
|
+
if "agents" not in cfg:
|
|
304
|
+
cfg["agents"] = {"defaults": {}}
|
|
305
|
+
if "defaults" not in cfg["agents"]:
|
|
306
|
+
cfg["agents"]["defaults"] = {}
|
|
307
|
+
if "model" not in cfg["agents"]["defaults"]:
|
|
308
|
+
cfg["agents"]["defaults"]["model"] = {}
|
|
309
|
+
cfg["agents"]["defaults"]["model"]["primary"] = f"llamacpp/{model_name}"
|
|
310
|
+
|
|
311
|
+
config_path.write_text(json.dumps(cfg, indent=2))
|
|
312
|
+
console.print(f" [green]✓ OpenClaw configured[/] [dim]({config_path})[/]")
|
|
313
|
+
console.print(f" [dim]Model: llamacpp/{model_name} → {api_url}[/]")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def ensure_setup():
|
|
317
|
+
"""Check if setup is done, run wizard if not."""
|
|
318
|
+
cfg = load_config()
|
|
319
|
+
if cfg.get("setup_complete"):
|
|
320
|
+
return cfg
|
|
321
|
+
return wizard()
|
localcoder/tui.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""localcoder TUI — Textual-based fixed-layout GPU health dashboard."""
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.containers import Horizontal, Vertical, Container
|
|
4
|
+
from textual.widgets import Static, Footer, Header, DataTable, LoadingIndicator
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual import work
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StatusIndicator(Static):
|
|
10
|
+
"""Top status bar: HEALTHY / DEGRADED / CRITICAL."""
|
|
11
|
+
status = reactive("scanning")
|
|
12
|
+
|
|
13
|
+
def render(self):
|
|
14
|
+
colors = {
|
|
15
|
+
"healthy": "green", "degraded": "yellow",
|
|
16
|
+
"critical": "red", "scanning": "cyan",
|
|
17
|
+
}
|
|
18
|
+
c = colors.get(self.status, "dim")
|
|
19
|
+
return f"[bold {c}] {self.status.upper()} [/]"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GpuBar(Static):
|
|
23
|
+
"""Visual VRAM usage bar."""
|
|
24
|
+
gpu_alloc = reactive(0)
|
|
25
|
+
gpu_total = reactive(16384)
|
|
26
|
+
|
|
27
|
+
def render(self):
|
|
28
|
+
pct = min(1.0, self.gpu_alloc / max(1, self.gpu_total))
|
|
29
|
+
w = 40
|
|
30
|
+
filled = int(pct * w)
|
|
31
|
+
color = "green" if pct < 0.7 else "yellow" if pct < 0.9 else "red"
|
|
32
|
+
bar = f"[{color}]{'█' * filled}[/{color}][dim]{'░' * (w - filled)}[/dim]"
|
|
33
|
+
return f" VRAM {bar} {self.gpu_alloc // 1024}/{self.gpu_total // 1024}GB"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InfoCard(Static):
|
|
37
|
+
"""A status card (Compute / KV Cache / Memory)."""
|
|
38
|
+
DEFAULT_CSS = """
|
|
39
|
+
InfoCard {
|
|
40
|
+
width: 1fr;
|
|
41
|
+
height: auto;
|
|
42
|
+
min-height: 7;
|
|
43
|
+
border: solid $primary;
|
|
44
|
+
padding: 0 1;
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BottomBar(Static):
|
|
50
|
+
"""Pinned bottom status bar with GPU stats + shortcuts."""
|
|
51
|
+
DEFAULT_CSS = """
|
|
52
|
+
BottomBar {
|
|
53
|
+
dock: bottom;
|
|
54
|
+
height: 1;
|
|
55
|
+
background: $surface;
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
gpu_text = reactive("")
|
|
59
|
+
|
|
60
|
+
def render(self):
|
|
61
|
+
return self.gpu_text or " GPU --/-- SWAP -- MEM --"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HealthDashboard(App):
|
|
65
|
+
"""GPU Health Dashboard — fixed layout, no scrolling."""
|
|
66
|
+
|
|
67
|
+
CSS = """
|
|
68
|
+
Screen {
|
|
69
|
+
layout: vertical;
|
|
70
|
+
}
|
|
71
|
+
#header-bar {
|
|
72
|
+
height: 3;
|
|
73
|
+
padding: 0 1;
|
|
74
|
+
}
|
|
75
|
+
#cards-row {
|
|
76
|
+
height: auto;
|
|
77
|
+
min-height: 8;
|
|
78
|
+
max-height: 10;
|
|
79
|
+
}
|
|
80
|
+
#vram-bar {
|
|
81
|
+
height: 2;
|
|
82
|
+
padding: 0 1;
|
|
83
|
+
}
|
|
84
|
+
#procs-panel {
|
|
85
|
+
height: 1fr;
|
|
86
|
+
padding: 0 1;
|
|
87
|
+
}
|
|
88
|
+
#fixes-panel {
|
|
89
|
+
height: auto;
|
|
90
|
+
max-height: 8;
|
|
91
|
+
padding: 0 1;
|
|
92
|
+
border: solid $warning;
|
|
93
|
+
}
|
|
94
|
+
InfoCard {
|
|
95
|
+
border: solid $primary;
|
|
96
|
+
padding: 0 1;
|
|
97
|
+
width: 1fr;
|
|
98
|
+
}
|
|
99
|
+
#status-line {
|
|
100
|
+
height: 1;
|
|
101
|
+
dock: top;
|
|
102
|
+
}
|
|
103
|
+
DataTable {
|
|
104
|
+
height: 1fr;
|
|
105
|
+
}
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
BINDINGS = [
|
|
109
|
+
("q", "quit", "Quit"),
|
|
110
|
+
("c", "cleanup", "Cleanup"),
|
|
111
|
+
("d", "debloat", "Debloat"),
|
|
112
|
+
("s", "simulate", "Simulate"),
|
|
113
|
+
("r", "refresh", "Refresh"),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def compose(self) -> ComposeResult:
|
|
117
|
+
yield StatusIndicator(id="status-line")
|
|
118
|
+
yield Static(id="header-bar")
|
|
119
|
+
|
|
120
|
+
with Horizontal(id="cards-row"):
|
|
121
|
+
yield InfoCard(id="card-compute")
|
|
122
|
+
yield InfoCard(id="card-kv")
|
|
123
|
+
yield InfoCard(id="card-mem")
|
|
124
|
+
|
|
125
|
+
yield GpuBar(id="vram-bar")
|
|
126
|
+
|
|
127
|
+
yield Container(
|
|
128
|
+
DataTable(id="proc-table"),
|
|
129
|
+
id="procs-panel",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
yield Static(id="fixes-panel")
|
|
133
|
+
yield BottomBar(id="bottom-bar")
|
|
134
|
+
yield Footer()
|
|
135
|
+
|
|
136
|
+
def on_mount(self) -> None:
|
|
137
|
+
"""Start loading data."""
|
|
138
|
+
self.load_data()
|
|
139
|
+
|
|
140
|
+
@work(thread=True)
|
|
141
|
+
def load_data(self) -> None:
|
|
142
|
+
"""Load GPU data in background thread."""
|
|
143
|
+
from localcoder.backends import (
|
|
144
|
+
get_machine_specs, diagnose_gpu_health, get_metal_gpu_stats,
|
|
145
|
+
get_top_memory_processes, get_swap_usage_mb, _detect_model_info,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
specs = get_machine_specs()
|
|
149
|
+
diag = diagnose_gpu_health()
|
|
150
|
+
metal = get_metal_gpu_stats()
|
|
151
|
+
procs = get_top_memory_processes(min_mb=80, limit=8)
|
|
152
|
+
swap_mb = get_swap_usage_mb()
|
|
153
|
+
model_info = _detect_model_info(diag["server_config"], None)
|
|
154
|
+
|
|
155
|
+
# Update UI from worker thread
|
|
156
|
+
self.call_from_thread(self._update_ui, specs, diag, metal, procs, swap_mb, model_info)
|
|
157
|
+
|
|
158
|
+
def _update_ui(self, specs, diag, metal, procs, swap_mb, model_info):
|
|
159
|
+
"""Update all widgets with loaded data."""
|
|
160
|
+
# Status
|
|
161
|
+
status = self.query_one("#status-line", StatusIndicator)
|
|
162
|
+
status.status = diag["status"]
|
|
163
|
+
|
|
164
|
+
# Header
|
|
165
|
+
header = self.query_one("#header-bar", Static)
|
|
166
|
+
model_str = ""
|
|
167
|
+
if model_info["name"]:
|
|
168
|
+
parts = [model_info["name"]]
|
|
169
|
+
if model_info["quant"]:
|
|
170
|
+
parts.append(model_info["quant"])
|
|
171
|
+
if model_info["size_gb"]:
|
|
172
|
+
parts.append(f"{model_info['size_gb']}GB")
|
|
173
|
+
model_str = f"\n [cyan]{' · '.join(parts)}[/cyan]"
|
|
174
|
+
header.update(
|
|
175
|
+
f" [bold]{specs['chip']}[/bold] · {specs['ram_gb']}GB RAM · "
|
|
176
|
+
f"{specs.get('gpu_cores', '?')} GPU cores{model_str}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Compute card
|
|
180
|
+
srv = diag["server_config"]
|
|
181
|
+
if srv.get("running"):
|
|
182
|
+
gpu_icon = "[green]●[/] GPU (Metal)" if diag["on_gpu"] else "[red]●[/] CPU — SLOW"
|
|
183
|
+
compute = (
|
|
184
|
+
f"[bold]Compute[/bold]\n"
|
|
185
|
+
f"{gpu_icon}\n"
|
|
186
|
+
f"Layers: {diag['gpu_layers']}/99\n"
|
|
187
|
+
f"Util: {diag['gpu_util_pct']}%\n"
|
|
188
|
+
f"Model: {srv.get('footprint_mb', 0)} MB"
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
compute = "[bold]Compute[/bold]\n[dim]Server not running[/dim]"
|
|
192
|
+
self.query_one("#card-compute", InfoCard).update(compute)
|
|
193
|
+
|
|
194
|
+
# KV Cache card
|
|
195
|
+
kv_icon = "[green]●[/]" if diag["kv_quantized"] else "[red]●[/]"
|
|
196
|
+
kv_type = f"Type: {diag['kv_type']}\n" if diag["kv_type"] else ""
|
|
197
|
+
fa_icon = "[green]●[/]" if diag["flash_attn"] else "[yellow]●[/]"
|
|
198
|
+
self.query_one("#card-kv", InfoCard).update(
|
|
199
|
+
f"[bold]KV Cache[/bold]\n"
|
|
200
|
+
f"{kv_icon} {'Quantized' if diag['kv_quantized'] else 'Full (2x mem!)'}\n"
|
|
201
|
+
f"{kv_type}"
|
|
202
|
+
f"Size: ~{diag['kv_cache_est_mb']} MB\n"
|
|
203
|
+
f"Ctx: {diag['context_size'] // 1024}K\n"
|
|
204
|
+
f"{fa_icon} FlashAttn: {'on' if diag['flash_attn'] else 'off'}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Memory card
|
|
208
|
+
pc = {"normal": "green", "warn": "yellow", "critical": "red"}.get(diag["mem_pressure"], "dim")
|
|
209
|
+
sc = "red" if swap_mb > 4000 else "green"
|
|
210
|
+
headroom = diag["gpu_total_mb"] - diag["gpu_alloc_mb"]
|
|
211
|
+
hc = "green" if headroom > 2048 else "yellow" if headroom > 0 else "red"
|
|
212
|
+
self.query_one("#card-mem", InfoCard).update(
|
|
213
|
+
f"[bold]Memory[/bold]\n"
|
|
214
|
+
f"Pressure: [{pc}]{diag['mem_pressure']}[/{pc}]\n"
|
|
215
|
+
f"Swap: [{sc}]{swap_mb // 1024}GB[/{sc}]\n"
|
|
216
|
+
f"GPU: {diag['gpu_alloc_mb'] // 1024}/{diag['gpu_total_mb'] // 1024}GB\n"
|
|
217
|
+
f"Free: [{hc}]{headroom // 1024}GB[/{hc}]"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# VRAM bar
|
|
221
|
+
vram = self.query_one("#vram-bar", GpuBar)
|
|
222
|
+
vram.gpu_alloc = diag["gpu_alloc_mb"]
|
|
223
|
+
vram.gpu_total = diag["gpu_total_mb"]
|
|
224
|
+
|
|
225
|
+
# Process table
|
|
226
|
+
table = self.query_one("#proc-table", DataTable)
|
|
227
|
+
table.clear(columns=True)
|
|
228
|
+
table.add_columns("#", "Process", "Memory", "Type")
|
|
229
|
+
for i, p in enumerate(procs, 1):
|
|
230
|
+
mb = p["mb"]
|
|
231
|
+
size = f"{mb / 1024:.1f}G" if mb >= 1024 else f"{mb}M"
|
|
232
|
+
cat = {"ml": "ML", "app": "app", "system": "sys", "bloat": "bloat"}.get(p["category"], "?")
|
|
233
|
+
name = p["name"] + (f" ×{p['count']}" if p.get("count", 1) > 1 else "")
|
|
234
|
+
table.add_row(str(i), name, size, cat)
|
|
235
|
+
|
|
236
|
+
# Fixes
|
|
237
|
+
fixes = self.query_one("#fixes-panel", Static)
|
|
238
|
+
if diag["issues"]:
|
|
239
|
+
lines = []
|
|
240
|
+
for issue in diag["issues"]:
|
|
241
|
+
lines.append(f"[red]●[/] {issue}")
|
|
242
|
+
for fix in diag.get("fixes", []):
|
|
243
|
+
lines.append(f"[green]→[/] {fix}")
|
|
244
|
+
fixes.update("\n".join(lines))
|
|
245
|
+
else:
|
|
246
|
+
fixes.update("[green]All good — no issues detected[/]")
|
|
247
|
+
|
|
248
|
+
# Bottom bar
|
|
249
|
+
bar = self.query_one("#bottom-bar", BottomBar)
|
|
250
|
+
gc = "red" if diag["gpu_alloc_mb"] > diag["gpu_total_mb"] else "green"
|
|
251
|
+
bar.gpu_text = (
|
|
252
|
+
f" GPU [{gc}]{diag['gpu_alloc_mb'] // 1024}/{diag['gpu_total_mb'] // 1024}GB[/{gc}]"
|
|
253
|
+
f" SWAP [{sc}]{swap_mb // 1024}GB[/{sc}]"
|
|
254
|
+
f" MEM [{pc}]{diag['mem_pressure']}[/{pc}]"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def action_refresh(self) -> None:
|
|
258
|
+
status = self.query_one("#status-line", StatusIndicator)
|
|
259
|
+
status.status = "scanning"
|
|
260
|
+
self.load_data()
|
|
261
|
+
|
|
262
|
+
def action_cleanup(self) -> None:
|
|
263
|
+
self.exit(return_code=10) # Signal to CLI to run cleanup
|
|
264
|
+
|
|
265
|
+
def action_debloat(self) -> None:
|
|
266
|
+
self.exit(return_code=11)
|
|
267
|
+
|
|
268
|
+
def action_simulate(self) -> None:
|
|
269
|
+
self.exit(return_code=12)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run_tui_dashboard():
|
|
273
|
+
"""Launch the Textual TUI dashboard."""
|
|
274
|
+
app = HealthDashboard()
|
|
275
|
+
result = app.run()
|
|
276
|
+
return result
|