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
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
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
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BcdAxUp3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFWF7uFR.css">
<script type="module" crossorigin src="/assets/index-BK9pTA8z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDCyVscm.css">
</head>
<body>
<div id="root"></div>
+14
View File
@@ -53,6 +53,20 @@ export async function api<T = unknown>(path: string, init?: RequestInit): Promis
return res.json() as Promise<T>
}
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
export interface GroupSpec {
swap: boolean
persist: boolean
members: string[]
}
export interface GroupsResp {
groups: Record<string, GroupSpec>
}
export const getGroups = () => api<GroupsResp>("/api/groups")
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
export interface Capabilities {
moe: boolean
active_b: number | null
+5
View File
@@ -10,6 +10,7 @@ import {
type ConnectHealth,
type DiscoverResp,
type DraftsResp,
type GroupsResp,
type HermesBrainResp,
type Health,
type Job,
@@ -29,6 +30,7 @@ export const qk = {
systemStatus: ["system-status"] as const,
services: ["services"] as const,
models: ["models"] as const,
groups: ["groups"] as const,
routing: ["routing"] as const,
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
@@ -62,6 +64,9 @@ export const useServices = (refetchInterval = 3_000) =>
export const useModels = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useGroups = (refetchInterval = 8_000) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
+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(