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")))
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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" />
|
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user