Frontend: Voice-Umzug in Desktop-App, MC3-Prototyp, UI-Rework-Dateien auf lucy-v2, dist konsistent
- VoiceView + components/voice + lib/voice entfernt (lebt jetzt in client/lucy-desktop)
- mc3/: Basisstation-Prototyp hinter #mc3-Weiche (non-destruktiv)
- roleMeta/useLucyHealth/useExpertMode/ExpertToggle/LucyHealthCard/WarmSetManager (UI-Rework, auf main a341d25 committet, hier nachgezogen)
- dist frisch gebaut (Asset-Stand war inkonsistent: alte geloescht, neue untracked)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { useState } from "react"
|
||||
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
|
||||
import { setGroup, type ModelInfo } from "@/lib/api"
|
||||
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||
import { fmtSize } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
|
||||
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
|
||||
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
|
||||
export function WarmSetManager() {
|
||||
const qc = useQueryClient()
|
||||
const { data: modelsResp } = useModels(4_000)
|
||||
const { data: groupsResp } = useGroups()
|
||||
const { data: sysStatus } = useSystemStatus()
|
||||
const { data: brain } = useHermesBrain()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const models = modelsResp?.models ?? []
|
||||
const running = modelsResp?.running ?? []
|
||||
const group = groupsResp?.groups?.brains
|
||||
const members = group?.members ?? []
|
||||
const byName = (name: string) => models.find((m) => m.name === name)
|
||||
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
|
||||
|
||||
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
|
||||
const B = 1024 ** 3
|
||||
const bud = brain?.budget
|
||||
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
|
||||
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
|
||||
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
|
||||
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
|
||||
const reservedGb = bud?.warm_projected_gb ?? weightsGb
|
||||
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
|
||||
|
||||
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
|
||||
|
||||
async function save(next: string[]) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
|
||||
qc.invalidateQueries({ queryKey: qk.groups })
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
qc.invalidateQueries({ queryKey: qk.hermesBrain })
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
const m = byName(name)
|
||||
if (m && roleMeta(m.role).protected) {
|
||||
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
|
||||
return
|
||||
}
|
||||
void save(members.filter((x) => x !== name))
|
||||
}
|
||||
|
||||
function add(name: string) {
|
||||
setPicking(false)
|
||||
void save([...members, name])
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
|
||||
<Header />
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
|
||||
</p>
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
|
||||
<Header />
|
||||
|
||||
{/* Mitglieder */}
|
||||
<div className="space-y-1.5">
|
||||
{memberModels.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
|
||||
)}
|
||||
{memberModels.map((m) => {
|
||||
const isWarm = running.includes(m.name)
|
||||
const prot = roleMeta(m.role).protected
|
||||
return (
|
||||
<div key={m.name} className="flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
|
||||
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
|
||||
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
|
||||
</div>
|
||||
{prot ? (
|
||||
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => remove(m.name)}
|
||||
disabled={busy}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
|
||||
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hinzufügen */}
|
||||
<button
|
||||
onClick={() => setPicking(true)}
|
||||
disabled={busy || eligible.length === 0}
|
||||
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
|
||||
</button>
|
||||
|
||||
{/* Speicher-Budget */}
|
||||
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
||||
<div className="mb-1.5 flex items-center justify-between text-[11px]">
|
||||
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + Arbeitsspeicher (KV-Cache)">
|
||||
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
|
||||
</span>
|
||||
<span className="font-mono font-bold text-foreground">
|
||||
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
|
||||
<div className={cn("h-full rounded-full transition-all duration-500", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
|
||||
{bud && !bud.fits && (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB, z. B. das große Hirn) passt das Set
|
||||
nicht gleichzeitig in den Speicher. Beim Laden wird das Set kurz verdrängt und danach automatisch nachgeladen — kostet ein paar Sekunden.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{bud && bud.fits && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
|
||||
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell — nichts wird verdrängt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auswahl-Modal */}
|
||||
{picking && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
|
||||
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">Dauerhaft bereithalten</h3>
|
||||
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
|
||||
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
|
||||
{eligible.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => add(m.name)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4.5 w-4.5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
|
||||
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit — sie antworten ohne Ladezeit.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user