feat(2.0): Phase 6a — Modell-Install (Download-Jobs + Split + UI)

jobengine portiert (Live-Log + %-Fortschritt aus .incomplete), services/hf.py
(resolve_gguf inkl. Split -of-, Groessen), POST /api/models/install (hf
download als Job + sofortige Registrierung, Split-fest), GET /api/jobs +
cancel. huggingface_hub in requirements. Frontend: Install-Buttons auf
Discover-Karten + Live-Download-Fortschrittsbalken (JobsBar).

Verifiziert: resolve_gguf gegen echte Repos (Qwen3.6-35B-A3B 23GB single,
Qwen3.5-122B-A10B 77GB 3-part split) + Frontend-Build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 09:59:46 +02:00
parent f1cbfa8e67
commit af46a7b041
11 changed files with 419 additions and 42 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ PORT = int(os.environ.get("MC_PORT", "9000"))
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist"))) FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton). # Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-phase5" VERSION = "2.0.0-phase6"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml). # Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML() yaml = YAML()
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.30
httpx>=0.27 httpx>=0.27
ruamel.yaml>=0.18 ruamel.yaml>=0.18
psutil>=5.9 psutil>=5.9
huggingface_hub>=0.27
+66 -2
View File
@@ -4,8 +4,9 @@ import psutil
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import discover, llamaswap from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services.fit import evaluate_fit, max_ctx_for from services import discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -60,6 +61,69 @@ def register(req: RegisterReq) -> dict:
return {"ok": True, "model_id": model_id} return {"ok": True, "model_id": model_id}
class InstallReq(BaseModel):
repo: str
role: str | None = None
quant: str = "Q4_K_M"
ctx: int | None = None
jinja: bool = False
hf_token: str | None = None
@router.post("/models/install")
def install(req: InstallReq) -> dict:
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald
die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
erste Teil (-00001-of-…)."""
info = hf.resolve_gguf(req.repo, req.quant)
if not info["first"]:
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {req.repo} gefunden.")
subdir = req.repo.split("/")[-1]
target = MODELS_DIR / subdir
target.mkdir(parents=True, exist_ok=True)
model_path = str(target / info["first"])
mmproj_path = str(target / info["mmproj"]) if info["mmproj"] else None
ctx = req.ctx
if ctx is None:
ram = _ram_gb()
ctx = max_ctx_for(extract_params_b(req.repo), req.quant, ram)
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
try:
model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja)
except PermissionError as exc:
raise HTTPException(500, str(exc))
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", req.repo]
for f in info["files"]:
args.append(f)
if info["mmproj"]:
args.append(info["mmproj"])
args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])}
@router.get("/jobs")
def jobs() -> dict:
return {"jobs": jobengine.public_jobs()}
@router.post("/jobs/{job_id}/cancel")
def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)}
@router.get("/groups") @router.get("/groups")
def groups() -> dict: def groups() -> dict:
return {"groups": llamaswap.list_groups()} return {"groups": llamaswap.list_groups()}
+58
View File
@@ -0,0 +1,58 @@
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen."""
import os
import sys
import httpx
def hf_bin() -> str:
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
return cand if os.path.exists(cand) else "hf"
def _tree(repo: str) -> list[dict]:
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=true"
with httpx.Client(timeout=20.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
def _size(entry: dict) -> int:
return int(entry.get("size") or (entry.get("lfs") or {}).get("size") or 0)
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
"""Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
"""
tree = _tree(repo)
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
q = quant.lower()
# mmproj separat (Vision-Projektor)
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
# bevorzugt den gewünschten Quant
pref = [e for e in model if q in e["path"].lower()]
chosen = pref or model
if not chosen:
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
split = any("-of-" in e["path"].lower() for e in chosen)
if split:
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
files = [e["path"] for e in parts]
first = files[0]
total = sum(_size(e) for e in parts)
else:
# ein einzelnes File: nimm das kleinste passende (typisch genau eins)
chosen.sort(key=lambda e: _size(e))
first = chosen[0]["path"]
files = [first]
total = _size(chosen[0])
if mmproj:
total += next((_size(e) for e in ggufs if e["path"] == mmproj), 0)
return {"files": files, "first": first, "total_bytes": total, "mmproj": mmproj, "split": split}
+163
View File
@@ -0,0 +1,163 @@
"""
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
"""
import glob
import os
import shlex
import subprocess
import threading
import time
import uuid
JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {}
_LOG_CAP = 400
def _append_log(job: dict, line: str) -> None:
job["log"].append(line)
if len(job["log"]) > _LOG_CAP:
del job["log"][0]
def _pump_output(job: dict, stream) -> None:
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
buf = b""
overwrite = False
pending_cr = False
def commit():
line = buf.decode("utf-8", "replace")
if overwrite and job["log"]:
job["log"][-1] = line
else:
_append_log(job, line)
while True:
ch = stream.read(1)
if not ch:
break
if pending_cr:
pending_cr = False
if ch == b"\n":
commit(); overwrite = False; buf = b""
continue
commit(); overwrite = True; buf = b""
if ch == b"\r":
pending_cr = True
elif ch == b"\n":
commit(); overwrite = False; buf = b""
else:
buf += ch
if pending_cr:
commit(); overwrite = True; buf = b""
if buf:
commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None):
job = JOBS[job_id]
job["state"] = "running"
try:
proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0,
env={**os.environ, **(env or {})},
)
_PROCS[job_id] = proc
_pump_output(job, proc.stdout)
proc.wait()
job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed"
job["returncode"] = -1
finally:
_PROCS.pop(job_id, None)
job["finished_at"] = time.time()
cb = job.pop("_on_done", None)
if cb and job["state"] == "done":
try:
cb()
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id)
if job is not None:
job["progress"] = 0
job["total_bytes"] = total_bytes
def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
prev_t = prev_b = None
rate = 0.0
while True:
j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"):
break
try:
inc = glob.glob(pat, recursive=True)
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
if cur:
j["progress"] = min(99, int(cur * 100 / total_bytes))
j["done_bytes"] = cur
now = time.time()
if prev_t is not None and now > prev_t and cur >= prev_b:
inst = (cur - prev_b) / (now - prev_t)
rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate
if rate > 0:
j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur
except Exception: # noqa: BLE001
pass
time.sleep(1.0)
j = JOBS.get(job_id)
if j and j["state"] == "done":
j["progress"] = 100
j.pop("eta_s", None)
threading.Thread(target=_watch, daemon=True).start()
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None) -> str:
job_id = uuid.uuid4().hex[:12]
JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued",
"log": ["$ " + " ".join(shlex.quote(a) for a in args)],
"returncode": None, "started_at": time.time(), "finished_at": None,
}
if on_done:
JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
return job_id
def cancel_job(job_id: str) -> bool:
job = JOBS.get(job_id)
if not job or job["state"] in ("done", "failed", "canceled"):
return False
job["canceled"] = True
_append_log(job, "[mc] Abbruch angefordert…")
proc = _PROCS.get(job_id)
if proc is not None:
try:
proc.terminate()
except Exception: # noqa: BLE001
pass
else:
job["state"] = "canceled"
job["finished_at"] = time.time()
return True
def public_jobs() -> list[dict]:
"""Jobs ohne interne Felder (_on_done) für die API."""
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" /> <meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CDBFzDBf.js"></script> <script type="module" crossorigin src="/assets/index-pBjYpaCo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CatWSvf4.css"> <link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+11
View File
@@ -138,6 +138,17 @@ export interface AgentStatus {
has_memories: boolean has_memories: boolean
} }
export interface Job {
id: string
label: string
state: "queued" | "running" | "done" | "failed" | "canceled"
progress?: number
total_bytes?: number
done_bytes?: number
rate_bps?: number
eta_s?: number
}
export interface Health { export interface Health {
status: string status: string
version: string version: string
+76 -1
View File
@@ -1,8 +1,57 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { api, type DiscoverResp, type Fit, type ModelInfo } from "@/lib/api" import { Download } from "lucide-react"
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo } from "@/lib/api"
import { CapsChips } from "@/components/CapsChips" import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function fmtBytes(b?: number) {
if (!b) return ""
return `${(b / 1024 ** 3).toFixed(1)} GB`
}
function fmtEta(s?: number) {
if (!s) return ""
const m = Math.floor(s / 60)
return m > 0 ? `${m} min` : `${s} s`
}
function JobsBar() {
const [jobs, setJobs] = useState<Job[]>([])
useEffect(() => {
const load = () => api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs)).catch(() => {})
load()
const t = setInterval(load, 2000)
return () => clearInterval(t)
}, [])
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null
return (
<div className="space-y-2 rounded-xl border border-border bg-card p-3">
<div className="text-xs font-medium text-muted-foreground">Downloads</div>
{active.map((j) => (
<div key={j.id} className="space-y-1">
<div className="flex justify-between text-xs">
<span className="truncate">{j.label}</span>
<span className="text-muted-foreground">
{j.progress ?? 0}% · {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
{j.eta_s ? ` · ETA ${fmtEta(j.eta_s)}` : ""}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${j.progress ?? 0}%` }} />
</div>
</div>
))}
{recent.map((j) => (
<div key={j.id} className="flex justify-between text-xs text-muted-foreground">
<span className="truncate">{j.label}</span>
<span className={j.state === "done" ? "text-emerald-500" : "text-amber-500"}>{j.state}</span>
</div>
))}
</div>
)
}
function fmtSize(b: number | null) { function fmtSize(b: number | null) {
if (!b) return "—" if (!b) return "—"
const gb = b / 1024 ** 3 const gb = b / 1024 ** 3
@@ -92,6 +141,7 @@ function Discover() {
const [data, setData] = useState<DiscoverResp | null>(null) const [data, setData] = useState<DiscoverResp | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
api<DiscoverResp>("/api/discover") api<DiscoverResp>("/api/discover")
@@ -100,6 +150,19 @@ function Discover() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "…" }))
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
setInstalling((s) => ({ ...s, [repo]: "geladen" }))
} catch (e) {
setInstalling((s) => ({ ...s, [repo]: `Fehler` }))
}
}
if (loading) return <div className="text-sm text-muted-foreground">Suche aktuelle Modelle</div> if (loading) return <div className="text-sm text-muted-foreground">Suche aktuelle Modelle</div>
if (error || !data) if (error || !data)
return ( return (
@@ -128,6 +191,16 @@ function Discover() {
<CapsChips caps={m.caps} /> <CapsChips caps={m.caps} />
<FitBadge fit={m.fit} /> <FitBadge fit={m.fit} />
</div> </div>
{m.fit.level !== "too_tight" && (
<button
onClick={() => install(m.repo, m.role, m.quant, m.caps.tools !== "no")}
disabled={!!installing[m.repo]}
className="mt-2 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-60"
>
<Download className="h-3.5 w-3.5 text-primary" />
{installing[m.repo] || "Installieren"}
</button>
)}
</div> </div>
))} ))}
</div> </div>
@@ -148,6 +221,8 @@ export function ModelsView() {
</p> </p>
</div> </div>
<JobsBar />
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm"> <div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm">
{(["installed", "discover"] as const).map((t) => ( {(["installed", "discover"] as const).map((t) => (
<button <button