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:
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+5461
File diff suppressed because one or more lines are too long
-5459
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user