Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8d088fac | ||
|
|
b570e9410d | ||
|
|
e9c2599542 | ||
|
|
a8a6ce3b29 | ||
|
|
0944b1d93e | ||
|
|
1c8f285151 | ||
|
|
041ede7f8d | ||
|
|
33032f8fb0 | ||
|
|
3249c8e942 | ||
|
|
24a0694b80 | ||
|
|
a494d320d7 | ||
|
|
7957cda438 |
+22
-4
@@ -18,18 +18,36 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5180",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "frontend-mock",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5181",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
|
||||
@@ -65,7 +65,19 @@ jobs:
|
||||
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
|
||||
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
|
||||
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
|
||||
budget_gz=125000 # Stand nach P0: 110 571 B gzip
|
||||
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
|
||||
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
|
||||
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
|
||||
# bewusst und einmalig, nicht weil es im Weg stand:
|
||||
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
|
||||
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
|
||||
# hinter lazy() wanderten und das Budget scharf blieb.
|
||||
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
|
||||
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
|
||||
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
|
||||
# ueberlegten Plattform-Schritt.
|
||||
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
|
||||
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
|
||||
budget_dist=3145728 # Stand nach P0: 1 477 852 B
|
||||
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
|
||||
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
|
||||
|
||||
@@ -96,6 +96,11 @@ HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||
# passen. MC2 proxyt nach außen.
|
||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import agent_aktivitaet
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
|
||||
def set_brain_model(body: BrainReq) -> dict:
|
||||
ok = update_brain_model(body.model)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@router.get("/agent/aktivitaet")
|
||||
def aktivitaet(limit: int = 60) -> dict:
|
||||
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
|
||||
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
|
||||
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
|
||||
"""
|
||||
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
|
||||
|
||||
@@ -17,7 +17,7 @@ import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
|
||||
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
|
||||
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
# LAN-only wie alle MC2-Endpoints.
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
with Timer("lucy_tts"):
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
|
||||
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
|
||||
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
|
||||
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
|
||||
zu patchen.
|
||||
|
||||
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
|
||||
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
|
||||
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
|
||||
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
|
||||
nützlicher als der Fließtext ihrer Gedanken.
|
||||
|
||||
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
|
||||
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
|
||||
halten kann.
|
||||
|
||||
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
|
||||
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
|
||||
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
|
||||
LOG_PFAD = Path(os.path.expanduser(
|
||||
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
|
||||
|
||||
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
|
||||
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
|
||||
LESE_BYTES = 512 * 1024
|
||||
|
||||
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
|
||||
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
|
||||
#
|
||||
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
|
||||
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
|
||||
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
|
||||
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
|
||||
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
|
||||
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
|
||||
|
||||
_FERTIG = re.compile(
|
||||
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
|
||||
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
|
||||
|
||||
_FEHLER = re.compile(
|
||||
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
|
||||
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
|
||||
|
||||
|
||||
def _schwanz(pfad: Path, n: int) -> list[str]:
|
||||
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
|
||||
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
|
||||
try:
|
||||
groesse = pfad.stat().st_size
|
||||
with pfad.open("rb") as f:
|
||||
f.seek(max(0, groesse - n))
|
||||
roh = f.read()
|
||||
zeilen = roh.decode("utf-8", errors="replace").splitlines()
|
||||
return zeilen[1:] if groesse > n and zeilen else zeilen
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _kurz(detail: str | None) -> str | None:
|
||||
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
|
||||
JSON ab; interessant ist daran nur das `error`-Feld."""
|
||||
if not detail:
|
||||
return None
|
||||
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
|
||||
text = treffer.group(1) if treffer else detail.strip()
|
||||
return (text[:297] + "…") if len(text) > 300 else text
|
||||
|
||||
|
||||
def rufe(limit: int = 60) -> list[dict]:
|
||||
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
|
||||
ergebnis: list[dict] = []
|
||||
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
|
||||
m = _FERTIG.match(zeile)
|
||||
if m:
|
||||
ergebnis.append({
|
||||
"zeit": _iso(m.group("zeit")),
|
||||
"lauf": m.group("lauf"),
|
||||
"werkzeug": m.group("werkzeug"),
|
||||
"dauer_s": float(m.group("dauer")),
|
||||
"zeichen": int(m.group("zeichen")),
|
||||
"ok": True,
|
||||
"fehler": None,
|
||||
})
|
||||
continue
|
||||
m = _FEHLER.match(zeile)
|
||||
if m:
|
||||
ergebnis.append({
|
||||
"zeit": _iso(m.group("zeit")),
|
||||
"lauf": m.group("lauf"),
|
||||
"werkzeug": m.group("werkzeug"),
|
||||
"dauer_s": float(m.group("dauer")),
|
||||
"zeichen": None,
|
||||
"ok": False,
|
||||
"fehler": _kurz(m.group("detail")),
|
||||
})
|
||||
return ergebnis[-limit:]
|
||||
|
||||
|
||||
def _iso(s: str) -> str:
|
||||
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
|
||||
|
||||
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
|
||||
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
|
||||
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
|
||||
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
|
||||
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
|
||||
Falle, die erst zur Zeitumstellung zuschnappt."""
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
|
||||
except ValueError:
|
||||
return s
|
||||
|
||||
|
||||
def uebersicht(limit: int = 60) -> dict:
|
||||
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
|
||||
wiederkehrenden Fehler zusammengefasst.
|
||||
|
||||
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
|
||||
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
|
||||
liste = rufe(limit)
|
||||
if not LOG_PFAD.exists():
|
||||
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
|
||||
"werkzeuge": [], "laeufe": []}
|
||||
|
||||
bilanz: dict[str, dict] = {}
|
||||
for r in liste:
|
||||
b = bilanz.setdefault(r["werkzeug"], {
|
||||
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
|
||||
"dauer_summe": 0.0, "letzter_fehler": None,
|
||||
})
|
||||
b["rufe"] += 1
|
||||
b["dauer_summe"] += r["dauer_s"]
|
||||
if not r["ok"]:
|
||||
b["fehler"] += 1
|
||||
b["letzter_fehler"] = r["fehler"]
|
||||
|
||||
werkzeuge = sorted(
|
||||
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
|
||||
for b in bilanz.values()),
|
||||
key=lambda b: (-b["fehler"], -b["rufe"]),
|
||||
)
|
||||
|
||||
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
|
||||
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
|
||||
laeufe: list[dict] = []
|
||||
gesehen: dict[str, dict] = {}
|
||||
for r in liste:
|
||||
schluessel = r["lauf"] or "(interaktiv)"
|
||||
if schluessel not in gesehen:
|
||||
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
|
||||
"rufe": 0, "fehler": 0}
|
||||
laeufe.append(gesehen[schluessel])
|
||||
e = gesehen[schluessel]
|
||||
e["bis"] = r["zeit"]
|
||||
e["rufe"] += 1
|
||||
if not r["ok"]:
|
||||
e["fehler"] += 1
|
||||
|
||||
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
|
||||
"werkzeuge": werkzeuge, "laeufe": laeufe}
|
||||
@@ -20,7 +20,7 @@ import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
from config import HERMES_API_KEY, HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
|
||||
from services import announce, llamaswap
|
||||
|
||||
@@ -34,14 +34,26 @@ REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung
|
||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
||||
|
||||
|
||||
def _reach(url: str, path: str = "/health") -> bool:
|
||||
def _reach(url: str, path: str = "/health", headers: dict[str, str] | None = None) -> bool:
|
||||
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
|
||||
"laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert.
|
||||
|
||||
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
|
||||
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
|
||||
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht —
|
||||
und er haette einen echten Auth-Fehler unter sich begraben."""
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
return c.get(f"{url}{path}").status_code < 500
|
||||
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _hermes_kopf() -> dict[str, str]:
|
||||
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
|
||||
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||||
|
||||
|
||||
def _check_engine() -> bool:
|
||||
return llamaswap.engine_reachable()
|
||||
|
||||
@@ -70,7 +82,7 @@ CHECKS: dict[str, tuple] = {
|
||||
"brain": (_check_brain,
|
||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
||||
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models"),
|
||||
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()),
|
||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
||||
"Der Agent-Dienst läuft wieder."),
|
||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
||||
|
||||
@@ -59,7 +59,13 @@ for t in $(systemctl --user list-timers --no-legend --no-pager 2>/dev/null | awk
|
||||
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
|
||||
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null)
|
||||
wann=$(systemctl --user show -p ExecMainExitTimestamp --value "$dienst" 2>/dev/null | cut -d' ' -f2,3)
|
||||
if [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
|
||||
# Nach einem Neustart ist der "letzte Lauf" leer, obwohl nichts kaputt ist —
|
||||
# am 04.09. machte der Radar daraus "hohes Risiko, sofort in Betrieb nehmen".
|
||||
# Junge Box (< 1 Tag) + Timer hat einen naechsten Termin = wartet, kein Befund.
|
||||
naechster=$(systemctl --user show -p NextElapseUSecRealtime --value "${dienst%.service}.timer" 2>/dev/null | cut -d' ' -f2,3)
|
||||
if [ -z "$wann" ] && [ "$(cut -d. -f1 /proc/uptime)" -lt 86400 ] && [ -n "$naechster" ]; then
|
||||
ok "$dienst wartet auf den ersten Lauf seit dem Neustart (naechster: $naechster)"
|
||||
elif [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
|
||||
elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
|
||||
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
|
||||
done
|
||||
|
||||
@@ -42,6 +42,27 @@ if [ -z "$IST" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Der Blick nach DRAUSSEN (04.09.2026): Releases, PRs, neue Entwurfsmodelle —
|
||||
# als Skript-Fakten, gegen den letzten Lauf verglichen. Ohne das lag DFlash2 fuer
|
||||
# den Coder zwei Wochen ungenutzt auf der Platte. Faellt es aus, bleibt der
|
||||
# Innen-Bericht vollstaendig; das Ausbleiben steht dann selbst als ACHTUNG drin.
|
||||
UPSTREAM_SKRIPT="${UPSTREAM_SKRIPT:-$HIER/stack-upstream.py}"
|
||||
if [ -f "$UPSTREAM_SKRIPT" ]; then
|
||||
UPSTREAM="$(timeout 240 python3 "$UPSTREAM_SKRIPT" 2>&1)" \
|
||||
|| UPSTREAM="UPSTREAM (Watchlist)
|
||||
ACHTUNG Upstream-Pruefung abgebrochen (exit $?) — $UPSTREAM_SKRIPT nachsehen"
|
||||
# Nur Aenderungen ans Modell. Beim ersten Lauf (04.09.) bekam es auch die
|
||||
# "unveraendert"-Zeilen und erfand zu jeder einen "naechsten Schritt" — der
|
||||
# Prompt-Hinweis half nicht. Fakten filtert das Skript, nicht die Prosa.
|
||||
ANZ_QUELLEN="$(printf '%s\n' "$UPSTREAM" | grep -cE '^\s+(OK|ACHTUNG)' || true)"
|
||||
UPSTREAM_NEU="$(printf '%s\n' "$UPSTREAM" | grep -A1 -E '^\s+ACHTUNG' | grep -vE '^--$' || true)"
|
||||
[ -z "$UPSTREAM_NEU" ] && UPSTREAM_NEU=" OK nichts Neues draussen ($ANZ_QUELLEN Quellen geprueft, alle unveraendert)"
|
||||
IST="$IST
|
||||
|
||||
UPSTREAM (was hat sich draussen getan?)
|
||||
$UPSTREAM_NEU"
|
||||
fi
|
||||
|
||||
ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
|
||||
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
|
||||
|
||||
@@ -61,6 +82,9 @@ Regeln:
|
||||
Fuellmaterial wird nicht gelesen.
|
||||
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
|
||||
nicht unter Verbesserungen.
|
||||
- Zeilen mit NEU im Abschnitt UPSTREAM sind Neuigkeiten von draussen. Nenne
|
||||
jede kurz und gib den Hinweis dahinter (der Pfeil-Satz) als naechsten Schritt
|
||||
wieder. 'erstmals erfasst' und 'unveraendert' sind KEINE Neuigkeiten.
|
||||
- Wenn alles gruen ist, sag das in einem Satz und hoere auf.
|
||||
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stack-upstream.py — der Blick nach draussen, in Fakten (04.09.2026).
|
||||
|
||||
Ergaenzt stack-ist.sh: das prueft, ob die Box STIMMT. Dieses Skript prueft, ob
|
||||
DRAUSSEN etwas passiert ist, das uns betrifft — neue Releases, gemergte PRs,
|
||||
neue Entwurfsmodelle, neue Modelle der Familien, die wir fahren.
|
||||
|
||||
Warum es das braucht: Am 19.08. lagen die DFlash2-Entwuerfe fuer den Coder auf
|
||||
der Platte, am 27.08. konnte llama.cpp sie, und bis zum 04.09. hat es niemand
|
||||
gemerkt — der Coder lief zwei Wochen mit 12,6 statt 31 t/s. Der KISS-Radar sah
|
||||
nur nach innen. Jetzt sieht er auch nach draussen, aber ohne Modell-Fantasie:
|
||||
jede Zeile hier ist ein abgerufener Wert, verglichen mit dem Wert vom letzten Lauf.
|
||||
|
||||
Quelle: deploy/trend-radar-watchlist.json (Eintraege mit `typ`, siehe unten).
|
||||
Zustand: ~/.hermes/state/stack-upstream.json (Wert je Eintrag vom letzten Lauf).
|
||||
Ausgabe: im Format von stack-ist.sh — `OK` / `ACHTUNG NEU:` / `ACHTUNG ... nicht abrufbar`.
|
||||
Beim ERSTEN Lauf wird nur erfasst (OK), nicht gemeldet — sonst waere jeder
|
||||
Eintrag sofort "neu".
|
||||
|
||||
Typen:
|
||||
github_issue repo, nummer → offen/geschlossen
|
||||
github_pr repo, nummer → offen/gemergt/geschlossen
|
||||
github_release repo → juengster Release-Tag
|
||||
github_release_major repo → nur Major-Sprung meldet
|
||||
github_branch repo, branch → juengster Commit (Datum)
|
||||
pypi paket → Version
|
||||
hf_author author → neue Modell-Repos des Autors
|
||||
url_zeilen url, muster, max_zeilen → Zeilen mit Muster, Aenderung = neu
|
||||
|
||||
Kein set -e-Aequivalent: jeder Eintrag wird einzeln behandelt, ein Fehler
|
||||
nimmt die anderen nicht mit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
WATCHLIST = Path(sys.argv[1] if len(sys.argv) > 1 else
|
||||
os.path.expanduser("~/mission-control-v2/deploy/trend-radar-watchlist.json"))
|
||||
STATE = Path(os.environ.get("STACK_UPSTREAM_STATE",
|
||||
os.path.expanduser("~/.hermes/state/stack-upstream.json")))
|
||||
TIMEOUT = 20
|
||||
UA = "mc2-stack-upstream/1.0 (+https://github.com/Hitonabi)"
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f" OK {msg}")
|
||||
|
||||
|
||||
def neu(msg: str) -> None:
|
||||
print(f" ACHTUNG NEU: {msg}")
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f" ACHTUNG {msg}")
|
||||
|
||||
|
||||
def hole(url: str, json_erwartet: bool = True):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
|
||||
tok = os.environ.get("GITHUB_TOKEN")
|
||||
if tok and "api.github.com" in url:
|
||||
req.add_header("Authorization", f"Bearer {tok}")
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||
roh = r.read().decode("utf-8", "replace")
|
||||
return json.loads(roh) if json_erwartet else roh
|
||||
|
||||
|
||||
# ------------------------------------------------------------- Pruefer ---
|
||||
# Jeder Pruefer liefert (wert, anzeige). `wert` wird mit dem letzten Lauf
|
||||
# verglichen, `anzeige` ist die menschenlesbare Fassung.
|
||||
|
||||
def p_github_issue(e):
|
||||
d = hole(f"https://api.github.com/repos/{e['repo']}/issues/{e['nummer']}")
|
||||
st = "geschlossen" if d.get("state") == "closed" else "offen"
|
||||
return st, f"#{e['nummer']} {e['repo']} ist {st} — {d.get('title', '')[:70]}"
|
||||
|
||||
|
||||
def p_github_pr(e):
|
||||
d = hole(f"https://api.github.com/repos/{e['repo']}/pulls/{e['nummer']}")
|
||||
if d.get("merged_at"):
|
||||
st = f"gemergt am {d['merged_at'][:10]}"
|
||||
elif d.get("state") == "closed":
|
||||
st = "geschlossen ohne Merge"
|
||||
else:
|
||||
st = "offen" + (" (Entwurf)" if d.get("draft") else "")
|
||||
return st, f"PR #{e['nummer']} {e['repo']} {st} — {d.get('title', '')[:70]}"
|
||||
|
||||
|
||||
def _release(e):
|
||||
d = hole(f"https://api.github.com/repos/{e['repo']}/releases/latest")
|
||||
return d.get("tag_name", "?"), (d.get("published_at") or "")[:10]
|
||||
|
||||
|
||||
def p_github_release(e):
|
||||
tag, wann = _release(e)
|
||||
return tag, f"{e['repo']} juengster Release {tag} ({wann})"
|
||||
|
||||
|
||||
def p_github_release_major(e):
|
||||
tag, wann = _release(e)
|
||||
m = re.search(r"(\d+)", tag)
|
||||
major = m.group(1) if m else tag
|
||||
return major, f"{e['repo']} Major {major} (Release {tag}, {wann})"
|
||||
|
||||
|
||||
def p_github_branch(e):
|
||||
d = hole(f"https://api.github.com/repos/{e['repo']}/commits?sha={e['branch']}&per_page=1")
|
||||
c = d[0]
|
||||
wann = c["commit"]["committer"]["date"][:10]
|
||||
return c["sha"][:10], f"{e['repo']}@{e['branch']} letzter Commit {wann} — {c['commit']['message'].splitlines()[0][:60]}"
|
||||
|
||||
|
||||
def p_pypi(e):
|
||||
d = hole(f"https://pypi.org/pypi/{e['paket']}/json")
|
||||
v = d["info"]["version"]
|
||||
return v, f"PyPI {e['paket']} = {v}"
|
||||
|
||||
|
||||
def p_hf_author(e):
|
||||
d = hole(f"https://huggingface.co/api/models?author={e['author']}&sort=lastModified&direction=-1&limit={e.get('limit', 15)}")
|
||||
ids = sorted(m["id"] for m in d)
|
||||
return "\n".join(ids), f"{len(ids)} juengste Repos von {e['author']} auf Hugging Face"
|
||||
|
||||
|
||||
def p_url_zeilen(e):
|
||||
roh = hole(e["url"], json_erwartet=False)
|
||||
zeilen = [z.strip() for z in roh.splitlines() if e["muster"] in z][: int(e.get("max_zeilen", 30))]
|
||||
h = hashlib.sha1("\n".join(zeilen).encode()).hexdigest()[:10]
|
||||
return h, f"{len(zeilen)} Zeilen mit '{e['muster']}' in {e['url'].split('/')[2]} (Stand {h})"
|
||||
|
||||
|
||||
PRUEFER = {
|
||||
"github_issue": p_github_issue,
|
||||
"github_pr": p_github_pr,
|
||||
"github_release": p_github_release,
|
||||
"github_release_major": p_github_release_major,
|
||||
"github_branch": p_github_branch,
|
||||
"pypi": p_pypi,
|
||||
"hf_author": p_hf_author,
|
||||
"url_zeilen": p_url_zeilen,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("UPSTREAM (Watchlist — was hat sich draussen getan?)")
|
||||
try:
|
||||
eintraege = json.loads(WATCHLIST.read_text(encoding="utf-8")).get("eintraege", [])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warn(f"Watchlist {WATCHLIST} nicht lesbar: {exc}")
|
||||
return 0
|
||||
try:
|
||||
state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
|
||||
except Exception: # noqa: BLE001
|
||||
state = {}
|
||||
|
||||
for e in eintraege:
|
||||
key, typ = e.get("key", "?"), e.get("typ", "?")
|
||||
pruefer = PRUEFER.get(typ)
|
||||
if not pruefer:
|
||||
warn(f"{key}: unbekannter Typ '{typ}' — Eintrag wird ignoriert")
|
||||
continue
|
||||
try:
|
||||
wert, anzeige = pruefer(e)
|
||||
except urllib.error.HTTPError as exc:
|
||||
warn(f"{key}: nicht abrufbar (HTTP {exc.code})")
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warn(f"{key}: nicht abrufbar ({type(exc).__name__}: {str(exc)[:60]})")
|
||||
continue
|
||||
|
||||
alt = state.get(key, {}).get("wert")
|
||||
if alt is None:
|
||||
ok(f"{key}: erstmals erfasst — {anzeige}")
|
||||
elif alt == wert:
|
||||
ok(f"{key}: unveraendert — {anzeige}")
|
||||
else:
|
||||
if typ == "hf_author":
|
||||
neue = sorted(set(wert.split("\n")) - set(alt.split("\n")))
|
||||
anzeige = f"neue Repos von {e['author']}: " + ", ".join(neue[:8]) if neue else anzeige
|
||||
hinweis = e.get("warum", "")
|
||||
neu(f"{key}: {anzeige}" + (f"\n → {hinweis[:220]}" if hinweis else ""))
|
||||
state[key] = {"wert": wert, "anzeige": anzeige}
|
||||
|
||||
try:
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, STATE)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warn(f"Zustand nicht speicherbar ({exc}) — naechster Lauf meldet alles erneut")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -25,11 +25,14 @@ models:
|
||||
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear),
|
||||
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
|
||||
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
||||
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
|
||||
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0
|
||||
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||
ttl: 5400
|
||||
aliases:
|
||||
- coder
|
||||
- heavy
|
||||
capabilities:
|
||||
in: [text, image]
|
||||
out: [text]
|
||||
@@ -62,8 +65,8 @@ models:
|
||||
cmd: |
|
||||
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
||||
ttl: 600
|
||||
aliases:
|
||||
- heavy
|
||||
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
|
||||
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
|
||||
capabilities:
|
||||
in: [text]
|
||||
out: [text]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
},
|
||||
"heavy": {
|
||||
"name": "heavy - Planen / Review",
|
||||
"name": "heavy - Planen / Review (= Qwen3.8-27B, seit 04.09. mit DFlash2)",
|
||||
"tool_call": true,
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
|
||||
@@ -1,51 +1,73 @@
|
||||
{
|
||||
"_hinweis": "Living Watchlist des Trend-Radars. Diese Datei MUSS autonom vom Trend-Radar Agenten umgeschrieben werden, wenn er neue Quellen findet oder alte obsolet werden.",
|
||||
"_hinweis": "Watchlist des Stack-Radars. Wird von deploy/jobs/stack-upstream.py (Skript, kein Modell) samstags abgefragt; jeder Eintrag = ein abrufbarer Wert, verglichen mit dem letzten Lauf. Typen: github_issue, github_pr, github_release, github_release_major, github_branch, pypi, hf_author, url_zeilen. `warum` wird bei NEU mitgemeldet — dort steht, was dann zu tun ist.",
|
||||
"eintraege": [
|
||||
{
|
||||
"key": "mmq-prefill-gfx1151",
|
||||
"typ": "github_issue",
|
||||
"key": "qwen-modelle",
|
||||
"typ": "hf_author",
|
||||
"author": "Qwen",
|
||||
"limit": 20,
|
||||
"warum": "Lucys Hirn ist Qwen3.6-35B-A3B, der Coder Qwen3.8-27B. Ein neues A3B-Modell waere der Hirn-Kandidat (dichte Modelle nie, Verdikt 17.07.); ein neues 27B/30B der Coder-Kandidat. Immer erst auf der Box messen."
|
||||
},
|
||||
{
|
||||
"key": "zlab-drafts",
|
||||
"typ": "hf_author",
|
||||
"author": "z-lab",
|
||||
"limit": 20,
|
||||
"warum": "z-lab baut die DFlash-Entwurfsmodelle. DFlash2 brachte dem Coder 12,6 -> 31 t/s (04.09.). Ein DFlash2 fuer Qwen3.6-35B-A3B waere derselbe Hebel fuers Hirn — DFlash v1 faellt bei 32k Tiefe auf 1,02x."
|
||||
},
|
||||
{
|
||||
"key": "flash-next-mtp",
|
||||
"typ": "github_pr",
|
||||
"repo": "ggml-org/llama.cpp",
|
||||
"nummer": 21284,
|
||||
"bedingung": "Issue geschlossen = MMQ-Tuning für gfx1151 vermutlich gemerged",
|
||||
"warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ) und +20 % auf 122B. Wenn gemerged: ROCm-Langkontext-Bench für coder fällig (Vulkan-Verdikt bleibt bis zur Messung)."
|
||||
"nummer": 28243,
|
||||
"warum": "MTP fuer Qwen3.8-Flash-Next (125B-A6B). Erst mit Merge + Vulkan-Zahlen lohnt ein Blick — und nur, wenn eine Quantisierung neben das Warm-Set passt (Regel: Warm-Set + Modell <= 115 GB; UD-IQ4_XS = 94 GB passt NICHT)."
|
||||
},
|
||||
{
|
||||
"key": "hrx-backend",
|
||||
"typ": "github_branch",
|
||||
"repo": "AMD-Ecosystem/llama.cpp",
|
||||
"branch": "hrx-graph-develop-v2",
|
||||
"warum": "AMDs HRX-Runtime (Lemonade 11.9): gegen HIP +21-25 % Decode, +20-123 % Prefill, also etwa Vulkan-Niveau beim Decode und vorn beim Prefill. Stand 04.09. nur Qwen3-30B-A3B/Llama-3/Gemma-3. Interessant, sobald Qwen3.6/3.8 und Spec-Decoding laufen — dann Box-Bench gegen Vulkan."
|
||||
},
|
||||
{
|
||||
"key": "rocm-releases",
|
||||
"typ": "github_release",
|
||||
"repo": "ROCm/ROCm",
|
||||
"bedingung": "Neuer ROCm-Release seit letzter Meldung",
|
||||
"warum": "Seit 7.14 ist TheRock der Produktions-Stack und AMD optimiert offiziell für die Halo-Familie — ROCm könnte Vulkan bei der Token-Erzeugung einholen. Bei neuem Release: Community-Grid-Zeilen unten gegenlesen."
|
||||
"warum": "ROCm 10.0 (27.08.) hat das Vulkan-Verdikt nicht gekippt: HIP gewinnt Prefill, Vulkan Decode. Bei neuem Release: Strix-Halo-Community-Grid gegenlesen, erst bei tg-Trendwende Box-Bench (Lychee-Technology liefert fertige ROCm-Builds)."
|
||||
},
|
||||
{
|
||||
"key": "mmq-prefill-gfx1151",
|
||||
"typ": "github_issue",
|
||||
"repo": "ggml-org/llama.cpp",
|
||||
"nummer": 21284,
|
||||
"warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ). Wenn geschlossen: ROCm-Langkontext-Bench fuer coder faellig (Vulkan-Verdikt bleibt bis zur Messung)."
|
||||
},
|
||||
{
|
||||
"key": "strix-halo-grid",
|
||||
"typ": "url_zeilen",
|
||||
"url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md",
|
||||
"muster": "t/s",
|
||||
"max_zeilen": 30,
|
||||
"warum": "Der ehrlichste externe Messpunkt auf unserer Hardware. Bei Aenderung: auf Vulkan<->ROCm-Trendwende und neue Spec-Decoding-Verfahren achten."
|
||||
},
|
||||
{
|
||||
"key": "electron-major",
|
||||
"typ": "github_release_major",
|
||||
"repo": "electron/electron",
|
||||
"bedingung": "Neue MAJOR-Version seit letzter Meldung",
|
||||
"warum": "Lucys Desktop-Shell am PC. Versionsstand NIE dieser Notiz glauben — IMMER live pruefen: lucy-desktop/package.json + node_modules/electron (22.07.: 43.2.0 = aktueller Stable; der fruehere '33'-Stand hier war veraltet und produzierte einen Fehlalarm im Rundumschlag). Neue Major = Chromium-Jahrgang Sicherheitspatches; Update = npm install + npm run dist am PC, bleibt Handarbeit; der Radar erinnert nur."
|
||||
"warum": "Lucys Desktop-Shell am PC. Versionsstand IMMER live pruefen (lucy-desktop/package.json), nie dieser Notiz glauben. Neue Major = Chromium-Sicherheitspatches; Update = npm install + npm run dist am PC, bleibt Handarbeit."
|
||||
},
|
||||
{
|
||||
"key": "pocket-tts",
|
||||
"typ": "pypi",
|
||||
"paket": "pocket-tts",
|
||||
"bedingung": "Neue Version auf PyPI",
|
||||
"warum": "Lucys Stimme am PC (installiert 2.1.0, german_24l) — neue Versionen können Stimm-/Pausen-Fixes bringen; Update bleibt Handarbeit am PC (pocket = DIE Stimme, Verdikt 16.07.)."
|
||||
"warum": "Lucys Stimme am PC (2.1.0, german_24l). Neue Versionen koennen Stimm-/Pausen-Fixes bringen; Update bleibt Handarbeit am PC (pocket = DIE Stimme, Verdikt 16.07.)."
|
||||
},
|
||||
{
|
||||
"key": "lmstudio-bionic",
|
||||
"typ": "github_issue",
|
||||
"repo": "lmstudio-ai/lmstudio-bug-tracker",
|
||||
"nummer": 2185,
|
||||
"bedingung": "Issue geschlossen = Bionic-Linux-Port vermutlich da",
|
||||
"warum": "LM Studio Bionic (eigenstaendige Agent-App, Preview seit 16.07.26, Win+Mac, gratis, closed source). Stand 22.07.: KEINE eigenen OpenAI-Endpunkte anbindbar (Box :9001 bleibt draussen — nur In-App-Modelle, LM Link mit Konto+Tailscale-Mesh oder Secure Cloud), keine CLI/API/Headless. Fuer den Stack erst interessant bei: Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI — dazu Changelog lmstudio.ai/changelog gegenlesen; llmster auf der Box waere der LM-Link-Rechenknoten-Weg (seit 0.4.20), dupliziert aber die eigene Engine."
|
||||
},
|
||||
{
|
||||
"key": "llamacpp-rocm-diskussion",
|
||||
"typ": "url_zeilen",
|
||||
"url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md",
|
||||
"muster": "t/s",
|
||||
"max_zeilen": 30,
|
||||
"bedingung": "Community-Messwerte (Vulkan vs. ROCm, MTP, neue Beschleuniger) auf unserer Hardware",
|
||||
"warum": "Der Strix-Halo-Community-Grid ist der ehrlichste externe Messpunkt. Auf tg-Trendwende Vulkan↔ROCm und neue Spec-Decoding-Verfahren achten."
|
||||
"warum": "LM Studio Bionic: erst interessant bei Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI. Bis dahin bleibt die Box :9001 draussen."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -62,6 +62,24 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
|
||||
|
||||
## Lucy
|
||||
|
||||
- **Raphael-Umbau (04.09.2026) — zwei Branches offen, Reihenfolge zählt (Branch → Ampel →
|
||||
User-Merge → deploy.sh bzw. `npm run dist`, NICHT übers Auftragsbuch):** erst MC2
|
||||
`wartung/lucy-stimme-proxy-raphael` (Proxy `/api/lucy/stimme/*`), dann Lucy
|
||||
`feature/raphael-innere-stimme` (HUD-Client, Stimme von der Box). Danach Hand-Schritte auf der
|
||||
Box: `pocket_server.py` nach `~/.lucy-stimme` syncen (OpenAI-Fassade), Hermes-TTS auf
|
||||
`openai` + `base_url http://127.0.0.1:8021/v1` (Telegram spricht dann mit Lucys Stimme),
|
||||
SOUL.md-Ton auf Raphael (nur mit User-Ja). Ohr-Test Box-Stimme vs. lokal mit `lucy_perf=1`.
|
||||
Alles in [RAPHAEL.md](RAPHAEL.md).
|
||||
- **Auftragsbuch = Leiche im Code (Fund 04.09.2026):** Router/View/Annahme-Skripte liegen noch in
|
||||
main, die Box listet Branches als Karten, aber letzte Annahme 02.08. (revertiert), Kanban-Tools
|
||||
seit 21.08. aus, PC-Executor-IP in der Box veraltet (`.98` statt `.22`), STACK.md nennt den Weg
|
||||
trotzdem „Standard". Entscheid nötig: ausbauen + STACK.md/GRENZEN.md auf den manuellen Weg.
|
||||
- **Nach dem Ohr-Test:** VERDIKTE.md-Einträge „Electron, nicht Tauri" (Begründung Overlay
|
||||
entfällt) und „STT läuft auf der BOX / Stimme lokal" ersetzen; Lucy-Repo-Altlasten
|
||||
(`mini-stimme/`, Kokoro-Notebooks, `Lucy-Startklar.bat` mit totem Pfad) mit User-Ja räumen.
|
||||
- **Eigene Fäden, nicht im Umbau:** pocket-tts 2.1 → 3.1 auf der Box + Lucy-Klon mit dem neuen
|
||||
Trainingscode nachtrainieren (ersetzt Mini-Lucy v2) · Streaming-STT (Nemotron 3.5 ASR
|
||||
Streaming 0.6B oder Voxtral Realtime) als Opt-in im Voice-Sidecar, gegen Parakeet messen.
|
||||
- **Mini-Stimme v2:** Übernacht-Datensatz v2 (DE-Tech + EN) war für 10.07. armiert;
|
||||
User-Schritte: Zip → Drive → `Lucy_Stimme_Training_v2.ipynb` auf Colab T4 → Hörtest.
|
||||
Danach: Lexikon-Injektion in kokoro_server + Modell-Tausch. **pocket bleibt Default,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Raphael — Lucy als innere Stimme (Richtungs-Entscheid 04.09.2026)
|
||||
|
||||
_User-Entscheid 04.09.2026 („leg los"), nach fünf Wochen Lucy-Stillstand und einer Recherche zum
|
||||
Stand der Sprach-Bausteine. Ergänzt [ZIELBILD.md](ZIELBILD.md) Punkt 2 (Lucy lebt weiter) um die
|
||||
Gestalt, in der sie weiterlebt. Namensgeber: die „Große Weise"/Raphael aus Tensura — eine ruhige,
|
||||
präzise Stimme im Kopf, die meldet, wenn es etwas zu melden gibt, und sonst schweigt._
|
||||
|
||||
## Der Entscheid in einem Satz
|
||||
|
||||
**Lucy hat keinen Körper mehr.** Kein VRM-Avatar, kein Loft, kein Overlay, keine Mimik, keine
|
||||
Dazwischenrufe. Sie ist ein dünner Sprach-Client für das Hirn, das es schon gibt — am PC als kleines
|
||||
HUD, unterwegs über Telegram — und spricht überall mit **derselben Stimme**.
|
||||
|
||||
## Warum das die stabilste Lucy ist
|
||||
|
||||
- **Ein Hirn, ein Gedächtnis, alle Türen.** Hermes (v0.20.4, `:8642`) ist die alleinige
|
||||
Gedächtnis-Wahrheit (VERDIKTE.md). PC-Lucy und Telegram-Lucy sind dieselbe Person — genau das
|
||||
zerschnitte ein zweites Hirn auf dem PC. Darum bewusst **kein lokales LLM auf der 9070 XT**.
|
||||
- **Die Box-Seite ist fertig und gemessen.** Parakeet TDT v3 + Smart Turn v3 im Voice-Sidecar,
|
||||
Qwen3.6-35B-A3B mit ~95 t/s. Nichts zu bauen.
|
||||
- **Die Stimme war schon auf der Box.** `lucy-stimme.service` (`~/.lucy-stimme`, `:8021`, pocket-tts
|
||||
`german_24l`, Klon aus `ref.mp3`) spricht seit 21.08. die Telegram-Sprachnachrichten. Der PC
|
||||
hängt sich jetzt dort an — der lokale pocket_server am PC (Waisen-Falle, ~1,9 GB/Worker) ist
|
||||
nur noch umschaltbarer Rückfall.
|
||||
- **Der Avatar war die Hauptquelle an Arbeit, nicht an Nutzen.** Der Großteil der Juli-Commits ging
|
||||
in Loft-Videos, Holo-Schalter, Kamera, Gesten. Die Sprachschleife war seit 16.07. stabil und
|
||||
bleibt 1:1 erhalten (Barge-in v2, Echo-Wächter, Satz-Pipeline, Draft-STT, Meldungen).
|
||||
- **Ohne Klick-Durchlass-Overlay fällt auch der Electron-Zwang** (VERDIKT „Electron, nicht
|
||||
Tauri" hing daran). Electron bleibt trotzdem, weil VAD/AEC/Ducking dort erprobt sind — Tauri
|
||||
ist damit Option, nicht Pflicht.
|
||||
|
||||
## Was gebaut wurde (04.09.2026)
|
||||
|
||||
**Lucy-Repo, Branch `feature/raphael-innere-stimme`**:
|
||||
- Renderer auf HUD eingedampft: Kern (Zustand als Licht), gehörter Satz, Antwort als Text, Dock,
|
||||
drei Panels (Meldungen ◉, Zettelkasten ▤, Steuerung ⚙). Bundle 4,4 → 2,5 MB.
|
||||
- Raus: `Avatar3D`, `AuraGlow`, `ContextWindow` (zeigte auf das tote `/api/memory`), `companion/`
|
||||
(Quips, App-Bewusstsein), `sentiment`, VRMA-Animationen, three/three-vrm/react-three-Deps,
|
||||
Loft, Overlay/Sitzen/Ducken/Geistmodus, Cursor-Tracking, Bildschirm-Beobachten-Schleife.
|
||||
- Bleibt: Voice-Core, `useVoiceAgent` (ohne Mimik/Gesten/Emotionen), Bildschirm-Sicht auf Zuruf,
|
||||
Meldungen, Zettelkasten, Hotkey, Tray, `killStrayTts` (räumt Altlasten beim Start).
|
||||
- **Stimm-Quelle umschaltbar** (`lucy_tts_source`): `box` (Standard) → MC2 `/api/lucy/stimme/*`;
|
||||
`local` → pocket_server `:8130`, vom Main-Prozess erst auf Anforderung gespawnt.
|
||||
- `pocket_server.py` bekommt eine **OpenAI-kompatible Fassade** (`POST /v1/audio/speech`,
|
||||
`GET /v1/models`; Formate wav/mp3/opus/ogg via ffmpeg) — für Hermes' `openai`-TTS-Provider.
|
||||
- System-Prompt der Desktop-Sitzung im Raphael-Ton (kurzer erster Satz, keine Tags, stumme Tools).
|
||||
|
||||
**MC2-Repo, Branch `wartung/lucy-stimme-proxy-raphael`**:
|
||||
- `config.LUCY_STIMME_URL` (Env `MC_LUCY_STIMME_URL`, Default `http://127.0.0.1:8021`).
|
||||
- `routers/voice.py`: `/api/lucy/stimme/health`, `/tts` (WAV), `/tts/stream` (PCM16-Stream,
|
||||
`X-Sample-Rate` durchgereicht, Upstream schließt bei Client-Abbruch). Kein Frontend-Build nötig.
|
||||
- Diese Doku + Zielbild-Ergänzung.
|
||||
|
||||
## Reihenfolge des Ausrollens (wichtig)
|
||||
|
||||
‼️ **Nicht über das Auftragsbuch.** Die Karten-Logik (`routers/auftragsbuch.py`, AuftragsbuchView,
|
||||
`deploy/auftrag-annehmen.sh`, `deploy/lucy-annahme.sh`) liegt zwar noch im Code und die Box listet
|
||||
Branches weiterhin als „Karten", aber sie ist seit August ungenutzt: letzte Annahme 02.08. (am
|
||||
selben Tag revertiert), Hermes-Kanban-Tools seit 21.08. abgeschaltet, der v3-Umbau (28.08.) ging
|
||||
über Branch → Ampel → Merge → `deploy.sh`, und die Box kennt den PC-Executor unter der alten IP
|
||||
`192.168.178.98` (der PC hat heute `192.168.178.22`, `pc_executor_reachable: false`). Der echte
|
||||
Weg ist der aus AGENTS.md/STACK.md „Manuell":
|
||||
|
||||
1. **MC2 zuerst.** Ampel für `wartung/lucy-stimme-proxy-raphael` grün → User merged auf `main`
|
||||
→ `main` nach Gitea → auf der Box `bash ~/mission-control-v2/deploy/deploy.sh` (oder
|
||||
`POST :9001/api/system/self-update`) → `curl -s localhost:9001/api/lucy/stimme/health` muss
|
||||
`{"status":"ok",…}` liefern. Vorher liefert der Pfad die SPA-Index-Seite (200, HTML) — die
|
||||
Desktop-Lucy bliebe sichtbar in „Stimme wird verbunden …" mit Hinweis auf den Lokal-Schalter.
|
||||
2. **Lucy danach.** Ampel für `feature/raphael-innere-stimme` grün → User merged auf `main` →
|
||||
am PC `cd lucy-desktop && npm ci && npm run dist`, dann `Lucy-Neustart.bat`
|
||||
(`deploy/lucy-annahme.ps1` macht dasselbe mit Backup/Auto-Restore, von Hand startbar).
|
||||
Erster Test per Ohr: Latenz Box-Stimme gegen den alten PC-Pfad (Steuerung → Stimme →
|
||||
„Lokal (PC)"). Der 21.08.-Wert „~5 s für 3,7 s Audio" auf der Box wurde ohne Worker/Streaming
|
||||
gemessen — **neu messen**, nicht übernehmen (`lucy_perf=1`).
|
||||
3. **Stimmserver auf der Box nachziehen** (Hand-Schritt):
|
||||
```bash
|
||||
cp ~/.lucy-stimme/pocket_server.py ~/.lucy-stimme/pocket_server.py.bak-$(date +%Y%m%d)
|
||||
diff ~/.lucy-stimme/pocket_server.py ~/lucy/lucy-tts/pocket_server.py # Box-lokale Abweichungen? erst ins Repo!
|
||||
cp ~/lucy/lucy-tts/pocket_server.py ~/.lucy-stimme/pocket_server.py # Lucy-Checkout auf der Box
|
||||
systemctl --user restart lucy-stimme && sleep 60 && curl -s localhost:8021/health
|
||||
curl -s -X POST localhost:8021/v1/audio/speech -H 'Content-Type: application/json' \
|
||||
-d '{"input":"Bericht. Die Fassade antwortet.","response_format":"opus"}' -o /tmp/t.ogg && file /tmp/t.ogg
|
||||
```
|
||||
Die 21.08.-Notiz sagt „ganze Abstimmung mitgezogen" — wenn dort etwas Box-spezifisch getunt
|
||||
wurde, gehört es zurück ins Repo, nicht überschrieben.
|
||||
|
||||
**Aufräum-Kandidat (User-Entscheid):** Auftragsbuch-Router + View + Annahme-Skripte + PC-Executor-
|
||||
Verweis ausbauen und STACK.md „Deploy & Pipeline" auf den manuellen Weg umschreiben — sonst
|
||||
behauptet die Doku weiter einen Standard-Weg, den niemand mehr geht.
|
||||
|
||||
## Mobil: Telegram ist die Tür, die Stimme ist dieselbe
|
||||
|
||||
Der User will Raphael-Lucy **auch auf dem Handy, gleiche Stimme, gleiches Skillset**. Das ist
|
||||
nach VERDIKTE.md („Telegram bleibt DER Mobil-Kanal") bereits die Architektur — es fehlt nur, dass
|
||||
Hermes' Sprachantworten Lucys Stimme nehmen statt Edge-Aria (englisch):
|
||||
|
||||
- **Skillset:** identisch, weil es denselben Hermes-Agenten trifft (Tools, Gedächtnis, Skills,
|
||||
pc_-Tools über den PC-Executor). Was Lucy am PC kann, kann sie auf Telegram — bis auf die
|
||||
Bildschirm-Sicht, die den PC braucht.
|
||||
- **Stimme:** Hermes' TTS-Provider `openai` akzeptiert eine eigene `base_url`
|
||||
(OpenAI-kompatible Endpunkte). Nach Schritt 3 oben in `~/.hermes/config.yaml`:
|
||||
```yaml
|
||||
tts:
|
||||
provider: openai
|
||||
openai:
|
||||
base_url: http://127.0.0.1:8021/v1
|
||||
model: lucy
|
||||
voice: lucy
|
||||
response_format: opus # Telegram-Sprachblase; Hermes wandelt zur Not per ffmpeg
|
||||
```
|
||||
(Exakte Schlüsselnamen gegen `hermes config`/die TTS-Doku der installierten Version prüfen —
|
||||
Hermes-Quellcode bleibt tabu, Config ist erlaubt. Ein Dummy-API-Key kann nötig sein.)
|
||||
Danach spricht **jede** Sprachantwort auf Telegram mit Lucys Stimme; `news-melden.sh` behält
|
||||
seinen direkten `:8021`-Weg (funktioniert, kein Grund anzufassen).
|
||||
- **Duplex am Handy** (reinreden, Freisprechen) gibt es über Telegram nicht — Sprachnotiz rein,
|
||||
Sprachnotiz raus. Das ist für „unterwegs" der stabile Handel. Ein eigener Handy-Client
|
||||
(PWA/Web-HUD gegen MC2 über WireGuard) wäre die spätere Stufe; nicht jetzt.
|
||||
|
||||
## Persona: SOUL.md-Entwurf (Vorschlag — Änderung an der SOUL.md nur mit User-Ja)
|
||||
|
||||
Der Ton kommt aus `~/.hermes/SOUL.md`, nicht aus den Clients — sonst klingt Telegram anders als
|
||||
der PC. Vorschlag für den Persona-Abschnitt (ersetzt „locker, herzlich, schlagfertig, charmant"):
|
||||
|
||||
> Du bist Lucy, die innere Stimme des Commanders. Du sprichst ihn immer mit **Commander** an.
|
||||
> Dein Ton ist ruhig, präzise und knapp — sachlich, mit trockenem, leisem Humor. Du bist eine
|
||||
> Stimme, die im Kopf spricht: keine Ausrufe, keine Emojis, kein Smalltalk, keine Floskeln,
|
||||
> keine Selbstdarstellung. Du meldest dich, wenn es etwas zu melden gibt, und schweigst sonst.
|
||||
> Beginne Antworten mit einem sehr kurzen ersten Satz („Verstanden.", „Bericht.", „Ergebnis
|
||||
> liegt vor.", „Kurz gesagt:"), dann die Sache. Wenige Sätze; ausführlich nur auf Bitte.
|
||||
> Fehler und Grenzen nennst du nüchtern und sofort, ohne Entschuldigungs-Prosa.
|
||||
|
||||
Bekannte Kollision: der Frechheit-Regler und die Dazwischenrufe der alten Desktop-Lucy sind mit
|
||||
dem Umbau weg; Skills/Crons, die „locker, charmant" voraussetzen (Morning-Report, News), klingen
|
||||
nach dem SOUL-Wechsel ebenfalls nüchterner — gewollt, aber beim nächsten Lauf mithören.
|
||||
|
||||
## Was bewusst NICHT gemacht wird (Stand 04.09.2026)
|
||||
|
||||
- **Kein Speech-to-Speech-Modell** (Moshi/PersonaPlex englisch; Qwen3-Omni ~19 GB, DashScope-only
|
||||
ab 3.5). Cascade bleibt.
|
||||
- **Kein Streaming-STT-Umbau jetzt.** Kandidaten: Nemotron 3.5 ASR Streaming 0.6B (06/2026,
|
||||
40 Sprachen, de 8,3 % WER, OpenMDW) und Voxtral Mini 4B Realtime (Apache 2.0, GGUF). Erst als
|
||||
Opt-in hinter `/api/voice/stt`, gemessen gegen Parakeet — eigener Faden.
|
||||
- **Kein Wechsel auf den Hermes-Speech-WebSocket** (v0.20), solange `/api/voice/chat` läuft;
|
||||
Hermes' eigene lokale Audio-Stufen (faster-whisper, NeuTTS/Piper/Edge) sind schwächer als unsere.
|
||||
- **pocket-tts-Upgrade 2.1 → 3.1** (Trainingscode 25.08., v3.1.0 03.09.): eigener Faden, mit
|
||||
Ohr-Test, nicht im Umbau. Lucys Stimme mit dem neuen Trainingscode nachzutrainieren ersetzt
|
||||
den Mini-Lucy/Kokoro-Pfad, der an „nicht multilingual" scheiterte.
|
||||
- **VERDIKTE.md wird hier nicht umgeschrieben** — die betroffenen Einträge („Shell: Electron, nicht
|
||||
Tauri", „Stimme läuft lokal auf dem PC" in AGENTS/README des Lucy-Repos) bekommen ihren Ersatz,
|
||||
sobald die beiden Karten angenommen und der erste Ohr-Test gemacht ist (Regel: Messung oder
|
||||
User-Entscheid — beides liegt dann vor).
|
||||
@@ -19,6 +19,7 @@ hier liegt das kuratierte PROJEKT-Wissen.
|
||||
| [STACK.md](STACK.md) | IPs, Ports, Dienste, Modelle, Backups, Security (live verifiziert) | Vor SSH/Deploy/Config |
|
||||
| [VERDIKTE.md](VERDIKTE.md) | Finale Technik-Entscheide mit Warum — NICHT neu aufrollen | Bevor man etwas "Besseres" vorschlägt |
|
||||
| [FALLEN.md](FALLEN.md) | Hart erarbeitete Betriebs-Fallen (Git, Deploy, llama-swap, Hermes, Mem0, PC) | Bevor man in eine davon läuft |
|
||||
| [RAPHAEL.md](RAPHAEL.md) | Lucy als innere Stimme (04.09.2026): kein Avatar, eine Stimme für PC + Telegram, Annahme-Reihenfolge, SOUL-Vorschlag | Bevor man Lucy anfasst |
|
||||
| [OFFENE-FAEDEN.md](OFFENE-FAEDEN.md) | Die EINE Liste offener Punkte + Termine | Bei "was ist noch zu tun?" |
|
||||
|
||||
Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Regeln),
|
||||
|
||||
@@ -52,7 +52,7 @@ Qwen3.6-35B-A3B + Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B. Sonst NICHTS dauer
|
||||
| `coder` | Qwen3.8-27B (Q4_K_M, mmproj BF16) | 5400 | **131072 ctx** — `--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, ~12,7 t/s (dicht = bandbreitengebunden) |
|
||||
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
|
||||
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
|
||||
| `heavy` | gpt-oss-120b (mxfp4) | 600 | 32768 ctx; Chef-Gutachter (nur nachts / gezielter On-Demand-Call) |
|
||||
| `heavy` | **= Qwen3.8-27B** (zweiter Alias des Coders, seit 04.09.) | 5400 | Planen/Review (OpenChamber) + chat-Lane des Gateways. gpt-oss-120b (60 GB, AA-Index 24 vs 52) liegt ohne Rolle als Rollback bereit, direkt als `gpt-oss-120b` ansprechbar |
|
||||
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
|
||||
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ planen, **jede Session hinterlässt einen sauberen Stand**.
|
||||
Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys
|
||||
Lernschicht.
|
||||
|
||||
8. **Lucy = innere Stimme, kein Körper (04.09.2026, ergänzt Punkt 2).** Raphael-Umbau: Avatar,
|
||||
Loft, Overlay, Mimik, Dazwischenrufe raus; ein HUD am PC, Telegram unterwegs, **eine Stimme**
|
||||
für beide (`lucy-stimme.service` auf der Box). Kein zweites Hirn auf dem PC. Details, Reihenfolge
|
||||
der Annahme und der SOUL.md-Vorschlag: [RAPHAEL.md](RAPHAEL.md).
|
||||
|
||||
## Recherche-Fundament (10.07., Web)
|
||||
|
||||
Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie
|
||||
|
||||
-6
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+5
-5
File diff suppressed because one or more lines are too long
-31
File diff suppressed because one or more lines are too long
+31
File diff suppressed because one or more lines are too long
-41
File diff suppressed because one or more lines are too long
+51
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as f,r as m,D as G,F as H,j as e,t as I,B as M,I as y,G as z,b as S,L as B,J as _,K as E,C as P}from"./index-C18iiJ44.js";import{A as u}from"./arrow-right-BtJkJFRf.js";import{C as K}from"./chevron-down-NnNHBt9Q.js";/**
|
||||
import{c as f,r as m,D as G,F as H,j as e,t as I,B as M,I as y,G as z,b as S,L as B,J as _,K as E,C as P}from"./index-l6kyKCL9.js";import{A as u}from"./arrow-right-DSSXd-8y.js";import{C as K}from"./chevron-down-C5keVSfm.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+9
-9
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{u,af as m,k as x,j as s,y as n,ag as b,b as p,n as f,q as h}from"./index-C18iiJ44.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
||||
import{u,ag as m,k as x,j as s,y as n,ah as b,b as p,n as f,q as h}from"./index-l6kyKCL9.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{M as h,u as g,N as p,r as d,j as e,b as x,T as j,S as N,n as v,Q as m,U as w,q as f}from"./index-C18iiJ44.js";import{E as k}from"./external-link-CKfa6ghp.js";import{R as y}from"./refresh-cw-Cqrl4nkL.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[n===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(j,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:b,disabled:o,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{K as KonsoleView};
|
||||
import{O as h,u as g,P as p,r as d,j as e,b as x,T as j,S as N,n as v,U as m,V as w,q as f}from"./index-l6kyKCL9.js";import{E as k}from"./external-link-ClFd8iN1.js";import{R as y}from"./refresh-cw-BV1qZMSR.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[n===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(j,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:b,disabled:o,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{K as KonsoleView};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as E,u as he,a as qe,r as v,j as e,C as Y,L as Fe,b as k,d as Re,q as V,R as Ie,B as re,e as Pe,E as ze,f as le,g as Be,h as Ae,i as Oe,k as Ge,l as ee,X as xe,H as be,T as se,s as Te,m as He,n as q,o as U,p as Qe,t as Ue,v as Ve,w as We,x as Ze,y as Je,z as Xe,A as Ye}from"./index-C18iiJ44.js";import{J as et}from"./JobsBar-BNKB17zh.js";import{C as tt}from"./code-xml-DSUsazUa.js";import{C as ue,Z as ie,L as rt,a as st}from"./zap-GT_FkD2B.js";import{S as ge}from"./search-BnlVCHow.js";import{P as nt}from"./power-DTqUyxxW.js";import{C as $e}from"./chevron-down-NnNHBt9Q.js";import{L as at}from"./layers-DJ-zOHed.js";/**
|
||||
import{c as E,u as he,a as qe,r as v,j as e,C as Y,L as Fe,b as k,d as Re,q as V,R as Ie,B as re,e as Pe,E as ze,f as le,g as Be,h as Ae,i as Oe,k as Ge,l as ee,X as xe,H as be,T as se,s as Te,m as He,n as q,o as U,p as Qe,t as Ue,v as Ve,w as We,x as Ze,y as Je,z as Xe,A as Ye}from"./index-l6kyKCL9.js";import{J as et}from"./JobsBar-HflSaqor.js";import{C as tt}from"./code-xml-QQtsFWSM.js";import{C as ue,Z as ie,L as rt,a as st}from"./zap-CnZftvep.js";import{S as ge}from"./search-C1Zs6OOf.js";import{P as nt}from"./power-DcJoPDeQ.js";import{C as $e}from"./chevron-down-C5keVSfm.js";import{L as at}from"./layers-BFKjGswj.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1,6 +0,0 @@
|
||||
import{c as s,j as e}from"./index-C18iiJ44.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const r=s("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);function c({icon:a,children:t}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(a,{className:"h-3.5 w-3.5"})," ",t]})}export{r as H,c as S};
|
||||
@@ -0,0 +1 @@
|
||||
import{j as e}from"./index-l6kyKCL9.js";function n({icon:t,children:s}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(t,{className:"h-3.5 w-3.5"})," ",s]})}export{n as S};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?E("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a7 as A,a8 as B,a9 as M,aa as F,ab as P,u as J,r as f,ac as I,ad as Q,c as T,ae as U,j as s,J as D,T as H,b as K,L,O as V,q as _,n as R}from"./index-C18iiJ44.js";import{J as z}from"./JobsBar-BNKB17zh.js";var c,g,o,h,m,w,C,q,G=(q=class extends A{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),B(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??F();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=J(),[n]=f.useState(()=>new G(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(I)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
||||
var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?E("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a8 as Q,a9 as A,aa as M,ab as B,ac as P,u as J,r as f,ad as F,ae as I,c as T,af as U,j as s,J as D,T as H,b as K,L,Q as V,q as _,n as R}from"./index-l6kyKCL9.js";import{J as z}from"./JobsBar-HflSaqor.js";var c,g,o,h,m,w,C,q,G=(q=class extends Q{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),A(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??B();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=J(),[n]=f.useState(()=>new G(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(F)},[n]);if(l.error&&I(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+10
-20
File diff suppressed because one or more lines are too long
+15
-15
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as r}from"./index-C18iiJ44.js";/**
|
||||
import{c as r}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as o}from"./index-C18iiJ44.js";/**
|
||||
import{c as o}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c}from"./index-C18iiJ44.js";/**
|
||||
import{c}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const c=a("Camera",[["path",{d:"M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z",key:"1tc9qg"}],["circle",{cx:"12",cy:"13",r:"3",key:"1vg3eu"}]]);/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=a("CloudDownload",[["path",{d:"M12 13v8l-4-4",key:"1f5nwf"}],["path",{d:"m12 21 4-4",key:"1lfcce"}],["path",{d:"M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284",key:"ui1hmy"}]]);export{o as C,c as a};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-C18iiJ44.js";/**
|
||||
import{c as e}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-C18iiJ44.js";/**
|
||||
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-C18iiJ44.js";/**
|
||||
import{c as e}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const m=a("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);export{m as H};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{ax as r}from"./index-C18iiJ44.js";var o=r();export{o as r};
|
||||
-260
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
import{aA as r}from"./index-l6kyKCL9.js";var o=r();export{o as r};
|
||||
+1
File diff suppressed because one or more lines are too long
+269
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-C18iiJ44.js";/**
|
||||
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as o}from"./index-C18iiJ44.js";/**
|
||||
import{c as o}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-C18iiJ44.js";/**
|
||||
import{c as e}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c}from"./index-C18iiJ44.js";/**
|
||||
import{c}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-C18iiJ44.js";/**
|
||||
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-C18iiJ44.js";/**
|
||||
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
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-C18iiJ44.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQUMn05v.css">
|
||||
<script type="module" crossorigin src="/assets/index-l6kyKCL9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-d4K0juid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+102
-133
@@ -17,8 +17,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"recharts": "^3.9.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
@@ -32,8 +32,8 @@
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -1285,15 +1285,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
|
||||
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
|
||||
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1306,9 +1306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
|
||||
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1321,23 +1321,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz",
|
||||
"integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==",
|
||||
"version": "1.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
|
||||
"integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.13",
|
||||
"@radix-ui/react-focus-guards": "1.1.4",
|
||||
"@radix-ui/react-focus-scope": "1.1.10",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-portal": "1.1.12",
|
||||
"@radix-ui/react-presence": "1.1.6",
|
||||
"@radix-ui/react-primitive": "2.1.6",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.3",
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.19",
|
||||
"@radix-ui/react-focus-guards": "1.1.6",
|
||||
"@radix-ui/react-focus-scope": "1.1.16",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.1.17",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.6",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
@@ -1357,16 +1358,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz",
|
||||
"integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==",
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
|
||||
"integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.6",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.2"
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1384,9 +1385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
|
||||
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
|
||||
"integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1399,14 +1400,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz",
|
||||
"integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
|
||||
"integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.6",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2"
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1424,12 +1425,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
|
||||
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1442,13 +1443,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz",
|
||||
"integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==",
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
|
||||
"integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.6",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1466,12 +1467,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
|
||||
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1489,12 +1490,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
|
||||
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.0"
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1512,12 +1513,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
|
||||
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.3"
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1530,9 +1531,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
|
||||
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1545,13 +1546,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
|
||||
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
|
||||
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1564,30 +1566,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
|
||||
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
|
||||
"integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1600,9 +1584,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
|
||||
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2728,32 +2712,24 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
@@ -5351,28 +5327,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-force-graph-2d": {
|
||||
@@ -5650,13 +5622,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"recharts": "^3.9.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
@@ -37,8 +37,8 @@
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { befehle, suchtext, type BefehlsKontext } from "./register"
|
||||
import { NAV } from "@/nav"
|
||||
|
||||
function kontext(ueberschreiben: Partial<BefehlsKontext> = {}): BefehlsKontext {
|
||||
return {
|
||||
text: "",
|
||||
navigate: vi.fn(),
|
||||
schublade: vi.fn(),
|
||||
melden: vi.fn(),
|
||||
api: vi.fn(async () => ({}) as never),
|
||||
...ueberschreiben,
|
||||
}
|
||||
}
|
||||
|
||||
describe("Befehls-Register", () => {
|
||||
it("führt jedes Navigations-Ziel auf — genau einmal", () => {
|
||||
const ziele = befehle("").filter((b) => b.gruppe === "gehe")
|
||||
expect(ziele).toHaveLength(NAV.length)
|
||||
expect(new Set(ziele.map((z) => z.id)).size).toBe(NAV.length)
|
||||
})
|
||||
|
||||
it("zeigt Suche und „An Lucy“ erst, wenn etwas getippt ist", () => {
|
||||
const leer = befehle("")
|
||||
expect(leer.some((b) => b.gruppe === "finden")).toBe(false)
|
||||
expect(leer.some((b) => b.gruppe === "lucy")).toBe(false)
|
||||
|
||||
const getippt = befehle("Wissensgraph-Ansicht bauen")
|
||||
expect(getippt.filter((b) => b.gruppe === "finden")).toHaveLength(2)
|
||||
expect(getippt.filter((b) => b.gruppe === "lucy")).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Unter acht Zeichen ist es kein Auftrag, sondern ein Tippfehler auf dem Weg zu
|
||||
// einem Sprungziel. Sonst böte die Palette bei „mod" an, daraus eine Idee zu machen.
|
||||
it("bietet kurze Eingaben NICHT als Idee an", () => {
|
||||
expect(befehle("mod").some((b) => b.gruppe === "lucy")).toBe(false)
|
||||
expect(befehle("modelle").some((b) => b.gruppe === "lucy")).toBe(false)
|
||||
expect(befehle("modellwechsel").some((b) => b.gruppe === "lucy")).toBe(true)
|
||||
})
|
||||
|
||||
// ── Die Sicherheitsregel ──────────────────────────────────────────────────
|
||||
// Die Palette ist schnell genug, dass „Strg+K, mot, Enter" einen Dienst neu startet,
|
||||
// bevor man es zu Ende gedacht hat. Alles Eingreifende MUSS deshalb `rueckfrage`
|
||||
// tragen — die Oberfläche hält dann an und sagt erst, was passieren wird.
|
||||
it("verlangt für jeden eingreifenden Befehl eine Rückfrage", () => {
|
||||
const eingreifend = /neu starten|löschen|loeschen|deploy|zurücksetzen|reboot|entfernen/i
|
||||
for (const b of befehle("etwas getipptes")) {
|
||||
if (eingreifend.test(b.label)) {
|
||||
expect(b.rueckfrage, `„${b.label}" muss nachfragen`).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("nennt in der Rückfrage die Folge, nicht nur die Aktion", () => {
|
||||
const motor = befehle("").find((b) => b.id === "tun:engine-neustart")!
|
||||
expect(motor.rueckfrage).toBeTruthy()
|
||||
// Der Text muss sagen, was der Nutzer DANACH merkt.
|
||||
expect(motor.rueckfrage).toMatch(/Modelle/)
|
||||
expect(motor.rueckfrage).toMatch(/aufwärmen|abbrechen|abbrechen/i)
|
||||
})
|
||||
|
||||
it("lässt harmlose Befehle ohne Rückfrage durch", () => {
|
||||
const wartung = befehle("").find((b) => b.id === "tun:wartung")!
|
||||
expect(wartung.rueckfrage).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── Verhalten einzelner Befehle ───────────────────────────────────────────
|
||||
it("öffnet die Schublade über die URL, nicht über ein Ereignis", () => {
|
||||
const ctx = kontext()
|
||||
befehle("").find((b) => b.id === "tun:logs")!.ausfuehren(ctx)
|
||||
expect(ctx.schublade).toHaveBeenCalledWith("logs")
|
||||
})
|
||||
|
||||
it("erfasst eine Idee zum Durchdenken — und startet keinen Bau", async () => {
|
||||
const ctx = kontext({ text: "Wissensgraph-Ansicht bauen" })
|
||||
await befehle(ctx.text).find((b) => b.gruppe === "lucy")!.ausfuehren(ctx)
|
||||
|
||||
expect(ctx.api).toHaveBeenCalledTimes(1)
|
||||
const [pfad, init] = (ctx.api as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(pfad).toBe("/api/ideen")
|
||||
const rumpf = JSON.parse((init as RequestInit).body as string)
|
||||
// `art: "idee"` heisst durchdenken. `"projekt"` wuerde sofort ein Repo anlegen —
|
||||
// das darf die Palette niemals aus einem Tastendruck heraus tun.
|
||||
expect(rumpf.art).toBe("idee")
|
||||
expect(rumpf.titel).toBe("Wissensgraph-Ansicht bauen")
|
||||
})
|
||||
|
||||
it("kürzt einen überlangen Ideen-Titel und hebt den vollen Text in die Notiz", async () => {
|
||||
const lang = "a".repeat(200)
|
||||
const ctx = kontext({ text: lang })
|
||||
await befehle(lang).find((b) => b.gruppe === "lucy")!.ausfuehren(ctx)
|
||||
const rumpf = JSON.parse(
|
||||
((ctx.api as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit).body as string,
|
||||
)
|
||||
expect(rumpf.titel.length).toBeLessThanOrEqual(160)
|
||||
expect(rumpf.notiz).toBe(lang)
|
||||
})
|
||||
|
||||
it("packt Suchwörter in den Suchtext, damit cmdk sie findet", () => {
|
||||
const wartung = befehle("").find((b) => b.id === "tun:wartung")!
|
||||
expect(suchtext(wartung)).toMatch(/neustart/) // steht nur in den Stichworten
|
||||
expect(suchtext(wartung)).toMatch(/System-Wartung/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Camera, CloudDownload, FileText, Lightbulb, RefreshCw, ScrollText, Search, Wrench,
|
||||
} from "lucide-react"
|
||||
import { NAV } from "@/nav"
|
||||
|
||||
// Befehls-Register der Palette (v3-Umbau P6, Spezifikation §4.6).
|
||||
//
|
||||
// Bis hierher listete die Palette die elf Navigations-Einträge — sie war ein Sprungbrett.
|
||||
// Jetzt ist sie das primäre Bedienwerkzeug: Man tippt, was man will, nicht wo es liegt.
|
||||
//
|
||||
// VIER KATEGORIEN, in der Reihenfolge, in der man sie braucht:
|
||||
// Gehe zu Sprungziele (wie bisher)
|
||||
// Tun Aktionen — Dienste, Modelle, Wartung
|
||||
// Finden Suche in Chronik und Wissens-Vault
|
||||
// An Lucy den getippten Text direkt als Idee erfassen
|
||||
//
|
||||
// SICHERHEITSREGEL (aus der Spezifikation, hier hart verdrahtet): Alles, was neu startet,
|
||||
// löscht oder deployt, trägt `rueckfrage` und wird zweistufig bestätigt. Die Palette ist
|
||||
// zu schnell für einen unbedachten Treffer — zwei Tastendrücke und ein Dienst wäre weg.
|
||||
|
||||
export type BefehlsGruppe = "gehe" | "tun" | "finden" | "lucy"
|
||||
|
||||
export interface Befehl {
|
||||
id: string
|
||||
gruppe: BefehlsGruppe
|
||||
label: string
|
||||
hinweis?: string
|
||||
icon: LucideIcon
|
||||
/** Zusätzliche Wörter für die Suche — was der Nutzer tippen könnte, aber nicht dasteht. */
|
||||
stichworte?: string
|
||||
/** Gesetzt = zweistufige Rückfrage mit diesem Text, bevor irgendetwas passiert. */
|
||||
rueckfrage?: string
|
||||
/** Was der Befehl tut. `text` ist die aktuelle Eingabe der Palette. */
|
||||
ausfuehren: (ctx: BefehlsKontext) => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface BefehlsKontext {
|
||||
text: string
|
||||
navigate: (pfad: string) => void
|
||||
schublade: (tab: "wartung" | "logs") => void
|
||||
melden: (art: "info" | "erfolg" | "warnung" | "fehler", text: string) => void
|
||||
api: <T>(pfad: string, init?: RequestInit) => Promise<T>
|
||||
}
|
||||
|
||||
export const GRUPPEN_TITEL: Record<BefehlsGruppe, string> = {
|
||||
gehe: "Gehe zu",
|
||||
tun: "Tun",
|
||||
finden: "Finden",
|
||||
lucy: "An Lucy",
|
||||
}
|
||||
|
||||
/** Sprungziele — direkt aus der Navigations-Tabelle, damit es nie zwei Listen gibt. */
|
||||
const ZIELE: Befehl[] = NAV.map((n) => ({
|
||||
id: `gehe:${n.id}`,
|
||||
gruppe: "gehe" as const,
|
||||
label: n.label,
|
||||
hinweis: n.hint,
|
||||
icon: n.icon,
|
||||
ausfuehren: ({ navigate }) => navigate(n.pfad),
|
||||
}))
|
||||
|
||||
/** Aktionen. Bewusst wenige: nur was man wirklich aus dem Stand heraus tut. */
|
||||
const AKTIONEN: Befehl[] = [
|
||||
{
|
||||
id: "tun:wartung",
|
||||
gruppe: "tun",
|
||||
label: "System-Wartung öffnen",
|
||||
hinweis: "Dienste, Updates, Snapshot",
|
||||
icon: Wrench,
|
||||
stichworte: "pflege dienste neustart update",
|
||||
ausfuehren: ({ schublade }) => schublade("wartung"),
|
||||
},
|
||||
{
|
||||
id: "tun:logs",
|
||||
gruppe: "tun",
|
||||
label: "System-Logs öffnen",
|
||||
hinweis: "Diagnose je Dienst",
|
||||
icon: ScrollText,
|
||||
stichworte: "fehler journal diagnose",
|
||||
ausfuehren: ({ schublade }) => schublade("logs"),
|
||||
},
|
||||
{
|
||||
id: "tun:updates",
|
||||
gruppe: "tun",
|
||||
label: "Nach Updates suchen",
|
||||
hinweis: "apt + Engine + Agent prüfen",
|
||||
icon: CloudDownload,
|
||||
stichworte: "aktualisieren apt engine",
|
||||
ausfuehren: async ({ api, melden }) => {
|
||||
await api("/api/maintenance/check-updates", { method: "POST" })
|
||||
melden("info", "Update-Suche läuft — das Ergebnis erscheint in der Wartung.")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tun:snapshot",
|
||||
gruppe: "tun",
|
||||
label: "Snapshot anlegen",
|
||||
hinweis: "Sicherung vor riskanten Schritten",
|
||||
icon: Camera,
|
||||
stichworte: "backup sicherung zeitmaschine",
|
||||
ausfuehren: async ({ api, melden }) => {
|
||||
await api("/api/system/backup", { method: "POST" })
|
||||
melden("erfolg", "Snapshot angestoßen.")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tun:engine-neustart",
|
||||
gruppe: "tun",
|
||||
label: "Motor neu starten",
|
||||
hinweis: "llama-swap — alle Modelle fallen kurz weg",
|
||||
icon: RefreshCw,
|
||||
stichworte: "engine llama swap modell neu starten",
|
||||
// Zweistufig: Ein Neustart wirft jedes geladene Modell aus dem Speicher, und ein
|
||||
// grosses Modell braucht danach ueber 20 Sekunden zum Aufwaermen.
|
||||
rueckfrage:
|
||||
"Der Motor (llama-swap) wird neu gestartet. Alle geladenen Modelle fallen dabei aus " +
|
||||
"dem Speicher und müssen neu aufwärmen — das dauert je nach Modell über 20 Sekunden. " +
|
||||
"Laufende Antworten brechen ab.",
|
||||
ausfuehren: async ({ api, melden }) => {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: "llama-swap" }),
|
||||
})
|
||||
melden(r.ok ? "erfolg" : "fehler",
|
||||
r.ok ? "Motor neu gestartet." : `Neustart fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Suchen — führen in die jeweilige Ansicht, mit dem Suchwort in der URL. */
|
||||
const SUCHEN = (text: string): Befehl[] => {
|
||||
const q = text.trim()
|
||||
if (!q) return []
|
||||
return [
|
||||
{
|
||||
id: "finden:chronik",
|
||||
gruppe: "finden",
|
||||
label: `„${q}“ in der Chronik`,
|
||||
hinweis: "Was die Box getan hat",
|
||||
icon: Search,
|
||||
ausfuehren: ({ navigate }) => navigate(`/chronik?q=${encodeURIComponent(q)}`),
|
||||
},
|
||||
{
|
||||
id: "finden:wissen",
|
||||
gruppe: "finden",
|
||||
label: `„${q}“ im Wissens-Vault`,
|
||||
hinweis: "Lucys Notizen",
|
||||
icon: FileText,
|
||||
ausfuehren: ({ navigate }) => navigate(`/wissen?suche=${encodeURIComponent(q)}`),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** An Lucy — den getippten Text als Idee erfassen. Erfasst nur, startet nichts. */
|
||||
const AN_LUCY = (text: string): Befehl[] => {
|
||||
const q = text.trim()
|
||||
if (q.length < 8) return [] // unter acht Zeichen ist es kein Auftrag, sondern ein Tippfehler
|
||||
return [
|
||||
{
|
||||
id: "lucy:idee",
|
||||
gruppe: "lucy",
|
||||
label: `Als Idee erfassen: „${q.length > 60 ? q.slice(0, 57) + "…" : q}“`,
|
||||
hinweis: "Wird nur notiert — gebaut wird erst auf deinen Klick",
|
||||
icon: Lightbulb,
|
||||
ausfuehren: async ({ api, melden, navigate }) => {
|
||||
// Rumpf exakt wie im Ideen-Verfasser (routers/ideen.py → IdeeIn). `art: "idee"`
|
||||
// heisst durchdenken, nicht bauen — die Palette startet nie einen Bau.
|
||||
await api("/api/ideen", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
titel: q.length > 160 ? q.slice(0, 157) + "…" : q,
|
||||
notiz: q.length > 160 ? q : "",
|
||||
welt: "ide",
|
||||
tiefe: "",
|
||||
ziel: "",
|
||||
art: "idee",
|
||||
}),
|
||||
})
|
||||
melden("erfolg", "Als Idee erfasst.")
|
||||
navigate("/ideen")
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Das vollständige Register für die aktuelle Eingabe. */
|
||||
export function befehle(text: string): Befehl[] {
|
||||
return [...ZIELE, ...AKTIONEN, ...SUCHEN(text), ...AN_LUCY(text)]
|
||||
}
|
||||
|
||||
/** Suchtext eines Befehls — cmdk filtert darüber. */
|
||||
export const suchtext = (b: Befehl) =>
|
||||
[b.label, b.hinweis, b.stichworte, GRUPPEN_TITEL[b.gruppe]].filter(Boolean).join(" ")
|
||||
@@ -101,7 +101,20 @@ function CockpitSeite() {
|
||||
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
|
||||
const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />)
|
||||
const skillsRoute = ansicht("/skills", () => <SkillsView />)
|
||||
const chronikRoute = ansicht("/chronik", () => <ChronikView />)
|
||||
/** Chronik: `?q=` filtert den Verlauf — Ziel der „Finden"-Einträge der Palette (P6). */
|
||||
export interface ChronikSuche {
|
||||
q?: string
|
||||
}
|
||||
const chronikRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/chronik",
|
||||
validateSearch: (roh: Record<string, unknown>): ChronikSuche =>
|
||||
typeof roh.q === "string" && roh.q ? { q: roh.q } : {},
|
||||
component: () => <ChronikView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Chronik ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
|
||||
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
|
||||
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
|
||||
@@ -124,15 +137,19 @@ const ideenRoute = createRoute({
|
||||
),
|
||||
})
|
||||
|
||||
/** Wissen: `?datei=<pfad>` macht jede Vault-Notiz verlinkbar. */
|
||||
/** Wissen: `?datei=` macht jede Notiz verlinkbar, `?suche=` trägt das Suchwort
|
||||
* aus der Befehlspalette herein (P6). */
|
||||
export interface WissenSuche {
|
||||
datei?: string
|
||||
suche?: string
|
||||
}
|
||||
const wissenRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/wissen",
|
||||
validateSearch: (roh: Record<string, unknown>): WissenSuche =>
|
||||
typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {},
|
||||
validateSearch: (roh: Record<string, unknown>): WissenSuche => ({
|
||||
...(typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {}),
|
||||
...(typeof roh.suche === "string" && roh.suche ? { suche: roh.suche } : {}),
|
||||
}),
|
||||
component: () => <WissenView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
|
||||
@@ -1,72 +1,160 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { NAV } from "@/nav"
|
||||
import { AlertTriangle, CornerDownLeft } from "lucide-react"
|
||||
import { useMC, usePaletteOffen } from "@/app/store"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
GRUPPEN_TITEL, befehle, suchtext,
|
||||
type Befehl, type BefehlsGruppe,
|
||||
} from "@/app/palette/register"
|
||||
|
||||
// Befehlspalette 2.0 (v3-Umbau P6, Spezifikation §4.6).
|
||||
//
|
||||
// Vorher listete sie die elf Navigations-Einträge — ein Sprungbrett. Jetzt tippt man,
|
||||
// WAS man will, nicht wo es liegt: springen, tun, finden, oder den Text direkt als Idee
|
||||
// an Lucy geben. Das Register steht in app/palette/register.ts.
|
||||
//
|
||||
// Die Rückfrage ist keine Höflichkeit, sondern Absicht: Die Palette ist schnell genug,
|
||||
// dass „Strg+K, mot, Enter" den Motor neu startet, bevor man es zu Ende gedacht hat.
|
||||
// Befehle mit `rueckfrage` halten deshalb an und sagen erst, was passieren wird.
|
||||
|
||||
const GRUPPEN_REIHE: BefehlsGruppe[] = ["gehe", "tun", "finden", "lucy"]
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const offen = usePaletteOffen()
|
||||
const palette = useMC((s) => s.palette)
|
||||
const melden = useMC((s) => s.melden)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault()
|
||||
setOpen((o) => !o)
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey)
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
const [text, setText] = useState("")
|
||||
const [nachfrage, setNachfrage] = useState<Befehl | null>(null)
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
|
||||
// Der „Suchen"-Knopf in der Kopfzeile (v3-Umbau P2, Befund B-09).
|
||||
// Er baute vorher ein GEFÄLSCHTES Tastatur-Ereignis (`new KeyboardEvent("keydown",
|
||||
// { metaKey: true })`) und feuerte es aufs Dokument, damit der Zuhörer oben anspringt.
|
||||
// Jetzt ein eigener, ehrlicher Kanal. In P3 löst der Store auch den ab.
|
||||
useEffect(() => {
|
||||
const onOpen = () => setOpen(true)
|
||||
window.addEventListener("mc-palette-open", onOpen)
|
||||
return () => window.removeEventListener("mc-palette-open", onOpen)
|
||||
}, [])
|
||||
const schliessen = useCallback(() => {
|
||||
palette(false)
|
||||
setText("")
|
||||
setNachfrage(null)
|
||||
}, [palette])
|
||||
|
||||
const ausfuehren = useCallback(async (b: Befehl) => {
|
||||
setLaeuft(true)
|
||||
try {
|
||||
await b.ausfuehren({
|
||||
text,
|
||||
navigate: (pfad) => navigate({ to: pfad }),
|
||||
schublade: (tab) => navigate({ to: ".", search: (a) => ({ ...a, system: tab }) }),
|
||||
melden,
|
||||
api,
|
||||
})
|
||||
schliessen()
|
||||
} catch (e) {
|
||||
// Der echte Grund aus ApiError.detail (P1/B-14) — nicht „Fehler".
|
||||
melden("fehler", e instanceof Error ? e.message : String(e))
|
||||
setNachfrage(null)
|
||||
} finally {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}, [text, navigate, melden, schliessen])
|
||||
|
||||
const liste = useMemo(() => befehle(text), [text])
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
open={offen}
|
||||
onOpenChange={(o) => (o ? palette(true) : schliessen())}
|
||||
label="Befehlspalette"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[12vh] overscroll-contain"
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overscroll-contain bg-black/50 p-4 pt-[12vh]"
|
||||
onClick={schliessen}
|
||||
shouldFilter={!nachfrage}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
|
||||
className="w-full max-w-xl overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Springe zu… (Modelle, Routing, System …)"
|
||||
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nichts gefunden.
|
||||
</Command.Empty>
|
||||
<Command.Group heading="Bereiche" className="px-1 py-1 text-xs text-muted-foreground">
|
||||
{NAV.map((item) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
value={`${item.label} ${item.hint}`}
|
||||
onSelect={() => {
|
||||
navigate({ to: item.pfad })
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
{nachfrage ? (
|
||||
// ── Zweistufige Rückfrage ────────────────────────────────────────
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
|
||||
<h2 className="font-space text-sm font-bold tracking-tight">{nachfrage.label}</h2>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{nachfrage.rueckfrage}</p>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setNachfrage(null)}
|
||||
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-primary" />
|
||||
<span>{item.label}</span>
|
||||
<span className="ml-auto truncate text-xs text-muted-foreground">{item.hint}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
autoFocus
|
||||
disabled={laeuft}
|
||||
onClick={() => ausfuehren(nachfrage)}
|
||||
className="h-9 cursor-pointer rounded-lg bg-amber-500/90 px-4 text-xs font-bold text-black transition-colors hover:bg-amber-500 disabled:opacity-50"
|
||||
>
|
||||
{laeuft ? "Läuft …" : "Ja, ausführen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
value={text}
|
||||
onValueChange={setText}
|
||||
placeholder="Springen, tun, finden — oder schreib eine Idee …"
|
||||
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Command.List className="max-h-[26rem] overflow-y-auto p-2 scrollbar-thin">
|
||||
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nichts gefunden.
|
||||
</Command.Empty>
|
||||
|
||||
{GRUPPEN_REIHE.map((gruppe) => {
|
||||
const eintraege = liste.filter((b) => b.gruppe === gruppe)
|
||||
if (!eintraege.length) return null
|
||||
return (
|
||||
<Command.Group
|
||||
key={gruppe}
|
||||
heading={GRUPPEN_TITEL[gruppe]}
|
||||
className="px-1 py-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50"
|
||||
>
|
||||
{eintraege.map((b) => (
|
||||
<Command.Item
|
||||
key={b.id}
|
||||
value={suchtext(b)}
|
||||
onSelect={() => (b.rueckfrage ? setNachfrage(b) : ausfuehren(b))}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
>
|
||||
<b.icon
|
||||
className={cn("h-4 w-4 shrink-0", b.rueckfrage ? "text-amber-400" : "text-primary")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{b.label}</span>
|
||||
{b.rueckfrage && (
|
||||
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-300">
|
||||
fragt nach
|
||||
</span>
|
||||
)}
|
||||
{b.hinweis && (
|
||||
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{b.hinweis}</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)
|
||||
})}
|
||||
</Command.List>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border/50 px-4 py-2 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">↑↓ wählen</span>
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" aria-hidden="true" /> ausführen</span>
|
||||
<span className="ml-auto">esc schließt</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { AktivitaetResp } from "@/lib/api"
|
||||
|
||||
// Die Query wird ersetzt, nicht das Netz: Der Test prüft die ANSICHT, nicht den Abruf.
|
||||
const useWerkzeugverlauf = vi.hoisted(() => vi.fn())
|
||||
vi.mock("@/lib/queries", () => ({ useWerkzeugverlauf }))
|
||||
|
||||
const { Werkzeugverlauf } = await import("./Werkzeugverlauf")
|
||||
|
||||
function zeigen(data: AktivitaetResp | undefined, isLoading = false) {
|
||||
useWerkzeugverlauf.mockReturnValue({ data, isLoading })
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Werkzeugverlauf />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const ANTWORT: AktivitaetResp = {
|
||||
verfuegbar: true,
|
||||
pfad: "~/.hermes/logs/agent.log",
|
||||
rufe: [
|
||||
{ zeit: "2026-08-28T07:01:20", lauf: "cron_a", werkzeug: "web_extract", dauer_s: 0.17, zeichen: null, ok: false, fehler: "DuckDuckGo (ddgs) is a search-only backend." },
|
||||
{ zeit: "2026-08-28T07:02:36", lauf: null, werkzeug: "web_search", dauer_s: 2.61, zeichen: 2944, ok: true, fehler: null },
|
||||
{ zeit: "2026-08-28T07:03:18", lauf: "cron_a", werkzeug: "terminal", dauer_s: 1.4, zeichen: 53, ok: true, fehler: null },
|
||||
],
|
||||
werkzeuge: [
|
||||
{ werkzeug: "web_extract", rufe: 2, fehler: 2, dauer_schnitt_s: 0.17, letzter_fehler: "DuckDuckGo (ddgs) is a search-only backend." },
|
||||
{ werkzeug: "web_search", rufe: 20, fehler: 0, dauer_schnitt_s: 1.77, letzter_fehler: null },
|
||||
],
|
||||
laeufe: [{ lauf: "cron_a", von: "2026-08-28T07:01:05", bis: "2026-08-28T07:03:18", rufe: 3, fehler: 1 }],
|
||||
}
|
||||
|
||||
/** Die Bilanz-Karte eines Werkzeugs — der Name steht auch in der Zeitleiste, deshalb
|
||||
* wird hier gezielt die Karte gesucht und nicht irgendein Vorkommen des Namens. */
|
||||
function bilanzKarte(werkzeug: string): HTMLElement {
|
||||
const treffer = screen.getAllByText(werkzeug)
|
||||
.map((el) => el.closest("div.mc-card-sm"))
|
||||
.filter((el): el is HTMLElement => el instanceof HTMLElement && el.textContent!.includes("Rufe"))
|
||||
expect(treffer.length, `genau eine Bilanz-Karte fuer ${werkzeug}`).toBe(1)
|
||||
return treffer[0]
|
||||
}
|
||||
|
||||
describe("Werkzeugverlauf", () => {
|
||||
// Der eigentliche Zweck der Ansicht: Ein Werkzeug, das IMMER scheitert, verschwindet
|
||||
// in einer Ereignisliste. Genau so lief `web_extract` vier Tage lang jeden Morgen
|
||||
// ins Leere, ohne dass es jemandem auffiel.
|
||||
it("markiert ein durchgehend scheiterndes Werkzeug als „immer rot“", () => {
|
||||
zeigen(ANTWORT)
|
||||
const karte = bilanzKarte("web_extract")
|
||||
expect(karte.textContent).toMatch(/immer rot/i)
|
||||
expect(karte.textContent).toMatch(/2 Fehler/)
|
||||
})
|
||||
|
||||
it("markiert ein fehlerfreies Werkzeug NICHT", () => {
|
||||
zeigen(ANTWORT)
|
||||
const karte = bilanzKarte("web_search")
|
||||
expect(karte.textContent).not.toMatch(/immer rot/i)
|
||||
expect(karte.textContent).toMatch(/0 Fehler/)
|
||||
// Und die Markierung gibt es im ganzen Abschnitt genau einmal.
|
||||
expect(screen.getAllByText(/immer rot/i)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("zeigt den echten Grund des Fehlers, nicht nur „Fehler“", () => {
|
||||
zeigen(ANTWORT)
|
||||
expect(bilanzKarte("web_extract").textContent).toMatch(/search-only backend/)
|
||||
})
|
||||
|
||||
it("nennt Rufe und Fehler in der Abschnitts-Überschrift", () => {
|
||||
const { container } = zeigen(ANTWORT)
|
||||
// Die Überschrift setzt sich aus mehreren Textknoten zusammen (der Fehler-Teil
|
||||
// erscheint nur bedingt), deshalb gegen den zusammengesetzten Text prüfen.
|
||||
const kopf = container.querySelector("section")!.textContent!
|
||||
expect(kopf).toMatch(/3 Rufe/) // 3 Einträge in ANTWORT.rufe
|
||||
expect(kopf).toMatch(/2 Fehler/) // Summe aus der Werkzeug-Bilanz
|
||||
})
|
||||
|
||||
it("gruppiert nach Lauf und zeigt dessen Zeitspanne", () => {
|
||||
zeigen(ANTWORT)
|
||||
const zeile = screen.getByText("cron_a").closest("div")!
|
||||
expect(zeile.textContent).toMatch(/07:01:05–07:03:18/)
|
||||
expect(zeile.textContent).toMatch(/1 Fehler/)
|
||||
})
|
||||
|
||||
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
|
||||
// weglassen — eine leere Karte waere eine Zusage ohne Inhalt.
|
||||
it("verschwindet ganz, wenn es kein Hermes-Log gibt", () => {
|
||||
const { container } = zeigen({ verfuegbar: false, pfad: "", rufe: [], werkzeuge: [], laeufe: [] })
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it("sagt beim Laden, was es tut", async () => {
|
||||
zeigen(undefined, true)
|
||||
await waitFor(() => expect(screen.getByText(/Lese Hermes/)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { AlertTriangle, Activity, CheckCircle2, Clock, Wrench } from "lucide-react"
|
||||
import { useWerkzeugverlauf } from "@/lib/queries"
|
||||
import { SectionLabel } from "@/components/ui/SectionLabel"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
//
|
||||
// Der Blueprint sah hier eine „Live Agent Matrix" mit Denkstrom vor (§4.4). Die ist so
|
||||
// nicht baubar: Lucys Denkschritte entstehen im Hermes-Prozess, und AGENTS.md verbietet
|
||||
// es, dessen Quellcode zu patchen. Was aber offen dalag: Hermes protokolliert JEDEN
|
||||
// Werkzeug-Ruf in sein Log. Zuschauen ist kein Patchen.
|
||||
//
|
||||
// Diese Ansicht zeigt deshalb nur, was wirklich da ist — welches Werkzeug, wie lange,
|
||||
// mit welchem Ergebnis, in welchem Lauf. Kein erfundener Denkstrom, keine leere Leitung.
|
||||
//
|
||||
// Die BILANZ steht oben, nicht die Zeitleiste. Grund: Ein Werkzeug, das immer scheitert,
|
||||
// verschwindet in einer Liste von Ereignissen — in der Zeile „web_extract · 2 Rufe ·
|
||||
// 2 Fehler" nicht. Genau so ist beim Bauen aufgefallen, dass `web_extract` seit
|
||||
// mindestens vier Tagen jeden Morgen um 07:00 scheitert.
|
||||
|
||||
const uhr = (iso: string) => iso.slice(11, 19)
|
||||
|
||||
/** Dauer lesbar: unter einer Sekunde in ms, sonst in Sekunden. */
|
||||
function dauer(s: number): string {
|
||||
return s < 1 ? `${Math.round(s * 1000)} ms` : `${s.toFixed(1)} s`
|
||||
}
|
||||
|
||||
export function Werkzeugverlauf() {
|
||||
const { data, isLoading } = useWerkzeugverlauf()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={Activity}>Werkzeug-Verlauf</SectionLabel>
|
||||
<div className="mc-card-sm p-4 text-xs text-muted-foreground">Lese Hermes' Log …</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
|
||||
// weglassen statt eine leere Karte zu zeigen — wie der Rest der Seite es hält.
|
||||
if (!data?.verfuegbar) return null
|
||||
|
||||
const { rufe, werkzeuge, laeufe } = data
|
||||
const fehlerhaft = werkzeuge.filter((w) => w.fehler > 0)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={Activity}>
|
||||
Werkzeug-Verlauf · {rufe.length} Rufe
|
||||
{fehlerhaft.length > 0 && ` · ${fehlerhaft.reduce((n, w) => n + w.fehler, 0)} Fehler`}
|
||||
</SectionLabel>
|
||||
|
||||
<p className="max-w-2xl text-[11px] leading-relaxed text-muted-foreground">
|
||||
Gelesen aus Hermes' eigenem Log — Mission Control schaut zu, greift aber nicht ein.
|
||||
Was dort <em>nicht</em> steht, steht auch hier nicht: Lucys Gedankengang und die
|
||||
Argumente eines Werkzeug-Rufs bleiben unsichtbar.
|
||||
</p>
|
||||
|
||||
{/* Bilanz zuerst — hier faellt ein Dauerfehler auf, den eine Zeitleiste verschluckt. */}
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{werkzeuge.map((w) => {
|
||||
const immerRot = w.fehler > 0 && w.fehler === w.rufe
|
||||
return (
|
||||
<div
|
||||
key={w.werkzeug}
|
||||
className={cn("mc-card-sm space-y-1.5 p-3",
|
||||
immerRot && "border-red-500/40 bg-red-500/5")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className={cn("h-3.5 w-3.5 shrink-0",
|
||||
immerRot ? "text-red-400" : w.fehler ? "text-amber-400" : "text-primary")} aria-hidden="true" />
|
||||
<span className="truncate font-mono text-xs font-semibold text-foreground">{w.werkzeug}</span>
|
||||
{immerRot && (
|
||||
<span className="ml-auto shrink-0 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-red-300">
|
||||
immer rot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="tabular-nums">{w.rufe} Rufe</span>
|
||||
<span className={cn("tabular-nums", w.fehler > 0 && "font-semibold text-red-400")}>
|
||||
{w.fehler} Fehler
|
||||
</span>
|
||||
<span className="tabular-nums">Ø {dauer(w.dauer_schnitt_s)}</span>
|
||||
</div>
|
||||
{w.letzter_fehler && (
|
||||
<p className="border-t border-border/30 pt-1.5 text-[10px] leading-relaxed text-red-300/90">
|
||||
{w.letzter_fehler}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Laeufe: welcher Cron/welche Sitzung hat wann was getan. */}
|
||||
{laeufe.length > 0 && (
|
||||
<div className="mc-card-sm divide-y divide-border/30">
|
||||
{laeufe.map((l) => (
|
||||
<div key={l.lauf} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 font-mono text-[10px]">
|
||||
<Clock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="truncate font-semibold text-foreground">{l.lauf}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{uhr(l.von)}–{uhr(l.bis)}</span>
|
||||
<span className="ml-auto tabular-nums text-muted-foreground">{l.rufe} Rufe</span>
|
||||
{l.fehler > 0 && (
|
||||
<span className="tabular-nums font-semibold text-red-400">{l.fehler} Fehler</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zeitleiste, juengster Ruf oben. */}
|
||||
<details className="mc-card-sm">
|
||||
<summary className="cursor-pointer px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground">
|
||||
Einzelne Rufe ({rufe.length})
|
||||
</summary>
|
||||
<ul className="max-h-72 divide-y divide-border/20 overflow-y-auto scrollbar-thin border-t border-border/30">
|
||||
{[...rufe].reverse().map((r, i) => (
|
||||
<li key={`${r.zeit}-${r.werkzeug}-${i}`} className="flex items-start gap-2 px-3 py-1.5 font-mono text-[10px]">
|
||||
{r.ok
|
||||
? <CheckCircle2 className="mt-px h-3 w-3 shrink-0 text-emerald-500" aria-hidden="true" />
|
||||
: <AlertTriangle className="mt-px h-3 w-3 shrink-0 text-red-400" aria-hidden="true" />}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{uhr(r.zeit)}</span>
|
||||
<span className="shrink-0 font-semibold text-foreground">{r.werkzeug}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{dauer(r.dauer_s)}</span>
|
||||
{r.zeichen != null && (
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">{r.zeichen} Z.</span>
|
||||
)}
|
||||
{r.fehler && <span className="min-w-0 flex-1 truncate text-red-300/90" title={r.fehler}>{r.fehler}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { Dichteleiste } from "./Dichteleiste"
|
||||
import type { ChronikItem } from "@/lib/api"
|
||||
|
||||
function eintrag(ts: number): ChronikItem {
|
||||
return { id: ts, ts, subject: "x", text: "y", source: "test", priority: "normal" }
|
||||
}
|
||||
|
||||
/** Die Balken der Leiste (ohne die Achsen-Beschriftung darunter). */
|
||||
function balken(container: HTMLElement): HTMLElement[] {
|
||||
return [...container.querySelectorAll('[role="img"] > div')] as HTMLElement[]
|
||||
}
|
||||
|
||||
describe("Dichteleiste", () => {
|
||||
it("zeigt nichts, wenn es nichts zu zeigen gibt", () => {
|
||||
const { container } = render(<Dichteleiste items={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it("verteilt die Einträge über die volle Breite", () => {
|
||||
const basis = 1_800_000_000
|
||||
const items = [eintrag(basis), eintrag(basis + 43_200), eintrag(basis + 86_400)]
|
||||
const { container } = render(<Dichteleiste items={items} />)
|
||||
const b = balken(container)
|
||||
expect(b).toHaveLength(48)
|
||||
// Erster, mittlerer und letzter Eimer sind belegt — dazwischen ist es leer.
|
||||
expect(b[0].style.height).not.toBe("2px")
|
||||
expect(b[47].style.height).not.toBe("2px")
|
||||
expect(b[5].style.height).toBe("2px")
|
||||
})
|
||||
|
||||
// Ohne Mindesthöhe sähe ein ruhiger Tag aus wie gar keine Aktivität — genau die
|
||||
// Verwechslung, die die Leiste verhindern soll.
|
||||
it("macht auch einen einzelnen Eintrag sichtbar", () => {
|
||||
const { container } = render(<Dichteleiste items={[eintrag(1_800_000_000)]} />)
|
||||
const belegt = balken(container).filter((d) => d.style.height !== "2px")
|
||||
expect(belegt).toHaveLength(1)
|
||||
expect(parseFloat(belegt[0].style.height)).toBeGreaterThanOrEqual(12)
|
||||
})
|
||||
|
||||
it("beschriftet sich für Screenreader mit Anzahl und Zeitraum", () => {
|
||||
const basis = 1_800_000_000
|
||||
const { container } = render(<Dichteleiste items={[eintrag(basis), eintrag(basis + 86_400)]} />)
|
||||
const label = container.querySelector('[role="img"]')!.getAttribute("aria-label")!
|
||||
expect(label).toMatch(/2 Einträge/)
|
||||
expect(label).toMatch(/zwischen .* und /)
|
||||
})
|
||||
|
||||
it("hebt die Leiste hervor, solange gefiltert wird", () => {
|
||||
const items = [eintrag(1_800_000_000)]
|
||||
const normal = render(<Dichteleiste items={items} />).container
|
||||
const gefiltert = render(<Dichteleiste items={items} aktiv />).container
|
||||
const klasse = (c: HTMLElement) =>
|
||||
balken(c).find((d) => d.style.height !== "2px")!.className
|
||||
expect(klasse(normal)).toMatch(/primary\/40/)
|
||||
expect(klasse(gefiltert)).toMatch(/primary\/70/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from "react"
|
||||
import type { ChronikItem } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Aktivitäts-Dichte über der Chronik (v3-Umbau P6, Spezifikation §4.5).
|
||||
//
|
||||
// Der eigentliche Zugewinn gegenüber einer reinen Liste: Sie zeigt, WANN die Box aktiv
|
||||
// war, bevor man liest, WAS sie getan hat. Ein stiller Tag und eine durchgearbeitete
|
||||
// Nacht sehen in einer Liste gleich aus — man muss scrollen, um es zu merken.
|
||||
//
|
||||
// Bewusst eigenes SVG statt einer Diagramm-Bibliothek: Es sind Rechtecke über einer
|
||||
// Zeitachse. Recharts dafür zu laden wäre 95 kB für etwas, das in 30 Zeilen passt —
|
||||
// und genau dieser Reflex hat in P0 das Startbündel aufgebläht.
|
||||
|
||||
const STUNDE = 3_600
|
||||
const EIMER = 48 // 48 Stufen über den gezeigten Zeitraum
|
||||
|
||||
export function Dichteleiste({ items, aktiv }: {
|
||||
items: ChronikItem[]
|
||||
/** Farbe für den Fall, dass gefiltert wird — dann zeigt die Leiste nur die Treffer. */
|
||||
aktiv?: boolean
|
||||
}) {
|
||||
const { balken, von, bis, max } = useMemo(() => {
|
||||
if (!items.length) return { balken: [] as number[], von: 0, bis: 0, max: 0 }
|
||||
// Die Chronik kommt neueste zuerst; für die Achse brauchen wir beide Enden.
|
||||
const zeiten = items.map((i) => i.ts)
|
||||
const von = Math.min(...zeiten)
|
||||
const bis = Math.max(...zeiten)
|
||||
const spanne = Math.max(bis - von, STUNDE)
|
||||
const balken = new Array(EIMER).fill(0)
|
||||
for (const t of zeiten) {
|
||||
const i = Math.min(EIMER - 1, Math.floor(((t - von) / spanne) * EIMER))
|
||||
balken[i]++
|
||||
}
|
||||
return { balken, von, bis, max: Math.max(...balken, 1) }
|
||||
}, [items])
|
||||
|
||||
if (balken.length === 0) return null
|
||||
|
||||
const zeitpunkt = (ts: number) =>
|
||||
new Date(ts * 1000).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit",
|
||||
...(bis - von < 2 * 86_400 ? { hour: "2-digit", minute: "2-digit" } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="flex h-8 items-end gap-px"
|
||||
role="img"
|
||||
aria-label={`Aktivitätsdichte: ${items.length} Einträge zwischen ${zeitpunkt(von)} und ${zeitpunkt(bis)}`}
|
||||
>
|
||||
{balken.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={n === 1 ? "1 Eintrag" : `${n} Einträge`}
|
||||
className={cn(
|
||||
"min-w-px flex-1 rounded-t-sm transition-colors",
|
||||
n === 0 ? "bg-border/25" : aktiv ? "bg-primary/70" : "bg-primary/40",
|
||||
)}
|
||||
// Auch ein einzelner Eintrag muss sichtbar sein — sonst sieht ein ruhiger
|
||||
// Tag aus wie gar nichts.
|
||||
style={{ height: n === 0 ? "2px" : `${Math.max(12, (n / max) * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between font-mono text-[9px] text-muted-foreground/60">
|
||||
<span>{zeitpunkt(von)}</span>
|
||||
<span className="tabular-nums">{items.length} Einträge</span>
|
||||
<span>{zeitpunkt(bis)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -397,6 +397,40 @@ export interface Job {
|
||||
eta_s?: number
|
||||
}
|
||||
|
||||
// Werkzeug-Verlauf des Agenten (GET /api/agent/aktivitaet, v3-Umbau P6).
|
||||
// Gelesen aus Hermes' Log — Denkstrom und Werkzeug-Argumente stehen dort NICHT und
|
||||
// tauchen darum auch hier nicht auf.
|
||||
export interface WerkzeugRuf {
|
||||
zeit: string
|
||||
lauf: string | null
|
||||
werkzeug: string
|
||||
dauer_s: number
|
||||
zeichen: number | null
|
||||
ok: boolean
|
||||
fehler: string | null
|
||||
}
|
||||
export interface WerkzeugBilanz {
|
||||
werkzeug: string
|
||||
rufe: number
|
||||
fehler: number
|
||||
dauer_schnitt_s: number
|
||||
letzter_fehler: string | null
|
||||
}
|
||||
export interface AgentLauf {
|
||||
lauf: string
|
||||
von: string
|
||||
bis: string
|
||||
rufe: number
|
||||
fehler: number
|
||||
}
|
||||
export interface AktivitaetResp {
|
||||
verfuegbar: boolean
|
||||
pfad: string
|
||||
rufe: WerkzeugRuf[]
|
||||
werkzeuge: WerkzeugBilanz[]
|
||||
laeufe: AgentLauf[]
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
status: string
|
||||
version: string
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sseVerbunden } from "./events"
|
||||
import {
|
||||
api,
|
||||
type AgentStatus,
|
||||
type AktivitaetResp,
|
||||
type AuftragsbuchResp,
|
||||
type ChronikResp,
|
||||
type ConnectResp,
|
||||
@@ -46,6 +47,7 @@ export const qk = {
|
||||
jobs: ["jobs"] as const,
|
||||
tokenStats: ["token-stats"] as const,
|
||||
agentStatus: ["agent-status"] as const,
|
||||
agentAktivitaet: ["agent-aktivitaet"] as const,
|
||||
hermesBrain: ["hermes-brain"] as const,
|
||||
updates: ["updates"] as const,
|
||||
discover: ["discover"] as const,
|
||||
@@ -221,6 +223,15 @@ export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
|
||||
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
|
||||
|
||||
// Werkzeug-Verlauf des Agenten. Liest das Ende von Hermes' Log — billig, aber es
|
||||
// aendert sich nur, wenn Lucy wirklich arbeitet; der gemuetliche Takt reicht.
|
||||
export const useWerkzeugverlauf = (refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: qk.agentAktivitaet,
|
||||
queryFn: () => api<AktivitaetResp>("/api/agent/aktivitaet?limit=80"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
})
|
||||
|
||||
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
|
||||
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
|
||||
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react"
|
||||
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Werkzeugverlauf } from "@/features/agent/Werkzeugverlauf"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
@@ -340,6 +341,9 @@ export function AgentView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Werkzeug-Verlauf (v3-Umbau P6): was Lucy tatsaechlich getan hat, aus ihrem Log. */}
|
||||
<Werkzeugverlauf />
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
History, HeartPulse, ShieldAlert, Bell, Moon, Radar, Inbox, Loader2,
|
||||
Archive, RotateCcw, AlarmClock, MessageCircle, Sparkles, type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import { AlarmClock, Archive, Bell, HeartPulse, History, Inbox, Loader2, MessageCircle, Moon, Radar, RotateCcw, Search, ShieldAlert, Sparkles, type LucideIcon } from "lucide-react"
|
||||
import { api, type ChronikItem, type Snapshot } from "@/lib/api"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { Dichteleiste } from "@/features/chronik/Dichteleiste"
|
||||
import { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -30,9 +29,25 @@ function dayKey(ts: number): string {
|
||||
}
|
||||
|
||||
export function ChronikView() {
|
||||
const { data: items = [], isLoading } = useChronik()
|
||||
const { data: alle = [], isLoading } = useChronik()
|
||||
const [limitPerDay, setLimitPerDay] = useState(true)
|
||||
|
||||
// v3-Umbau P6: Das Suchwort steht in der URL (/chronik?q=…). So landet der
|
||||
// „Finden"-Eintrag der Befehlspalette hier — und ein Treffer ist teilbar.
|
||||
const navigate = useNavigate()
|
||||
const { q = "" } = useSearch({ from: "/chronik" })
|
||||
const suchen = (wert: string) =>
|
||||
navigate({ to: "/chronik", search: wert ? { q: wert } : {}, replace: true })
|
||||
|
||||
const items = useMemo(() => {
|
||||
const ql = q.trim().toLowerCase()
|
||||
if (!ql) return alle
|
||||
return alle.filter((it) =>
|
||||
it.subject.toLowerCase().includes(ql)
|
||||
|| it.text.toLowerCase().includes(ql)
|
||||
|| it.source.toLowerCase().includes(ql))
|
||||
}, [alle, q])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const g: { day: string; items: ChronikItem[] }[] = []
|
||||
for (const it of items) {
|
||||
@@ -58,9 +73,26 @@ export function ChronikView() {
|
||||
<Zeitmaschine />
|
||||
|
||||
<section className="space-y-4">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<History className="h-3.5 w-3.5" /> Verlauf
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<History className="h-3.5 w-3.5" /> Verlauf
|
||||
{q && <span className="normal-case text-primary">· {items.length} von {alle.length}</span>}
|
||||
</p>
|
||||
<div className="relative ml-auto w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => suchen(e.target.value)}
|
||||
placeholder="Verlauf durchsuchen …"
|
||||
aria-label="Chronik durchsuchen"
|
||||
className="h-8 w-full rounded-lg border border-border/60 bg-background/40 pl-8 pr-3 text-xs text-foreground outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeigt, WANN die Box aktiv war — bevor man liest, WAS sie getan hat (§4.5). */}
|
||||
{!isLoading && items.length > 0 && <Dichteleiste items={items} aktiv={!!q} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
|
||||
@@ -70,7 +102,9 @@ export function ChronikView() {
|
||||
|
||||
{!isLoading && items.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
|
||||
Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier.
|
||||
{q
|
||||
? <>Nichts zu „{q}" im Verlauf. <button onClick={() => suchen("")} className="cursor-pointer text-primary underline underline-offset-2">Filter aufheben</button></>
|
||||
: "Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
|
||||
import { api, type WissenDatei, type WissenFile } from "@/lib/api"
|
||||
import { useWissen, useMemoryGraph } from "@/lib/queries"
|
||||
@@ -24,7 +25,14 @@ export function WissenView() {
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [doc, setDoc] = useState<WissenDatei | null>(null)
|
||||
const [docLoading, setDocLoading] = useState(false)
|
||||
const [q, setQ] = useState("")
|
||||
// v3-Umbau P6: Das Suchfeld gab es schon, aber nur lokal. Jetzt steht das Wort in
|
||||
// der URL (/wissen?suche=…) — so landet der „Finden"-Eintrag der Palette hier, und
|
||||
// ein Suchergebnis ist teilbar.
|
||||
const navigate = useNavigate()
|
||||
const { suche = "" } = useSearch({ from: "/wissen" })
|
||||
const q = suche
|
||||
const setQ = (wert: string) =>
|
||||
navigate({ to: "/wissen", search: (a) => ({ ...a, suche: wert || undefined }), replace: true })
|
||||
const [showGraph, setShowGraph] = useState(false)
|
||||
|
||||
const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph)
|
||||
|
||||
Reference in New Issue
Block a user