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
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" />
<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>
+11
View File
@@ -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
+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