Author SHA1 Message Date
HitonabiandClaude Fable 5.1 df8d088fac doku(raphael): Ausroll-Weg korrigiert — Branch -> Ampel -> User-Merge -> deploy.sh, nicht Auftragsbuch
Ampel / ampel (push) Failing after 23s
Der vorige Commit sprach von Karten annehmen. Befund: die Auftragsbuch-Logik liegt noch im Code
und die Box listet Branches als Karten, ist aber seit 02.08. ungenutzt (Kanban-Tools seit 21.08.
aus, v3-Umbau lief ueber Branch/Ampel/Merge/deploy.sh, PC-Executor-IP in der Box veraltet).
RAPHAEL.md beschreibt jetzt den echten Weg mit Befehlen; OFFENE-FAEDEN traegt die Leiche als
Entscheid-Punkt ein. Kein Code geaendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:07:03 +02:00
HitonabiandClaude Fable 5.1 b570e9410d feat(voice): Lucys Stimme ins LAN (/api/lucy/stimme) + Raphael-Zielbild
Ampel / ampel (push) Failing after 24s
Die Desktop-Lucy spricht nach dem Raphael-Umbau (Lucy-Repo, feature/raphael-innere-stimme)
nicht mehr mit einem eigenen pocket_server am PC, sondern mit lucy-stimme.service (:8021,
pocket-tts german_24l) auf der Box — dieselbe Stimme wie die Telegram-Sprachnachrichten.
Der Dienst bindet nur Loopback, darum reicht MC2 ihn jetzt duenn durch:
  GET  /api/lucy/stimme/health       -> pocket /health (ok|loading)
  POST /api/lucy/stimme/tts          -> WAV (Warm-up, Jobs)
  POST /api/lucy/stimme/tts/stream   -> PCM16-Stream, X-Sample-Rate durchgereicht,
                                        Upstream schliesst bei Client-Abbruch (Barge-in)
config: LUCY_STIMME_URL (Env MC_LUCY_STIMME_URL, Default http://127.0.0.1:8021).
Kein Frontend-Build noetig (nur Backend + Doku).

Doku: docs/wissen/RAPHAEL.md (Entscheid, Annahme-Reihenfolge — DIESE Karte zuerst —,
Box-Handschritte fuer die OpenAI-Fassade + Hermes-TTS auf openai/base_url :8021, Mobil via
Telegram, SOUL.md-Vorschlag im Raphael-Ton), ZIELBILD Punkt 8, OFFENE-FAEDEN, wissen/README.
VERDIKTE.md bewusst unveraendert — Ersatz erst nach Ohr-Test.
Gates: py_compile + ruff gruen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:02:18 +02:00
HitonabiandClaude Fable 5.1 e9c2599542 fix(radar): nur Aenderungen ans Modell, und kein Fehlalarm nach dem Neustart
Ampel / ampel (push) Failing after 23s
Erster Lauf des Upstream-Blicks: das Modell machte aus "unveraendert"-Zeilen
naechste Schritte und aus "Timer seit Neustart noch nicht gelaufen" ein hohes
Risiko. Beides filtert jetzt das Skript: nur ACHTUNG-Zeilen (plus Hinweis)
gehen in den Prompt, und ein Timer mit naechstem Termin auf einer Box, die
juenger als einen Tag ist, gilt als wartend, nicht als kaputt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:53:55 +02:00
HitonabiandClaude Fable 5.1 a8a6ce3b29 feat(radar): Blick nach draussen als Skript — und heavy zieht auf Qwen3.8-27B
Warum: DFlash2 fuer den Coder lag vom 19.08. bis 04.09. ungenutzt auf der
Platte, weil der KISS-Radar nur nach innen sah. stack-upstream.py fragt die
Watchlist als Fakten ab (GitHub-Issues/PRs/Releases/Branches, PyPI, neue
Repos eines HF-Autors, Zeilen einer URL), vergleicht mit dem letzten Lauf und
meldet nur Aenderungen als ACHTUNG NEU. stack-radar.sh haengt das an den
IST-Zustand; faellt es aus, bleibt der Innen-Bericht vollstaendig.
Erster Lauf fand sofort: MMQ-Issue 21284 ist geschlossen, pocket-tts 3.1.0,
Electron 44 — alles Dinge, die der Radar seit Juli haette melden sollen.

heavy: gpt-oss-120b (AA-Index 24, 60 GB) gibt die Rolle an Qwen3.8-27B ab
(Index 52, 17 GB, 31 t/s mit DFlash2) — ein Modell, zwei Rollen. gpt-oss
bleibt ohne Alias als Rollback. Live gemessen ueber :9010 mit Reasoning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:52:50 +02:00
HitonabiandClaude Fable 5.1 0944b1d93e feat(llama-swap): DFlash2-Draft für den Coder — gemessen 12,6 → 31 t/s
Ampel / ampel (push) Successful in 35s
Qwen3.8-27B lief seit 19.08. ohne sein Entwurfsmodell, obwohl es auf der Box
lag: Mainline-llama.cpp konnte DFlash2 erst seit 27.08. (PR 27342) plus
Vulkan-Fix 28.08. (PR 27812); die Engine vom 1.9. (b10733) kann beides.
Auf der Box gemessen (Vulkan, 32k, Code-Prompt): ohne Draft 12,6 t/s,
mit Q4_K_M-Draft 31 t/s bei Akzeptanz 0,78. n-max 5, f16-KV und der
Q8-Draft bringen nichts. Live-Config und Repo-Kopie sind identisch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:45:54 +02:00
HitonabiandClaude Opus 5 1c8f285151 Merge: v3-Umbau P0-P6 — Frontend-Architektur, Sicherheit, Werkzeug-Verlauf
Ampel / ampel (push) Successful in 35s
Zehn Commits vom Zweig umbau/v3-p0-ballast, Ampel dort gruen.

  P0  Ballast raus            dist 27,4 MB -> 1,65 MB, Startbuendel halbiert
  P1  Sicherheit + Tests      Geheimnisse aus dem Browser, Vitest/ESLint ins Gate
  P2  Router                  Unterzustand in der URL, Fehlergrenze pro Route
  P3  Store + Statusleiste    ein Client-Speicher, aria-live, Ultrawide-Deckel
  P4  Stream                  Messwerte gepusht statt gepollt
  P5  Ideen-Bereich           1031-Zeilen-Monolith zerlegt
  P6  Werkzeug-Verlauf        Hermes' Log lesen statt patchen — fand einen vier
                              Tage alten stillen Fehler (web_extract)
      Palette 2.0             vier Kategorien, Rueckfrage-Regel per Test verdrahtet
      React 19                Budget bewusst 125 -> 140 kB
      Chronik-Dichteleiste    zeigt WANN, bevor man liest WAS
  +   Hermes-Probe            720 Auth-Fehler/Tag weniger in Hermes' Log
  +   Zeitzone                ruff DTZ007 — die Ampel hatte recht

59 Tests (vorher 0) · ESLint 0 Fehler · 20 von 22 Befunden erledigt.
Rueckweg: git reset --hard 3d1881f && bash deploy/deploy.sh

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:28:22 +02:00
HitonabiandClaude Opus 5 041ede7f8d fix(agent): Zeitstempel mit Zeitzone — die Ampel hatte recht
Ampel / ampel (push) Successful in 36s
Die Ampel wurde ROT: ruff DTZ007, `datetime.strptime()` ohne Offset in
services/agent_aktivitaet.py. Lokal war alles gruen, weil ich ruff nicht laufen
liess — nur py_compile, tsc, vitest und den Build. Mein Fehler, nicht der der Regel.

Und die Regel ist keine Schikane: AGENTS.md haelt fest, dass naive Zeiten ueber
MC_LOCAL_TZ aufzuloesen sind, nie zu raten. Hermes schreibt Ortszeit ohne Offset;
das so durchzureichen waere bequem gewesen (der Klient zeigt sie nur an) — aber
sobald jemand spaeter damit RECHNET (Dauer ueber Mitternacht, Abgleich mit einem
Cron-Plan), waere es eine Falle, die erst zur Zeitumstellung zuschnappt.

  vorher:  2026-08-28T07:03:18
  jetzt:   2026-08-28T07:03:18+02:00

Gegengeprueft am echten Box-Log; die Anzeige schneidet weiterhin Stelle 11-19
heraus und zeigt unveraendert 07:03:18.

`ruff check .` laeuft jetzt ueber das ganze Repo sauber durch — ab hier gehoert
es vor jeden Commit, der Python anfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:04:08 +02:00
HitonabiandClaude Opus 5 33032f8fb0 feat(chronik): P6 — Dichteleiste zeigt, WANN die Box aktiv war (§4.5)
Ampel / ampel (push) Failing after 25s
Der Zugewinn gegenueber einer reinen Liste: Ein stiller Tag und eine durchgearbeitete
Nacht sehen in einer Liste gleich aus — man muss scrollen, um es zu merken. Die
Dichteleiste macht das Muster sichtbar, bevor man den ersten Eintrag liest.

48 Stufen ueber den gezeigten Zeitraum, Achsenbeschriftung an beiden Enden, und beim
Filtern zeigt sie nur die Treffer (dann in kraeftigerem Ton).

Bewusst eigenes SVG-freies Markup statt einer Diagramm-Bibliothek: Es sind Rechtecke
ueber einer Zeitachse. Recharts dafuer zu laden waere 95 kB fuer etwas, das in 30
Zeilen passt — genau der Reflex, der in P0 das Startbuendel aufgeblaeht hatte.

Eine Kleinigkeit mit Absicht: Ein einzelner Eintrag bekommt 12 % Mindesthoehe. Ohne
sie saehe ein ruhiger Tag aus wie gar keine Aktivitaet — genau die Verwechslung, die
die Leiste verhindern soll. Dafuer gibt es einen eigenen Test.

Verifiziert gegen die echte Box:
  150 Eintraege vom 02.08. bis 28.08., 25 von 48 Stufen belegt, Hoehen gestaffelt
  Filter "update" -> 30 von 150, Leiste zeigt nur noch die Treffer und wird
    hervorgehoben, Adresse /chronik?q=update

59 Tests gruen (5 neue) · ESLint 0 Fehler · Einstieg 133 400 B gzip / Budget 140 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:02:07 +02:00
HitonabiandClaude Opus 5 3249c8e942 build(react): P6 — Sprung auf React 19, und das Budget bewusst angehoben
React 18.3.1 -> 19.2.8, dazu @types/react(-dom) 19.

## Nichts brach

  tsc --noEmit sauber, 54/54 Tests gruen, ESLint 0 Fehler
  Im Browser gegen die ECHTE Box: Cockpit mit 2 Diagrammen und 4 Flaechen,
    Statusleiste live (40,8/122,7 GB, 38 °C, 532 748 115 Token), 8 Karten
  NULL Konsolen-Warnungen — keine veralteten Muster im Bestand

Das eigentliche Risiko war `react-force-graph-2d` (peer: react "*", also ungeprueft,
und es umhuellt eine Nicht-React-Bibliothek). Gegen den echten Vault der Box getestet:
Canvas 500x560, 4498 gezeichnete Pixel, keine Fehlerflaeche. Es laeuft.

## Das Budget hat angeschlagen — und ich habe es trotzdem angehoben

Gemessen kostet der Sprung 14 645 B gzip (118 762 -> 133 407) und riss damit das
in P0 gesetzte Budget von 125 000. Das Gate hat also getan, was es soll.

Beim Router (P2) habe ich in derselben Lage NICHT das Budget angefasst, sondern den
Platz zurueckgeholt (Schublade und Palette hinter lazy()). Hier geht das nicht: Der
Zuwachs IST die Plattform, es gibt nichts wegzulassen.

Also angehoben — einmalig, auf 140 000, mit der Begruendung IM Ampel-Skript, damit ein
spaeterer Leser sieht, dass es ein ueberlegter Schritt war und kein Nachgeben:
MC2 ist eine LAN-Appliance; 14 kB sind ueber Gigabit-Ethernet keine messbare Wartezeit.
Das Budget existiert gegen DRIFT — einen 24-MB-Avatar, eine 95-kB-Diagramm-Bibliothek
auf der Startseite — nicht gegen einen bewussten Plattform-Schritt. Der relative
Abstand zum Deckel bleibt derselbe wie vorher (~5 %).

## Ehrlich zum Nutzen

React 19 bringt diesem Projekt HEUTE wenig Konkretes: use(), Actions und
Server-Komponenten sind hier nicht im Spiel. Der Grund ist die Zukunft — 18 altert,
und der React Compiler ist der Pfad, auf dem die 18 offenen Warnungen aus
eslint-plugin-react-hooks (setState im Effekt, Ref-Zugriff im Render) irgendwann
nicht mehr nur Warnungen sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:59:58 +02:00
HitonabiandClaude Opus 5 24a0694b80 feat(palette): P6 — die Palette wird zum Bedienwerkzeug (§4.6)
Bisher listete Strg+K die elf Navigations-Eintraege: ein Sprungbrett. Jetzt tippt
man, WAS man will, nicht wo es liegt.

  Gehe zu   die 11 Ziele — direkt aus NAV, damit es nie zwei Listen gibt
  Tun       Wartung/Logs oeffnen, Updates suchen, Snapshot, Motor neu starten
  Finden    das Getippte in Chronik oder Wissens-Vault suchen
  An Lucy   das Getippte als Idee erfassen

## Die Sicherheitsregel ist verdrahtet, nicht nur beschrieben

Die Palette ist schnell genug, dass "Strg+K, mot, Enter" den Motor neu startet,
bevor man es zu Ende gedacht hat. Jeder eingreifende Befehl traegt darum
`rueckfrage`; die Oberflaeche haelt dann an und sagt erst, was passieren WIRD:

  "Alle geladenen Modelle fallen dabei aus dem Speicher und muessen neu aufwaermen —
   das dauert je nach Modell ueber 20 Sekunden. Laufende Antworten brechen ab."

Ein Test prueft die Regel generisch: Jeder Befehl, dessen Label auf "neu starten",
"loeschen", "deploy", "reboot" o. AE. passt, MUSS eine Rueckfrage haben. Wer kuenftig
einen eingreifenden Befehl ohne Rueckfrage ergaenzt, macht die Ampel rot.

"An Lucy" erfasst mit `art: "idee"` — durchdenken, nicht bauen. `"projekt"` wuerde
sofort ein Repo anlegen; das darf aus einem Tastendruck nie passieren. Auch das ist
getestet.

## Die "Finden"-Eintraege fuehren nicht ins Leere

Sie brauchten erst ein Ziel — sonst haette ich genau die leere Leitung gebaut, die ich
beim Werkzeug-Verlauf vermieden habe:

  ChronikView   neues Suchfeld, filtert ueber Betreff/Text/Quelle, Wort in der URL
                (/chronik?q=…), Trefferzahl in der Ueberschrift, "Filter aufheben"
                im Leer-Zustand
  WissenView    hatte das Suchfeld schon, aber nur lokal — jetzt aus /wissen?suche=
                gespeist und damit teilbar

## Verifiziert im Browser

  Strg+K oeffnet, "Gehe zu" (11) + "Tun" (5); leere Gruppen bleiben verborgen
  Text tippen -> "Finden" (2) und "An Lucy" (1) erscheinen
  "Motor neu starten" traegt den Chip "fragt nach"; Auswahl zeigt die Rueckfrage
    mit vollem Folgentext — es wird NICHTS gestartet
  "Abbrechen" fuehrt zurueck in die Liste, keine Meldung, kein Aufruf

54 Tests gruen (10 neue) · ESLint 0 Fehler · Einstieg 118 762 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:56:26 +02:00
HitonabiandClaude Opus 5 a494d320d7 feat(agent): P6 — Werkzeug-Verlauf, und der Blocker war nur eine halbe Wand
Der Blueprint sah eine "Live Agent Matrix" mit Denkstrom vor (§4.4). In P4 hatte ich
sie als blockiert gemeldet: Lucys Denkschritte entstehen im Hermes-Prozess, und
AGENTS.md verbietet es, dessen Quellcode zu patchen.

Beim Nachsehen zeigte sich, dass die HAELFTE davon offen daliegt.
`~/.hermes/logs/agent.log` protokolliert JEDEN Werkzeug-Ruf:

  INFO [cron_195e…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
  WARNING [cron_195e…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}

Eine Log-Datei zu LESEN ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug,
wie lange, mit welchem Ergebnis, in welchem Lauf — ist fuer "was tut sie gerade und wo
haengt es" oft nuetzlicher als der Fliesstext ihrer Gedanken.

## Es hat sich beim ersten Blick bezahlt gemacht

Der Parser lief gegen den echten Box-Log und meldete sofort:

  web_extract      2 Rufe   2 Fehler   <-- IMMER ROT
      DuckDuckGo (ddgs) is a search-only backend and cannot extract URL content.
  web_search      20 Rufe   0 Fehler   Schnitt 1,77 s

Nachgesehen: `web_extract` scheitert seit MINDESTENS dem 25.08. jeden Morgen um 07:00
mit derselben Meldung — im Daily-News-Cron, vier Tage lang, ohne dass es irgendwo
aufgefallen waere. Genau dafuer steht die Bilanz OBEN und die Zeitleiste darunter:
Ein Dauerfehler verschwindet in einer Ereignisliste, in der Zeile
"web_extract · 2 Rufe · 2 Fehler" nicht.

## Was die Ansicht NICHT verspricht

Denkstrom und Werkzeug-Argumente stehen nicht im Log und tauchen darum auch nicht auf.
Das steht so in der Ansicht selbst, nicht nur im Code — eine Oberflaeche, die mehr
andeutet als sie hat, ist schlimmer als eine, die ihre Grenze nennt.

## Umsetzung

  services/agent_aktivitaet.py   liest nur die letzten 512 kB (die Datei waechst auf
                                 MB und wird rotiert), vier gemessene Zeilenformen,
                                 Bilanz je Werkzeug + Gruppierung je Lauf
  GET /api/agent/aktivitaet      limit gedeckelt auf 300
  features/agent/Werkzeugverlauf.tsx   Bilanz -> Laeufe -> aufklappbare Zeitleiste

Fehlt das Log (Entwicklungsrechner ohne Hermes), verschwindet der Abschnitt still,
statt eine leere Karte zu zeigen — wie der Rest der Seite es haelt.

## Verifiziert

  Parser gegen den echten Box-Log: 26 Rufe, 4 Werkzeuge, 2 Laeufe erkannt,
    Rauschen (mem_trim, aiohttp) ignoriert
  Im Browser gegen dieselben Daten: "IMMER ROT"-Markierung sitzt, Grund im Klartext,
    Laufgruppen mit Zeitspanne, 26 Einzelrufe in der Zeitleiste
  7 neue Tests (44 gesamt) — sie pruefen gezielt die BILANZ-Karte, nicht irgendein
    Vorkommen des Werkzeugnamens; der steht auch in der Zeitleiste

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:51:14 +02:00
HitonabiandClaude Opus 5 7957cda438 fix(sentry): Hermes-Probe schickt ihren Schluessel mit — 720 Fehler/Tag weniger
Beim Untersuchen von Hermes' Log gefunden: MC2s eigene Erreichbarkeits-Probe rief
`GET /v1/models` ohne API-Schluessel und produzierte damit alle zwei Minuten ein
`API server rejected invalid API key` in ~/.hermes/logs/agent.log.

  Gemessen: 30 Vorfaelle/Stunde, seit dem 27.08. 15:05 durchgehend — 558 Eintraege
  im aktuellen Log, ~720/Tag.

Das URTEIL war nie falsch: `_reach` prueft `status_code < 500`, und ein 401 beweist
ja, dass jemand zuhoert. Falsch war der Laerm — und dass ein echter Auth-Fehler darin
untergegangen waere.

MC2 hat den Schluessel laengst in der Config (HERMES_API_KEY, aus ~/.hermes/.env).
Die Probe schickt ihn jetzt als Bearer mit. Die Nachsichtigkeit von `< 500` bleibt
absichtlich: Die Frage ist "laeuft der Dienst", nicht "darf ich rein".

Auf der Box gegengeprueft:
  ohne Schluessel: 401
  mit Schluessel:  200

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:44:45 +02:00
73 changed files with 2282 additions and 695 deletions
+22 -4
View File
@@ -18,18 +18,36 @@
{ {
"name": "frontend", "name": "frontend",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"], "runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5180",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "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, "autoPort": false,
"port": 5180 "port": 5180
}, },
{ {
"name": "frontend-mock", "name": "frontend-mock",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"], "runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5181",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "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, "autoPort": false,
"port": 5181 "port": 5181
} }
+13 -1
View File
@@ -65,7 +65,19 @@ jobs:
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre # FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten). # über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then 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 budget_dist=3145728 # Stand nach P0: 1 477 852 B
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten: # Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein # Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
+5
View File
@@ -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 # Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
# passen. MC2 proxyt nach außen. # passen. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/") 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 # 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 # (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 # rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
+11
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import agent_aktivitaet
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
def set_brain_model(body: BrainReq) -> dict: def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model) ok = update_brain_model(body.model)
return {"ok": ok} 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)))
+69 -1
View File
@@ -17,7 +17,7 @@ import sys as _sys
import time import time
import httpx 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 import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch) _commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream") 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")})
+176
View File
@@ -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}
+16 -4
View File
@@ -20,7 +20,7 @@ import time
import httpx import httpx
import psutil 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 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")) 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: try:
with httpx.Client(timeout=5.0) as c: 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: except Exception:
return False 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: def _check_engine() -> bool:
return llamaswap.engine_reachable() return llamaswap.engine_reachable()
@@ -70,7 +82,7 @@ CHECKS: dict[str, tuple] = {
"brain": (_check_brain, "brain": (_check_brain,
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.", "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."), "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 ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."), "Der Agent-Dienst läuft wieder."),
"voice": (lambda: _reach(VOICE_SERVICE_URL), "voice": (lambda: _reach(VOICE_SERVICE_URL),
+7 -1
View File
@@ -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 case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null) 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) 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" elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
done done
+24
View File
@@ -42,6 +42,27 @@ if [ -z "$IST" ]; then
exit 1 exit 1
fi 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)" ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)" ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
@@ -61,6 +82,9 @@ Regeln:
Fuellmaterial wird nicht gelesen. Fuellmaterial wird nicht gelesen.
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG, - Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
nicht unter Verbesserungen. 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. - Wenn alles gruen ist, sag das in einem Satz und hoere auf.
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter. - Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
+201
View File
@@ -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())
+6 -3
View File
@@ -25,11 +25,14 @@ models:
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear), # 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. # 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. # 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: | 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 ttl: 5400
aliases: aliases:
- coder - coder
- heavy
capabilities: capabilities:
in: [text, image] in: [text, image]
out: [text] out: [text]
@@ -62,8 +65,8 @@ models:
cmd: | 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 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 ttl: 600
aliases: # 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
- heavy # Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
capabilities: capabilities:
in: [text] in: [text]
out: [text] out: [text]
+1 -1
View File
@@ -18,7 +18,7 @@
} }
}, },
"heavy": { "heavy": {
"name": "heavy - Planen / Review", "name": "heavy - Planen / Review (= Qwen3.8-27B, seit 04.09. mit DFlash2)",
"tool_call": true, "tool_call": true,
"limit": { "limit": {
"context": 32768, "context": 32768,
+45 -23
View File
@@ -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": [ "eintraege": [
{ {
"key": "mmq-prefill-gfx1151", "key": "qwen-modelle",
"typ": "github_issue", "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", "repo": "ggml-org/llama.cpp",
"nummer": 21284, "nummer": 28243,
"bedingung": "Issue geschlossen = MMQ-Tuning für gfx1151 vermutlich gemerged", "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)."
"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)." },
{
"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", "key": "rocm-releases",
"typ": "github_release", "typ": "github_release",
"repo": "ROCm/ROCm", "repo": "ROCm/ROCm",
"bedingung": "Neuer ROCm-Release seit letzter Meldung", "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)."
"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." },
{
"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", "key": "electron-major",
"typ": "github_release_major", "typ": "github_release_major",
"repo": "electron/electron", "repo": "electron/electron",
"bedingung": "Neue MAJOR-Version seit letzter Meldung", "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."
"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."
}, },
{ {
"key": "pocket-tts", "key": "pocket-tts",
"typ": "pypi", "typ": "pypi",
"paket": "pocket-tts", "paket": "pocket-tts",
"bedingung": "Neue Version auf PyPI", "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.)."
"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.)."
}, },
{ {
"key": "lmstudio-bionic", "key": "lmstudio-bionic",
"typ": "github_issue", "typ": "github_issue",
"repo": "lmstudio-ai/lmstudio-bug-tracker", "repo": "lmstudio-ai/lmstudio-bug-tracker",
"nummer": 2185, "nummer": 2185,
"bedingung": "Issue geschlossen = Bionic-Linux-Port vermutlich da", "warum": "LM Studio Bionic: erst interessant bei Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI. Bis dahin bleibt die Box :9001 draussen."
"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."
} }
] ]
} }
+18
View File
@@ -62,6 +62,24 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
## Lucy ## 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; - **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. 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, Danach: Lexikon-Injektion in kokoro_server + Modell-Tausch. **pocket bleibt Default,
+151
View File
@@ -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).
+1
View File
@@ -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 | | [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 | | [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 | | [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?" | | [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), Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Regeln),
+1 -1
View File
@@ -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) | | `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) | | `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) | | `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`) | | `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 | | `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
+5
View File
@@ -32,6 +32,11 @@ planen, **jede Session hinterlässt einen sauberen Stand**.
Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys
Lernschicht. 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) ## Recherche-Fundament (10.07., Web)
Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
@@ -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};
File diff suppressed because one or more lines are too long
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
-6
View File
@@ -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};
+1
View File
@@ -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,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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c}from"./index-C18iiJ44.js";/** import{c}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+11
View File
@@ -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,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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+6
View File
@@ -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
View File
@@ -1 +0,0 @@
import{ax as r}from"./index-C18iiJ44.js";var o=r();export{o as r};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{aA as r}from"./index-l6kyKCL9.js";var o=r();export{o as r};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c}from"./index-C18iiJ44.js";/** import{c}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-C18iiJ44.js"></script> <script type="module" crossorigin src="/assets/index-l6kyKCL9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQUMn05v.css"> <link rel="stylesheet" crossorigin href="/assets/index-d4K0juid.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+102 -133
View File
@@ -17,8 +17,8 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"react": "^18.3.1", "react": "^19.2.8",
"react-dom": "^18.3.1", "react-dom": "^19.2.8",
"react-force-graph-2d": "^1.29.1", "react-force-graph-2d": "^1.29.1",
"recharts": "^3.9.0", "recharts": "^3.9.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
@@ -32,8 +32,8 @@
"@testing-library/user-event": "^14.6.6", "@testing-library/user-event": "^14.6.6",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/react": "^18.3.12", "@types/react": "^19.2.18",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -1285,15 +1285,15 @@
} }
}, },
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.4", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1306,9 +1306,9 @@
} }
}, },
"node_modules/@radix-ui/react-context": { "node_modules/@radix-ui/react-context": {
"version": "1.1.4", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1321,23 +1321,24 @@
} }
}, },
"node_modules/@radix-ui/react-dialog": { "node_modules/@radix-ui/react-dialog": {
"version": "1.1.17", "version": "1.1.23",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
"integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.4", "@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.1.4", "@radix-ui/react-context": "1.2.2",
"@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-dismissable-layer": "1.1.19",
"@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-guards": "1.1.6",
"@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-focus-scope": "1.1.16",
"@radix-ui/react-id": "1.1.2", "@radix-ui/react-id": "1.1.4",
"@radix-ui/react-portal": "1.1.12", "@radix-ui/react-portal": "1.1.17",
"@radix-ui/react-presence": "1.1.6", "@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-slot": "1.3.0", "@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4", "aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2" "react-remove-scroll": "^2.7.2"
}, },
@@ -1357,16 +1358,16 @@
} }
}, },
"node_modules/@radix-ui/react-dismissable-layer": { "node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.13", "version": "1.1.19",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
"integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.4", "@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-escape-keydown": "1.1.2" "@radix-ui/react-use-effect-event": "0.0.5"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1384,9 +1385,9 @@
} }
}, },
"node_modules/@radix-ui/react-focus-guards": { "node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.4", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1399,14 +1400,14 @@
} }
}, },
"node_modules/@radix-ui/react-focus-scope": { "node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.10", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
"integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.2" "@radix-ui/react-use-callback-ref": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1424,12 +1425,12 @@
} }
}, },
"node_modules/@radix-ui/react-id": { "node_modules/@radix-ui/react-id": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2" "@radix-ui/react-use-layout-effect": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1442,13 +1443,13 @@
} }
}, },
"node_modules/@radix-ui/react-portal": { "node_modules/@radix-ui/react-portal": {
"version": "1.1.12", "version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
"integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-layout-effect": "1.1.2" "@radix-ui/react-use-layout-effect": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1466,12 +1467,12 @@
} }
}, },
"node_modules/@radix-ui/react-presence": { "node_modules/@radix-ui/react-presence": {
"version": "1.1.6", "version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2" "@radix-ui/react-use-layout-effect": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1489,12 +1490,12 @@
} }
}, },
"node_modules/@radix-ui/react-primitive": { "node_modules/@radix-ui/react-primitive": {
"version": "2.1.6", "version": "2.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "1.3.0" "@radix-ui/react-slot": "1.3.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1512,12 +1513,12 @@
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.3.0", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.3" "@radix-ui/react-compose-refs": "1.1.5"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1530,9 +1531,9 @@
} }
}, },
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1545,13 +1546,14 @@
} }
}, },
"node_modules/@radix-ui/react-use-controllable-state": { "node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/primitive": "1.1.7",
"@radix-ui/react-use-layout-effect": "1.1.2" "@radix-ui/react-use-effect-event": "0.0.5",
"@radix-ui/react-use-layout-effect": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1564,30 +1566,12 @@
} }
}, },
"node_modules/@radix-ui/react-use-effect-event": { "node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2" "@radix-ui/react-use-layout-effect": "1.1.4"
},
"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"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1600,9 +1584,9 @@
} }
}, },
"node_modules/@radix-ui/react-use-layout-effect": { "node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -2728,32 +2712,24 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "18.3.31", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.7", "version": "19.2.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
@@ -5351,28 +5327,24 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT", "license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "scheduler": "^0.27.0"
"scheduler": "^0.23.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^19.2.8"
} }
}, },
"node_modules/react-force-graph-2d": { "node_modules/react-force-graph-2d": {
@@ -5650,13 +5622,10 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT", "license": "MIT"
"dependencies": {
"loose-envify": "^1.1.0"
}
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
+4 -4
View File
@@ -22,8 +22,8 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"react": "^18.3.1", "react": "^19.2.8",
"react-dom": "^18.3.1", "react-dom": "^19.2.8",
"react-force-graph-2d": "^1.29.1", "react-force-graph-2d": "^1.29.1",
"recharts": "^3.9.0", "recharts": "^3.9.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
@@ -37,8 +37,8 @@
"@testing-library/user-event": "^14.6.6", "@testing-library/user-event": "^14.6.6",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/react": "^18.3.12", "@types/react": "^19.2.18",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
+104
View File
@@ -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/)
})
})
+194
View File
@@ -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(" ")
+21 -4
View File
@@ -101,7 +101,20 @@ function CockpitSeite() {
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />) const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />) const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />)
const skillsRoute = ansicht("/skills", () => <SkillsView />) 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 verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />) const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
const anleitungRoute = ansicht("/anleitung", () => <GuideView />) 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 { export interface WissenSuche {
datei?: string datei?: string
suche?: string
} }
const wissenRoute = createRoute({ const wissenRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/wissen", path: "/wissen",
validateSearch: (roh: Record<string, unknown>): WissenSuche => validateSearch: (roh: Record<string, unknown>): WissenSuche => ({
typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {}, ...(typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {}),
...(typeof roh.suche === "string" && roh.suche ? { suche: roh.suche } : {}),
}),
component: () => <WissenView />, component: () => <WissenView />,
errorComponent: ({ error, reset }) => ( errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} /> <RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
+128 -40
View File
@@ -1,72 +1,160 @@
import { useEffect, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { Command } from "cmdk" import { Command } from "cmdk"
import { useNavigate } from "@tanstack/react-router" 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() { 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() const navigate = useNavigate()
useEffect(() => { const [text, setText] = useState("")
const onKey = (e: KeyboardEvent) => { const [nachfrage, setNachfrage] = useState<Befehl | null>(null)
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { const [laeuft, setLaeuft] = useState(false)
e.preventDefault()
setOpen((o) => !o)
}
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [])
// Der „Suchen"-Knopf in der Kopfzeile (v3-Umbau P2, Befund B-09). const schliessen = useCallback(() => {
// Er baute vorher ein GEFÄLSCHTES Tastatur-Ereignis (`new KeyboardEvent("keydown", palette(false)
// { metaKey: true })`) und feuerte es aufs Dokument, damit der Zuhörer oben anspringt. setText("")
// Jetzt ein eigener, ehrlicher Kanal. In P3 löst der Store auch den ab. setNachfrage(null)
useEffect(() => { }, [palette])
const onOpen = () => setOpen(true)
window.addEventListener("mc-palette-open", onOpen) const ausfuehren = useCallback(async (b: Befehl) => {
return () => window.removeEventListener("mc-palette-open", onOpen) 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 ( return (
<Command.Dialog <Command.Dialog
open={open} open={offen}
onOpenChange={setOpen} onOpenChange={(o) => (o ? palette(true) : schliessen())}
label="Befehlspalette" label="Befehlspalette"
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[12vh] overscroll-contain" className="fixed inset-0 z-50 flex items-start justify-center overscroll-contain bg-black/50 p-4 pt-[12vh]"
onClick={() => setOpen(false)} onClick={schliessen}
shouldFilter={!nachfrage}
> >
<div <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()} onClick={(e) => e.stopPropagation()}
> >
{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"
>
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 <Command.Input
autoFocus autoFocus
placeholder="Springe zu… (Modelle, Routing, System …)" 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" 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.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"> <Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
Nichts gefunden. Nichts gefunden.
</Command.Empty> </Command.Empty>
<Command.Group heading="Bereiche" className="px-1 py-1 text-xs text-muted-foreground">
{NAV.map((item) => ( {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 <Command.Item
key={item.id} key={b.id}
value={`${item.label} ${item.hint}`} value={suchtext(b)}
onSelect={() => { onSelect={() => (b.rueckfrage ? setNachfrage(b) : ausfuehren(b))}
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" 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"
> >
<item.icon className="h-4 w-4 text-primary" /> <b.icon
<span>{item.label}</span> className={cn("h-4 w-4 shrink-0", b.rueckfrage ? "text-amber-400" : "text-primary")}
<span className="ml-auto truncate text-xs text-muted-foreground">{item.hint}</span> 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.Item>
))} ))}
</Command.Group> </Command.Group>
)
})}
</Command.List> </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> </div>
</Command.Dialog> </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:0507: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>
)
}
+34
View File
@@ -397,6 +397,40 @@ export interface Job {
eta_s?: number 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 { export interface Health {
status: string status: string
version: string version: string
+11
View File
@@ -7,6 +7,7 @@ import { sseVerbunden } from "./events"
import { import {
api, api,
type AgentStatus, type AgentStatus,
type AktivitaetResp,
type AuftragsbuchResp, type AuftragsbuchResp,
type ChronikResp, type ChronikResp,
type ConnectResp, type ConnectResp,
@@ -46,6 +47,7 @@ export const qk = {
jobs: ["jobs"] as const, jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const, tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const, agentStatus: ["agent-status"] as const,
agentAktivitaet: ["agent-aktivitaet"] as const,
hermesBrain: ["hermes-brain"] as const, hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const, updates: ["updates"] as const,
discover: ["discover"] as const, discover: ["discover"] as const,
@@ -221,6 +223,15 @@ export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
export const useAgentStatus = (refetchInterval: number = TAKT.normal) => export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval }) 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). // Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval: number = TAKT.traege) => export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval }) useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
+4
View File
@@ -1,6 +1,7 @@
import { useState } from "react" import { useState } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react" import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { Werkzeugverlauf } from "@/features/agent/Werkzeugverlauf"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries" import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
@@ -340,6 +341,9 @@ export function AgentView() {
</div> </div>
)} )}
{/* Werkzeug-Verlauf (v3-Umbau P6): was Lucy tatsaechlich getan hat, aus ihrem Log. */}
<Werkzeugverlauf />
{dialogElement} {dialogElement}
</div> </div>
) )
+40 -6
View File
@@ -1,9 +1,8 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { import { AlarmClock, Archive, Bell, HeartPulse, History, Inbox, Loader2, MessageCircle, Moon, Radar, RotateCcw, Search, ShieldAlert, Sparkles, type LucideIcon } from "lucide-react"
History, HeartPulse, ShieldAlert, Bell, Moon, Radar, Inbox, Loader2,
Archive, RotateCcw, AlarmClock, MessageCircle, Sparkles, type LucideIcon,
} from "lucide-react"
import { api, type ChronikItem, type Snapshot } from "@/lib/api" 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 { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -30,9 +29,25 @@ function dayKey(ts: number): string {
} }
export function ChronikView() { export function ChronikView() {
const { data: items = [], isLoading } = useChronik() const { data: alle = [], isLoading } = useChronik()
const [limitPerDay, setLimitPerDay] = useState(true) 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 grouped = useMemo(() => {
const g: { day: string; items: ChronikItem[] }[] = [] const g: { day: string; items: ChronikItem[] }[] = []
for (const it of items) { for (const it of items) {
@@ -58,9 +73,26 @@ export function ChronikView() {
<Zeitmaschine /> <Zeitmaschine />
<section className="space-y-4"> <section className="space-y-4">
<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"> <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 <History className="h-3.5 w-3.5" /> Verlauf
{q && <span className="normal-case text-primary">· {items.length} von {alle.length}</span>}
</p> </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 && ( {isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground"> <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 && ( {!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"> <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> </div>
)} )}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react" import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
import { api, type WissenDatei, type WissenFile } from "@/lib/api" import { api, type WissenDatei, type WissenFile } from "@/lib/api"
import { useWissen, useMemoryGraph } from "@/lib/queries" import { useWissen, useMemoryGraph } from "@/lib/queries"
@@ -24,7 +25,14 @@ export function WissenView() {
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [doc, setDoc] = useState<WissenDatei | null>(null) const [doc, setDoc] = useState<WissenDatei | null>(null)
const [docLoading, setDocLoading] = useState(false) 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 [showGraph, setShowGraph] = useState(false)
const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph) const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph)