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:
+1
-1
@@ -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")))
|
||||
|
||||
# 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).
|
||||
yaml = YAML()
|
||||
|
||||
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.30
|
||||
httpx>=0.27
|
||||
ruamel.yaml>=0.18
|
||||
psutil>=5.9
|
||||
huggingface_hub>=0.27
|
||||
|
||||
@@ -4,8 +4,9 @@ import psutil
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import discover, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from services import discover, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -60,6 +61,69 @@ def register(req: RegisterReq) -> dict:
|
||||
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")
|
||||
def groups() -> dict:
|
||||
return {"groups": llamaswap.list_groups()}
|
||||
|
||||
@@ -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}
|
||||
@@ -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()]
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+40
-35
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-CDBFzDBf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CatWSvf4.css">
|
||||
<script type="module" crossorigin src="/assets/index-pBjYpaCo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -138,6 +138,17 @@ export interface AgentStatus {
|
||||
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 {
|
||||
status: string
|
||||
version: string
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
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 { 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) {
|
||||
if (!b) return "—"
|
||||
const gb = b / 1024 ** 3
|
||||
@@ -92,6 +141,7 @@ function Discover() {
|
||||
const [data, setData] = useState<DiscoverResp | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [installing, setInstalling] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
api<DiscoverResp>("/api/discover")
|
||||
@@ -100,6 +150,19 @@ function Discover() {
|
||||
.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 (error || !data)
|
||||
return (
|
||||
@@ -128,6 +191,16 @@ function Discover() {
|
||||
<CapsChips caps={m.caps} />
|
||||
<FitBadge fit={m.fit} />
|
||||
</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>
|
||||
@@ -148,6 +221,8 @@ export function ModelsView() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<JobsBar />
|
||||
|
||||
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm">
|
||||
{(["installed", "discover"] as const).map((t) => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user