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="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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user