Feat: Model-Manager — Ko-Residenz-Schalter + "verdraengt das Hirn"-Warnung

Verhindert versehentliches Verdraengen des warmen Hirns im Modell-Manager.
- api.ts: GroupsResp-Typ + getGroups/setGroup (PUT /api/groups).
- queries.ts: useGroups-Hook (qk.groups).
- Cockpit: pro Modell ein "Brain"-Schalter (Ko-Residenz in der brains-Gruppe
  an/aus) + "Ko-resident"-Badge. Beim Laden eines gruppenlosen Modells, das
  ein warmes brains-Mitglied rauswerfen wuerde, erscheint eine Bestaetigung
  mit Hinweis auf den Schalter (beide warm halten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 22:52:00 +02:00
parent 68cad9c0f1
commit 50e04e0aee
9 changed files with 5568 additions and 5466 deletions
+84 -3
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Copy, Zap, Bot } from "lucide-react"
import { api, type ModelInfo, type RoleRecResp } from "@/lib/api"
import { useModels, useRouting, useConnect, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
import { useModels, useGroups, useRouting, useConnect, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
@@ -17,6 +17,7 @@ export function Cockpit() {
const { data: updates } = useUpdates(4_000)
const { data: brain } = useHermesBrain()
const { data: sysStatus } = useSystemStatus()
const { data: groupsResp } = useGroups()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
@@ -26,6 +27,30 @@ export function Cockpit() {
qc.invalidateQueries({ queryKey: qk.routing })
}
// Ko-Residenz-Gruppe „brains": ihre Mitglieder dürfen gleichzeitig resident bleiben
// (Hirn + Augen verdrängen sich nicht). Ein Modell außerhalb der Gruppe lädt exklusiv.
const brainsGroup = groupsResp?.groups?.brains
const brainsMembers = brainsGroup?.members ?? []
const isCoresident = (name: string) => brainsMembers.includes(name)
// Ist gerade ein warmes Hirn-Mitglied resident, das ein gruppenloses Lade-Modell verdrängen würde?
const brainsWarm = brainsMembers.some((mname) => running.includes(mname))
async function toggleCoresident(name: string) {
if (!brainsGroup) {
showAlert("Keine brains-Gruppe", "Es existiert noch keine Ko-Residenz-Gruppe „brains“ in der Engine-Konfiguration. Lege sie erst über die Gruppen-Verwaltung an.")
return
}
const inGroup = brainsMembers.includes(name)
const next = inGroup ? brainsMembers.filter((x) => x !== name) : [...brainsMembers, name]
try {
await setGroup("brains", next, brainsGroup.swap ?? false, brainsGroup.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups })
reload()
} catch (e: any) {
showAlert("Fehler", `Ko-Residenz konnte nicht geändert werden: ${e.message || e}`)
}
}
// UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleForAssign, setActiveRoleForAssign] = useState<string | null>(null)
@@ -93,7 +118,7 @@ export function Cockpit() {
return `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`
}
async function handleLoadModel(name: string) {
async function doLoadModel(name: string) {
try {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
reload()
@@ -102,6 +127,20 @@ export function Cockpit() {
}
}
async function handleLoadModel(name: string) {
// Warnung: Ein gruppenloses Modell lädt exklusiv und verdrängt das warme Hirn (brains-Ko-Residenz).
if (brainsGroup && brainsWarm && !isCoresident(name)) {
const warm = brainsMembers.filter((m) => running.includes(m)).map((m) => m.split("/").pop()).join(", ")
showConfirm(
"Verdrängt das Hirn?",
`${name.split("/").pop()}“ ist nicht in der Ko-Residenz-Gruppe „brains“. Beim Laden wirft es das aktuell warme Hirn (${warm}) raus — Lucy verliert Hirn bzw. Augen.\n\nTrotzdem exklusiv laden? Tipp: Mit dem 🧠-Schalter machst du das Modell dauerhaft ko-resident, dann bleiben beide warm.`,
() => doLoadModel(name),
)
return
}
void doLoadModel(name)
}
async function handleUnloadModel(name: string) {
try {
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
@@ -878,6 +917,11 @@ export function Cockpit() {
{m.role}
</span>
)}
{isCoresident(m.name) && (
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[9px] font-mono text-fuchsia-300 font-bold uppercase" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm, verdrängt es nicht.">
🧠 Ko-resident
</span>
)}
{m.prompt_cache && (
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[9px] font-mono text-cyan-400 font-bold uppercase" title="Prompt Caching aktiv">
PC
@@ -970,6 +1014,22 @@ export function Cockpit() {
>
Ctx
</button>
{brainsGroup && (
<button
onClick={() => toggleCoresident(m.name)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer",
isCoresident(m.name)
? "border-fuchsia-500/40 bg-fuchsia-500/10 text-fuchsia-300 hover:bg-fuchsia-500/20"
: "border-border/40 text-muted-foreground hover:bg-accent"
)}
title={isCoresident(m.name)
? "Ko-resident (brains): bleibt mit dem Hirn gemeinsam warm. Klick = aus der Gruppe nehmen."
: "Zur brains-Gruppe hinzufügen — lädt dann ko-resident neben dem Hirn, statt es zu verdrängen."}
>
🧠
</button>
)}
<button
onClick={() => setSpecModel(m)}
className={cn(
@@ -1037,6 +1097,11 @@ export function Cockpit() {
{m.role}
</span>
)}
{isCoresident(m.name) && (
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[8px] font-mono text-fuchsia-300 font-bold uppercase shrink-0" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm.">
🧠 Ko-resident
</span>
)}
{m.prompt_cache && (
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[8px] font-mono text-cyan-400 font-bold uppercase shrink-0" title="Prompt Caching aktiv">
PC
@@ -1105,6 +1170,22 @@ export function Cockpit() {
>
Ctx
</button>
{brainsGroup && (
<button
onClick={() => toggleCoresident(m.name)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer",
isCoresident(m.name)
? "border-fuchsia-500/40 bg-fuchsia-500/10 text-fuchsia-300 hover:bg-fuchsia-500/20"
: "border-border/40 text-muted-foreground hover:bg-accent"
)}
title={isCoresident(m.name)
? "Ko-resident (brains): bleibt mit dem Hirn gemeinsam warm. Klick = aus der Gruppe nehmen."
: "Zur brains-Gruppe hinzufügen — lädt dann ko-resident neben dem Hirn, statt es zu verdrängen."}
>
🧠
</button>
)}
<button
onClick={() => setSpecModel(m)}
className={cn(