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
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