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
+76 -1
View File
@@ -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