Zeitachse (24h-Historie), Verbinden-Autofill, Modelltausch abgesichert
Paket 1 - Metrik-Historie mit Zeitachse (User: 'WANN war Last?'): - services/metrics_history.py: 10s-Sampler (CPU/RAM/GPU/Disk + Token-Totale) in 24h-Ringpuffer (aelteres faellt automatisch raus, nichts waechst unbegrenzt), persistiert alle 5 min -> uebersteht Deploys; GET /api/system/history?minutes mit Downsampling auf ~300 Punkte; Lifespan-Task in app.py - Cockpit-Karten System-Status + Token-Durchsatz: Bereichs-Schalter Live/1h/24h, sichtbare Zeitachse (HH:MM:SS bei kurzen, HH:MM bei langen Fenstern), Tooltip zeigt Uhrzeit; Token-Raten in 1h/24h aus Totale-Deltas Paket 2 - Verbinden selbsterklaerend: - Box-IP vorbefuellt aus window.location.hostname (Dev-Fallback bleibt) - MCP-Scriptpfad-Feld aus der Kopfzeile in die 'Gedaechtnis anbinden'-Karte verschoben, mit Erklaerung WANN man es braucht Paket 3 - Modelltausch-Loecher gestopft (Review 16.07.): - install: Registrierung OHNE Alias-Umzug; Rolle wird erst NACH erfolgreichem Download uebernommen (on_done) - hermes dabei durch den warm-bewussten set_agent_brain-Flow statt rohem Alias-Move (Lucy waere sonst bis Download- Ende tot gewesen); Re-Install erhaelt bestehende Aliase - set_role_alias: lebenswichtige Aliase (hermes/embed) des Ziel-Modells ueberleben jeden Rollen-Klick (Qwen3.6 haelt live hermes+fast!) - Rollen-Endpoint verweigert Rollen-Wechsel am Halter geschuetzter Aliase mit klarer Anleitung; Werkbank sperrt die Rollen-Chips sichtbar Verifiziert: py_compile + Sampler-Smoke (2 Punkte, Persist ok) + Alias-Logik- Unittest (3 Faelle) + Browser (Zeitachse tickt, Schalter, Leerzustand, Verbinden). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,10 +18,15 @@ function fmt(v: number, unit: string): string {
|
||||
return `${n}${unit}`
|
||||
}
|
||||
|
||||
function ChartTooltip({ active, payload, unit }: any) {
|
||||
function ChartTooltip({ active, payload, unit, label }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
|
||||
{Number.isFinite(label) && (
|
||||
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
|
||||
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{payload.map((p: any) => (
|
||||
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
|
||||
@@ -36,15 +41,17 @@ function ChartTooltip({ active, payload, unit }: any) {
|
||||
}
|
||||
|
||||
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
|
||||
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice). */
|
||||
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
|
||||
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
|
||||
export function LiveAreaChart({
|
||||
data, series, unit = "%", yMode = "percent", height = 176,
|
||||
data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
|
||||
}: {
|
||||
data: any[]
|
||||
series: ChartSeries[]
|
||||
unit?: string
|
||||
yMode?: "percent" | "auto"
|
||||
height?: number
|
||||
showTime?: boolean
|
||||
}) {
|
||||
const peak = data.reduce(
|
||||
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
|
||||
@@ -53,6 +60,13 @@ export function LiveAreaChart({
|
||||
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
|
||||
: Math.max(niceCeil(peak * 1.15), 10)
|
||||
|
||||
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
|
||||
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
|
||||
const fmtTime = (v: number) =>
|
||||
new Date(v).toLocaleTimeString("de-DE",
|
||||
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
|
||||
: { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
return (
|
||||
<div style={{ height }} className="w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -66,7 +80,15 @@ export function LiveAreaChart({
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke={GRID} />
|
||||
<XAxis dataKey="t" hide />
|
||||
{showTime ? (
|
||||
<XAxis
|
||||
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
|
||||
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
|
||||
/>
|
||||
) : (
|
||||
<XAxis dataKey="t" hide />
|
||||
)}
|
||||
<YAxis
|
||||
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
|
||||
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Bereichs-Schalter der Leistungs-Karten: Live (2-min-Browser-Store) vs. 1h/24h
|
||||
// (Backend-Ringpuffer, /api/system/history). Bewusst winzig — kein Chart-Gebirge.
|
||||
export type ChartRange = "live" | "1h" | "24h"
|
||||
export const RANGE_MINUTES: Record<Exclude<ChartRange, "live">, number> = { "1h": 60, "24h": 1440 }
|
||||
|
||||
const LABELS: Record<ChartRange, string> = { live: "Live", "1h": "1 h", "24h": "24 h" }
|
||||
|
||||
export function RangeSwitch({ value, onChange }: { value: ChartRange; onChange: (r: ChartRange) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
|
||||
{(Object.keys(LABELS) as ChartRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onChange(r)}
|
||||
className={cn(
|
||||
"rounded-md px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
value === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Cpu } from "lucide-react"
|
||||
import { useSystemHistory } from "@/lib/useSystemHistory"
|
||||
import { useMetricHistory } from "@/lib/queries"
|
||||
import { gb } from "@/lib/format"
|
||||
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
|
||||
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
|
||||
|
||||
const SERIES: ChartSeries[] = [
|
||||
{ key: "cpu", label: "CPU", color: "#2dd4bf" },
|
||||
@@ -12,6 +15,14 @@ const SERIES: ChartSeries[] = [
|
||||
|
||||
export function SystemStatusCard() {
|
||||
const { sys, hist } = useSystemHistory()
|
||||
const [range, setRange] = useState<ChartRange>("live")
|
||||
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
|
||||
|
||||
// Live = 3-s-Browser-Store (~2 min); 1h/24h = Backend-Ringpuffer mit echten Zeitstempeln.
|
||||
const chartData = useMemo(() => {
|
||||
if (range === "live") return hist
|
||||
return (histQ.data?.points ?? []).map((p) => ({ t: p.t * 1000, cpu: p.cpu, ram: p.ram, gpu: p.gpu, disk: p.disk }))
|
||||
}, [range, hist, histQ.data])
|
||||
|
||||
const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null)
|
||||
const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu)
|
||||
@@ -33,9 +44,12 @@ export function SystemStatusCard() {
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Cpu className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2>
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
{range === "live" && (
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto"><RangeSwitch value={range} onChange={setRange} /></div>
|
||||
</div>
|
||||
|
||||
{sys ? (
|
||||
@@ -50,7 +64,14 @@ export function SystemStatusCard() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LiveAreaChart data={hist} series={activeSeries} unit="%" yMode="percent" height={176} />
|
||||
{range !== "live" && chartData.length === 0 && (
|
||||
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">
|
||||
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
|
||||
</div>
|
||||
)}
|
||||
{(range === "live" || chartData.length > 0) && (
|
||||
<LiveAreaChart data={chartData} series={activeSeries} unit="%" yMode="percent" height={176} showTime />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten…</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Activity } from "lucide-react"
|
||||
import { useTokenStats } from "@/lib/queries"
|
||||
import { useTokenStats, useMetricHistory } from "@/lib/queries"
|
||||
import { useTokHistory } from "@/lib/metricsStore"
|
||||
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
|
||||
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
|
||||
|
||||
const SERIES: ChartSeries[] = [
|
||||
{ key: "prompt", label: "Prompt", color: "#f59e0b" },
|
||||
@@ -10,7 +12,27 @@ const SERIES: ChartSeries[] = [
|
||||
|
||||
export function TokenPerformanceCard() {
|
||||
const { data: ts } = useTokenStats()
|
||||
const hist = useTokHistory() // Durchsatz-Verlauf aus dem modul-globalen Store
|
||||
const hist = useTokHistory() // Live-Durchsatz aus dem modul-globalen Store (~2 min)
|
||||
const [range, setRange] = useState<ChartRange>("live")
|
||||
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
|
||||
|
||||
// 1h/24h: Raten aus den Deltas der Gesamtzähler (tok/s zwischen zwei Sample-Punkten).
|
||||
const chartData = useMemo(() => {
|
||||
if (range === "live") return hist
|
||||
const pts = histQ.data?.points ?? []
|
||||
const out: { t: number; prompt: number; completion: number }[] = []
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const a = pts[i - 1], b = pts[i]
|
||||
if (a.tp == null || b.tp == null || a.tc == null || b.tc == null) continue
|
||||
const dt = Math.max(b.t - a.t, 1)
|
||||
out.push({
|
||||
t: b.t * 1000,
|
||||
prompt: Math.max(0, (b.tp - a.tp) / dt),
|
||||
completion: Math.max(0, (b.tc - a.tc) / dt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}, [range, hist, histQ.data])
|
||||
|
||||
const last = hist[hist.length - 1]
|
||||
const curRate = last ? Math.round(last.prompt + last.completion) : 0
|
||||
@@ -22,9 +44,11 @@ export function TokenPerformanceCard() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</h2>
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
{range === "live" && (
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ts && (
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
@@ -45,21 +69,28 @@ export function TokenPerformanceCard() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5 pt-1">
|
||||
{SERIES.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
|
||||
{Math.round((last?.[s.key as "prompt" | "completion"]) ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 pt-1">
|
||||
<RangeSwitch value={range} onChange={setRange} />
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{SERIES.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
|
||||
{Math.round((last?.[s.key as "prompt" | "completion"]) ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ts ? (
|
||||
<LiveAreaChart data={hist} series={SERIES} unit=" tok/s" yMode="auto" height={150} />
|
||||
{range !== "live" && chartData.length === 0 ? (
|
||||
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">
|
||||
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
|
||||
</div>
|
||||
) : ts ? (
|
||||
<LiveAreaChart data={chartData} series={SERIES} unit=" tok/s" yMode="auto" height={150} showTime />
|
||||
) : (
|
||||
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz…</div>
|
||||
)}
|
||||
|
||||
@@ -417,6 +417,22 @@ export interface Health {
|
||||
brain?: { role: string; model: string | null; ready: boolean }
|
||||
}
|
||||
|
||||
// 24-h-Metrik-Verlauf (GET /api/system/history?minutes=N) — Basis der Cockpit-Zeitachse.
|
||||
// tp/tc sind Token-GESAMTZÄHLER; Raten rechnet das Frontend aus den Deltas.
|
||||
export interface HistoryPoint {
|
||||
t: number // Epoch-Sekunden
|
||||
cpu: number | null
|
||||
ram: number | null
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
tp: number | null
|
||||
tc: number | null
|
||||
}
|
||||
export interface HistoryResp {
|
||||
sample_s: number
|
||||
points: HistoryPoint[]
|
||||
}
|
||||
|
||||
export interface TokenStats {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type GroupsResp,
|
||||
type HermesBrainResp,
|
||||
type Health,
|
||||
type HistoryResp,
|
||||
type IdeenLogResp,
|
||||
type IdeenResp,
|
||||
type Job,
|
||||
@@ -169,6 +170,16 @@ export const useHealth = () =>
|
||||
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
|
||||
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
|
||||
|
||||
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
|
||||
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
|
||||
export const useMetricHistory = (minutes: number, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: ["metrics-history", minutes],
|
||||
queryFn: () => api<HistoryResp>(`/api/system/history?minutes=${minutes}`),
|
||||
enabled,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const useServices = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
|
||||
|
||||
|
||||
@@ -72,8 +72,16 @@ function CodeWindow({
|
||||
)
|
||||
}
|
||||
|
||||
// Die Box-Adresse kennt der Browser schon — er ist ja über sie verbunden. Nur im
|
||||
// Dev-Modus (localhost + Vite-Proxy) greift der bekannte LAN-Fallback.
|
||||
function defaultHost(): string {
|
||||
const h = window.location.hostname
|
||||
if (h && h !== "localhost" && h !== "127.0.0.1") return h
|
||||
return "192.168.178.151"
|
||||
}
|
||||
|
||||
export function ConnectView() {
|
||||
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
|
||||
const [host, setHost] = useState(localStorage.getItem("mc_host") || defaultHost())
|
||||
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
|
||||
// Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg.
|
||||
const [active, setActive] = useState("kilo")
|
||||
@@ -187,11 +195,12 @@ export function ConnectView() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gemeinsame Variablen */}
|
||||
<div className="grid gap-4 md:grid-cols-2 p-5 mc-card">
|
||||
<div className="space-y-1.5">
|
||||
{/* Box-Adresse — vorbefüllt aus der Browser-URL; anfassen nur, wenn sich die Box-IP ändert. */}
|
||||
<div className="p-5 mc-card">
|
||||
<div className="space-y-1.5 max-w-md">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Globe className="h-3.5 w-3.5 text-primary" /> Box LAN IP-Adresse
|
||||
<span className="normal-case font-semibold tracking-normal text-muted-foreground/60">— vorbefüllt aus der Adresse dieser Seite</span>
|
||||
</label>
|
||||
<input
|
||||
value={host}
|
||||
@@ -201,21 +210,9 @@ export function ConnectView() {
|
||||
placeholder="z.B. 192.168.178.151"
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-violet-400" /> Lokaler MCP-Scriptpfad
|
||||
<span className="text-violet-400/70 normal-case font-semibold tracking-normal">(nur Leitung 2)</span>
|
||||
</label>
|
||||
<input
|
||||
value={mcpPath}
|
||||
onChange={(e) => saveMcpPath(e.target.value)}
|
||||
aria-label="Lokaler MCP-Scriptpfad"
|
||||
spellCheck={false}
|
||||
placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py"
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-violet-500/50 text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Wird in alle Snippets eingebacken. Ändern musst du sie nur, wenn die Box eine neue IP bekommt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,6 +329,26 @@ export function ConnectView() {
|
||||
<span>{data.memory.note}</span>
|
||||
</div>
|
||||
|
||||
{/* Der Pfad wirkt NUR auf dieses Snippet — deshalb wohnt das Feld hier,
|
||||
nicht mehr prominent in der Kopfzeile („Wann brauche ich das?"). */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-violet-400" /> Wo liegt mcp_memory.py auf DIESEM PC?
|
||||
</label>
|
||||
<input
|
||||
value={mcpPath}
|
||||
onChange={(e) => saveMcpPath(e.target.value)}
|
||||
aria-label="Lokaler MCP-Scriptpfad"
|
||||
spellCheck={false}
|
||||
placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py"
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-violet-500/50 text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Nur nötig, wenn du das Gedächtnis in einem PC-Tool anschließt — das Script läuft lokal
|
||||
und spricht mit der Box. Der Pfad landet direkt im Snippet unten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CodeWindow
|
||||
tool={data.memory}
|
||||
fileName="mcp.json"
|
||||
|
||||
@@ -259,14 +259,18 @@ function Detail({
|
||||
{lockUnload ? "🔒 bleibt geladen" : warm ? "Entladen" : "Ins Warm-Set laden"}
|
||||
</button>
|
||||
|
||||
{/* Rolle wählen */}
|
||||
{/* Rolle wählen — bei lebenswichtigen Rollen (Hirn/Gedächtnis) gesperrt: die Rolle
|
||||
wandert nur, indem man sie einem ANDEREN Modell zuweist (Backend erzwingt das auch). */}
|
||||
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Rolle</div>
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span>Rolle</span>
|
||||
{protectedRole && <span className="normal-case font-medium tracking-normal text-muted-foreground/70">🔒 lebenswichtig — zum Tausch die Rolle woanders zuweisen</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" />
|
||||
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" disabled={protectedRole} />
|
||||
{ROLE_META.map((r) => (
|
||||
<RoleChip key={r.role} active={m.role === r.role} onClick={() => onRole(r.role)}
|
||||
dot={roleBarColor(r.role).dot} label={r.short} />
|
||||
dot={roleBarColor(r.role).dot} label={r.short} disabled={protectedRole && m.role !== r.role} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,11 +326,16 @@ function Detail({
|
||||
)
|
||||
}
|
||||
|
||||
function RoleChip({ active, onClick, dot, label }: { active: boolean; onClick: () => void; dot: string; label: string }) {
|
||||
function RoleChip({ active, onClick, dot, label, disabled }: {
|
||||
active: boolean; onClick: () => void; dot: string; label: string; disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
active ? "border-primary/60 bg-primary/15 text-foreground" : "border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}>
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
title={disabled ? "Gesperrt: dieses Modell hält eine lebenswichtige Rolle." : undefined}
|
||||
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all",
|
||||
disabled ? "cursor-not-allowed opacity-40 border-border/40 bg-background/20 text-muted-foreground"
|
||||
: active ? "cursor-pointer border-primary/60 bg-primary/15 text-foreground"
|
||||
: "cursor-pointer border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}>
|
||||
<span className={cn("h-2 w-2 rounded-sm", dot)} />
|
||||
{label}
|
||||
{active && <Check className="h-3 w-3 text-primary" />}
|
||||
|
||||
Reference in New Issue
Block a user