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="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BcdAxUp3.js"></script> <script type="module" crossorigin src="/assets/index-BK9pTA8z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFWF7uFR.css"> <link rel="stylesheet" crossorigin href="/assets/index-CDCyVscm.css">
</head> </head>
<body> <body>
<div id="root"></div> <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> 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 { export interface Capabilities {
moe: boolean moe: boolean
active_b: number | null active_b: number | null
+5
View File
@@ -10,6 +10,7 @@ import {
type ConnectHealth, type ConnectHealth,
type DiscoverResp, type DiscoverResp,
type DraftsResp, type DraftsResp,
type GroupsResp,
type HermesBrainResp, type HermesBrainResp,
type Health, type Health,
type Job, type Job,
@@ -29,6 +30,7 @@ export const qk = {
systemStatus: ["system-status"] as const, systemStatus: ["system-status"] as const,
services: ["services"] as const, services: ["services"] as const,
models: ["models"] as const, models: ["models"] as const,
groups: ["groups"] as const,
routing: ["routing"] as const, routing: ["routing"] as const,
jobs: ["jobs"] as const, jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const, tokenStats: ["token-stats"] as const,
@@ -62,6 +64,9 @@ export const useServices = (refetchInterval = 3_000) =>
export const useModels = (refetchInterval = 4_000) => export const useModels = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval }) 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) => export const useRouting = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval }) 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 { useState, useRef, useCallback } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Copy, Zap, Bot } from "lucide-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 { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
import { useModels, useRouting, useConnect, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries" import { useModels, useGroups, useRouting, useConnect, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { CapsChips } from "@/components/CapsChips" import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -17,6 +17,7 @@ export function Cockpit() {
const { data: updates } = useUpdates(4_000) const { data: updates } = useUpdates(4_000)
const { data: brain } = useHermesBrain() const { data: brain } = useHermesBrain()
const { data: sysStatus } = useSystemStatus() const { data: sysStatus } = useSystemStatus()
const { data: groupsResp } = useGroups()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog() const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
const models = modelsResp?.models ?? [] const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? [] const running = modelsResp?.running ?? []
@@ -26,6 +27,30 @@ export function Cockpit() {
qc.invalidateQueries({ queryKey: qk.routing }) 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 // UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null) const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleForAssign, setActiveRoleForAssign] = useState<string | 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}` return `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`
} }
async function handleLoadModel(name: string) { async function doLoadModel(name: string) {
try { try {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" }) await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
reload() 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) { async function handleUnloadModel(name: string) {
try { try {
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" }) await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
@@ -878,6 +917,11 @@ export function Cockpit() {
{m.role} {m.role}
</span> </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 && ( {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"> <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 PC
@@ -970,6 +1014,22 @@ export function Cockpit() {
> >
Ctx Ctx
</button> </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 <button
onClick={() => setSpecModel(m)} onClick={() => setSpecModel(m)}
className={cn( className={cn(
@@ -1037,6 +1097,11 @@ export function Cockpit() {
{m.role} {m.role}
</span> </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 && ( {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"> <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 PC
@@ -1105,6 +1170,22 @@ export function Cockpit() {
> >
Ctx Ctx
</button> </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 <button
onClick={() => setSpecModel(m)} onClick={() => setSpecModel(m)}
className={cn( className={cn(