diff --git a/frontend/src/components/cockpit/Auftraege.tsx b/frontend/src/components/cockpit/Auftraege.tsx
index 2eaa696..7fb74e3 100644
--- a/frontend/src/components/cockpit/Auftraege.tsx
+++ b/frontend/src/components/cockpit/Auftraege.tsx
@@ -20,6 +20,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
/>
diff --git a/frontend/src/components/cockpit/Bestaetigen.tsx b/frontend/src/components/cockpit/Bestaetigen.tsx
index 230ec01..2b7014a 100644
--- a/frontend/src/components/cockpit/Bestaetigen.tsx
+++ b/frontend/src/components/cockpit/Bestaetigen.tsx
@@ -23,13 +23,16 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen"
}) {
return (
@@ -66,7 +84,7 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
{laeuft
- ? fortschritt ?? laufText(job)
+ ? fortschritt ?? laufText(job, jetzt)
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
diff --git a/frontend/src/components/cockpit/Rueckfrage.test.tsx b/frontend/src/components/cockpit/Rueckfrage.test.tsx
index 713c7bb..b9271f6 100644
--- a/frontend/src/components/cockpit/Rueckfrage.test.tsx
+++ b/frontend/src/components/cockpit/Rueckfrage.test.tsx
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { hinweisRueckfrage } from "@/lib/anzeige"
import { post } from "@/lib/api"
import type { Hinweis } from "@/lib/typen"
-import { rollenErsatzText } from "@/lib/wartung"
+import { installierenUnterbrechung, rollenErsatzText } from "@/lib/wartung"
import { Bestaetigen } from "./Bestaetigen"
import { Checkliste } from "./Checkliste"
@@ -63,11 +63,18 @@ describe("Bestaetigen", () => {
})
describe("rollenErsatzText", () => {
- it("sagt deutlich, welches Modell die Rolle verliert", () => {
- expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true })).toBe(
+ it("sagt deutlich, welches Modell die Rolle verliert und was es behält", () => {
+ expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true, weitere: ["heavy"] })).toBe(
"Achtung: Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“ und ersetzt damit Qwen3.8-27B-Heretic. " +
- "Das bisherige Modell bleibt auf der Platte. Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell.",
+ "Das bisherige Modell bleibt auf der Platte. Die Rolle „heavy“ behält es. " +
+ "Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell.",
)
expect(rollenErsatzText("org/Neu-GGUF", "Coder", undefined)).toBe("Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“.")
})
+
+ it("warnt vor der Unterbrechung durch das Neuladen von llama-swap", () => {
+ expect(installierenUnterbrechung(false)).toContain("bricht ab")
+ expect(installierenUnterbrechung(false)).not.toContain("noch einmal")
+ expect(installierenUnterbrechung(true)).toContain("Mit Rolle passiert das nach dem Download noch einmal.")
+ })
})
diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts
index 87df48e..8ff964d 100644
--- a/frontend/src/lib/abfragen.ts
+++ b/frontend/src/lib/abfragen.ts
@@ -3,7 +3,7 @@
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
// Sicherheitsnetz und laufen fünfmal seltener.
-import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { type QueryClient, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"
import { api, post } from "./api"
import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen"
@@ -15,14 +15,24 @@ import { gbText } from "./zeit"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
-/** Was ein beendeter Auftrag verändert haben kann: Versionen, Modelle, Sicherungen, Platte. */
-const NACH_AUFTRAG = ["start", "update-details", "updates-verlauf", "models", "sicherungen", "aufraeumen"]
-
-/** Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu. */
+/**
+ * Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu:
+ * nach einem Download die Modelle und die Platte, nach Updates und der Suche die Versionen, der
+ * Verlauf und die Sicherungen. Gezielt, weil die Update-Prüfung teuer ist (GitHub, apt, git fetch).
+ */
function auftragsEndeMelden(qc: QueryClient, neu: Job[]) {
const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? []
- const ebenFertig = neu.some((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
- if (ebenFertig) for (const k of NACH_AUFTRAG) qc.invalidateQueries({ queryKey: [k] })
+ const ebenFertig = neu.filter((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
+ if (ebenFertig.length === 0) return
+ const schluessel = new Set(["start"])
+ for (const j of ebenFertig) {
+ // Wie wartung.jobDownload — hier ausgeschrieben, damit wartung.ts nicht ins Start-Bündel wandert.
+ const download = j.group === "download" || /^download\s/i.test(j.label)
+ for (const k of download ? ["models", "aufraeumen"] : ["update-details", "updates-verlauf", "sicherungen"]) {
+ schluessel.add(k)
+ }
+ }
+ for (const k of schluessel) qc.invalidateQueries({ queryKey: [k] })
}
export const useStart = () =>
@@ -66,12 +76,18 @@ export const useSicherungen = () =>
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
})
-export const useUpdateDetails = (art: string | null) =>
- useQuery({
- queryKey: ["update-details", art],
- queryFn: () => api(`/api/maintenance/update-details?kind=${art}`),
- enabled: !!art,
- staleTime: 600_000,
+/**
+ * Was die Updates der Bausteine bringen würden — je Baustein eine eigene Abfrage, damit ein langsamer
+ * (Hermes: git fetch bis 25 s) die anderen nicht aufhält. Die Prüfung fragt GitHub, apt und git,
+ * darum gilt ein Ergebnis zehn Minuten.
+ */
+export const useUpdateDetails = (arten: readonly string[]) =>
+ useQueries({
+ queries: arten.map((art) => ({
+ queryKey: ["update-details", art],
+ queryFn: () => api(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`),
+ staleTime: 600_000,
+ })),
})
export interface UpdateLauf {
diff --git a/frontend/src/lib/wartung.ts b/frontend/src/lib/wartung.ts
index 2cec672..19cd586 100644
--- a/frontend/src/lib/wartung.ts
+++ b/frontend/src/lib/wartung.ts
@@ -46,22 +46,45 @@ export function pruefFehlerText(fehler: string): string {
// --- Aufträge (services/jobengine.py) ---------------------------------------------------
-/**
- * Darf man den Auftrag abbrechen? Updates nicht: Der Abbruch beendet nur die äußere Shell, apt
- * und die Update-Skripte liefen weiter, und die Karte zeigte fälschlich „Abgebrochen“. Scheitert
- * ein Update, rollen Motor und llama-swap selbst zurück.
- */
-export const abbrechbar = (j: Pick) => laeuftNoch(j) && j.group !== "maintenance"
-
-/** Die Beschriftung eines Auftrags auf Deutsch („download org/repo“ heißt im Backend noch englisch). */
-export function jobTitel(j: Pick): string {
- const m = /^download\s+(.+)$/i.exec(j.label)
- return m ? `Download ${m[1]}` : j.label
-}
-
/** Ist der Auftrag ein Modell-Download? (Bis das Backend eine Gruppe setzt, verrät es nur die Beschriftung.) */
export const jobDownload = (j: Pick) => j.group === "download" || /^download\s/i.test(j.label)
+/** Ist der Auftrag die Suche nach Updates (apt-get update)? Auch sie hat im Backend noch keine Gruppe. */
+export const jobSuche = (j: Pick) => j.group === "pruefen" || j.label === "Nach Updates suchen"
+
+/**
+ * Darf man den Auftrag abbrechen? Nur Downloads: Sie laufen als eigener Prozess (`hf download`), den
+ * der Abbruch wirklich beendet. Updates und die Suche laufen als Shell-Kette (`bash -c …`), und ob
+ * ein Abbruch dort alles stoppt, ist nicht sicher — bei Updates liefen apt und die Update-Skripte
+ * weiter, während die Karte „Abgebrochen“ zeigte. Scheitert ein Update, rollen Motor und llama-swap
+ * selbst zurück.
+ */
+export const abbrechbar = (j: Pick) => laeuftNoch(j) && jobDownload(j)
+
+// Die Beschriftungen setzt das Backend (services/maintenance.py, routers/models.py) mit technischen
+// Namen; die Oberfläche sagt Motor, llama-swap, Hermes und Betriebssystem.
+const TITEL: [RegExp, string][] = [
+ [/^download\s+(.+)$/i, "Download $1"],
+ [/^OS-Update \(apt\)$/, "Update Betriebssystem"],
+ [/^Engine-Update \(llama\.cpp Vulkan\)$/, "Update Motor (llama.cpp)"],
+ [/^Router-Update \(llama-swap\)$/, "Update llama-swap"],
+ [/^Hermes-Agent-Update$/, "Update Hermes"],
+]
+const TEILE: [string, string][] = [
+ ["Engine (llama.cpp)", "Motor"],
+ ["Router (llama-swap)", "llama-swap"],
+ ["Hermes-Agent", "Hermes"],
+ ["OS (apt)", "Betriebssystem"],
+]
+
+/** Die Beschriftung eines Auftrags in den Worten der Oberfläche. Unbekanntes bleibt, wie es ist. */
+export function jobTitel(j: Pick): string {
+ for (const [muster, ersatz] of TITEL) if (muster.test(j.label)) return j.label.replace(muster, ersatz)
+ // „Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))“
+ if (j.label.startsWith("Alle aktualisieren")) return TEILE.reduce((t, [alt, neu]) => t.split(alt).join(neu), j.label)
+ return j.label
+}
+
/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */
export function restzeit(s: number): string {
if (s < 60) return "noch unter einer Minute"
@@ -113,20 +136,41 @@ export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] {
// --- Installieren -------------------------------------------------------------------
+/** Wer eine Rolle heute innehat — für den Hinweis vor dem Installieren. */
+export interface RollenInhaber {
+ name: string
+ /** Hängt ein Bild-Zwilling an seinen Gewichten (vision, coder-bild)? */
+ zwilling: boolean
+ /** Rollen, die das Modell außerdem trägt (der Coder auch „heavy“) — die behält es. */
+ weitere: string[]
+}
+
/**
* Hinweis vor dem Installieren mit Rolle: Wen ersetzt das neue Modell? Nach dem Download wandert
- * nur der Rollen-Alias um (services/llamaswap.set_role) — das bisherige Modell bleibt auf der Platte
- * und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
+ * nur dieser eine Rollen-Alias um (services/llamaswap.set_role_alias): Das bisherige Modell bleibt
+ * auf der Platte, behält seine übrigen Rollen, und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
*/
-export function rollenErsatzText(
- neu: string,
- rolle: string,
- bisher: { name: string; zwilling: boolean } | undefined,
-): string {
+export function rollenErsatzText(neu: string, rolle: string, bisher: RollenInhaber | undefined): string {
if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.`
+ const behaelt = bisher.weitere.length
+ ? ` Die Rolle${bisher.weitere.length === 1 ? "" : "n"} „${bisher.weitere.join("“, „")}“ behält es.`
+ : ""
return (
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` +
- "Das bisherige Modell bleibt auf der Platte." +
+ `Das bisherige Modell bleibt auf der Platte.${behaelt}` +
(bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "")
)
}
+
+/**
+ * Was das Eintragen in llama-swap unterwegs anrichtet: llama-swap läuft mit `--watch-config` und lädt
+ * bei jeder Änderung sofort neu (services/warmer.py, deploy/coder-vollkontext.sh) — laufende Antworten
+ * brechen ab, geladene Modelle fliegen raus. Mit Rolle schreibt das Backend nach dem Download noch einmal.
+ */
+export function installierenUnterbrechung(mitRolle: boolean): string {
+ return (
+ "Kurze Unterbrechung: Beim Eintragen lädt llama-swap sofort neu. Was Lucy oder OpenChamber gerade " +
+ "schreiben, bricht ab, und die Modelle werden neu geladen." +
+ (mitRolle ? " Mit Rolle passiert das nach dem Download noch einmal." : "")
+ )
+}
diff --git a/frontend/src/views/ModellSuche.tsx b/frontend/src/views/ModellSuche.tsx
index b6e152a..b8780fb 100644
--- a/frontend/src/views/ModellSuche.tsx
+++ b/frontend/src/views/ModellSuche.tsx
@@ -5,18 +5,21 @@ import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { useInstallieren } from "@/lib/abfragen"
import { api } from "@/lib/api"
-import { rollenErsatzText } from "@/lib/wartung"
+import { installierenUnterbrechung, type RollenInhaber, rollenErsatzText } from "@/lib/wartung"
import { gbText, zahl } from "@/lib/zeit"
interface Treffer { repo: string; downloads: number; likes: number }
/** /api/hf/quants liefert heute nur die Namen. Kommen Größen dazu, zeigen Liste und Rückfrage sie an. */
interface Quant { quant?: string; name?: string; size_bytes?: number; total_bytes?: number }
-/** Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche). */
+/**
+ * Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche).
+ * Das Hirn steht hier (wie bisher) nicht zur Wahl; Hirn-Kandidaten testet das Radar nachts, bevor man sie übernimmt.
+ */
const ROLLEN: Record = { coder: "Coder" }
-/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */
-export type RollenStand = Record
+/** Wer die Rollen heute innehat (Schlüssel = Rollen-Alias). */
+export type RollenStand = Record
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
@@ -188,6 +191,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
{ersatz}
)}
+ {installierenUnterbrechung(!!rolle)}
diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx
index 40aec5f..a706399 100644
--- a/frontend/src/views/Modelle.tsx
+++ b/frontend/src/views/Modelle.tsx
@@ -214,8 +214,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.`
: `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`}
knopf="Übernehmen"
+ gesperrt={aktion.isPending}
onNein={() => setFrage(null)}
onJa={() => {
+ if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
setFrage(null)
}}
@@ -226,8 +228,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
titel={`${k.name} verwerfen?`}
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
knopf="Verwerfen"
+ gesperrt={aktion.isPending}
onNein={() => setFrage(null)}
onJa={() => {
+ if (frage !== "verwerfen") return
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
setFrage(null)
}}
@@ -353,6 +357,7 @@ function AufraeumenPanel() {
titel={`${frage?.name ?? ""} löschen?`}
text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`}
knopf="Endgültig löschen"
+ gesperrt={loeschen.isPending}
onNein={() => setFrage(null)}
onJa={() => {
if (frage) loeschen.mutate(frage)
@@ -432,7 +437,11 @@ export function ModelleSeite() {
setSuche(false)}
- rollenJetzt={{ coder: coder ? { name: coder.name, zwilling: !!coderZwilling } : undefined }}
+ rollenJetzt={{
+ coder: coder
+ ? { name: coder.name, zwilling: !!coderZwilling, weitere: coder.aliases.filter((a) => a.toLowerCase() !== "coder") }
+ : undefined,
+ }}
/>
>
)
diff --git a/frontend/src/views/Updates.tsx b/frontend/src/views/Updates.tsx
index 8487c1d..8afc0ba 100644
--- a/frontend/src/views/Updates.tsx
+++ b/frontend/src/views/Updates.tsx
@@ -1,4 +1,5 @@
import { useState } from "react"
+import type { UseQueryResult } from "@tanstack/react-query"
import { ChevronRight } from "lucide-react"
import { cn } from "cn"
import { Auftraege } from "@/components/cockpit/Auftraege"
@@ -11,7 +12,7 @@ import {
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
} from "@/lib/abfragen"
import { laeuftNoch } from "@/lib/anzeige"
-import { detailsHabenNeues, pruefFehlerText, versionen } from "@/lib/wartung"
+import { detailsHabenNeues, jobSuche, pruefFehlerText, versionen } from "@/lib/wartung"
import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
@@ -110,9 +111,12 @@ function zusammenfassung(summary: string | undefined): string | null {
return s
}
-function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFreigeben }: {
+function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gesperrt, onStart, onFreigeben }: {
id: string
name: string
+ details: UseQueryResult
+ /** Gibt es Neues? Frische Details schlagen den gecachten Stand der Startseite (siehe UpdatesSeite). */
+ hatUpdate: boolean
/** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */
neuLautStart: string | undefined
fest: Festgehalten | undefined
@@ -120,13 +124,10 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
onStart: () => void
onFreigeben: () => void
}) {
- const details = useUpdateDetails(id)
const d = details.data
const laedt = details.isPending
const fehler = details.isError ? details.error.message : d?.error
const v = d ? versionen(id, d) : null
- // Frische Details schlagen den gecachten Stand der Startseite; wissen sie nichts, gilt der Start.
- const hatUpdate = detailsHabenNeues(id, d) ?? neuLautStart != null
const neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null
const summary = zusammenfassung(d?.summary)
@@ -269,12 +270,16 @@ export function UpdatesSeite() {
const neuLautStart = new Map((start.data?.updates.bausteine ?? []).map((b) => [b.id, b.neu]))
const festgehalten = new Map((start.data?.updates.festgehalten ?? []).map((f) => [f.baustein, f]))
+ // /api/start kennt nur einen bis zu zehn Minuten alten Stand. Die Details prüfen frisch — sagen
+ // sie etwas, gilt das (für die Zeilen UND für „Alles jetzt aktualisieren“), sonst der Start.
+ const details = useUpdateDetails(BAUSTEINE.map((b) => b.id))
+ const hatNeues = BAUSTEINE.map((b, i) => detailsHabenNeues(b.id, details[i].data) ?? neuLautStart.has(b.id))
const alleJobs = jobs.data?.jobs ?? []
// Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt.
const updateLaeuft =
alleJobs.some((j) => j.group === "maintenance" && laeuftNoch(j)) || (start.data?.zustand.update_laeuft ?? false)
- // Die Suche hat im Backend (noch) keine Gruppe; sobald sie "pruefen" heißt, sperrt der Knopf auch hier.
- const suchtGerade = alleJobs.some((j) => j.group === "pruefen" && laeuftNoch(j))
+ // Zwei Suchen gleichzeitig stolpern über die apt-Sperre — solange eine läuft, bleibt der Knopf zu.
+ const suchtGerade = alleJobs.some((j) => jobSuche(j) && laeuftNoch(j))
const gesperrt = updateLaeuft || updateStarten.isPending
const zurueckMoeglich = sicherungen.data?.available ?? false
@@ -296,11 +301,13 @@ export function UpdatesSeite() {
Was sich ändert
Aktion
- {BAUSTEINE.map((b) => (
+ {BAUSTEINE.map((b, i) => (
updateStarten.mutate("pruefen")}
disabled={updateStarten.isPending || suchtGerade}
>
- Nach Neuem suchen
+ {suchtGerade ? "Sucht …" : "Nach Neuem suchen"}
-