Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3650bb0e4 | ||
|
|
514ba44e51 | ||
|
|
617dddf3d8 | ||
|
|
88faf57253 | ||
|
|
41714f7cf1 | ||
|
|
93401d42d1 | ||
|
|
c4e658446d | ||
|
|
3693157210 | ||
|
|
64670cdd12 | ||
|
|
c82616ebbd | ||
|
|
b226e1da11 | ||
|
|
0b7ed241b4 | ||
|
|
a2b7e3a48d | ||
|
|
7b72149671 | ||
|
|
e9439dbccb | ||
|
|
219de091d2 | ||
|
|
5992a7e441 | ||
|
|
07ccf05429 | ||
|
|
4ab0e68245 | ||
|
|
9636a702d3 | ||
|
|
5f09e2bca3 | ||
|
|
ebe0e8065f | ||
|
|
9d2c07d8d1 | ||
|
|
9aae5af6a9 | ||
|
|
a8ff73f93d | ||
|
|
0e9adeee5c | ||
|
|
6addb2f8fe | ||
|
|
9bdfb7193e | ||
|
|
9e78d75149 | ||
|
|
517c993ac8 | ||
|
|
c1bd13687d | ||
|
|
d9bb7239c6 | ||
|
|
0265aabb5e | ||
|
|
17bc50b847 | ||
|
|
1f2be4299d | ||
|
|
0ca8a82013 | ||
|
|
7bc3f827d0 | ||
|
|
e7da9cf9c4 | ||
|
|
353fd98d3f | ||
|
|
0350dd33c8 | ||
|
|
7b956c6606 | ||
|
|
36f04f83ff | ||
|
|
01df26e6df | ||
|
|
f226c91973 | ||
|
|
3324d39d50 | ||
|
|
fff2e7df34 | ||
|
|
0aac114142 | ||
|
|
ba60f793fb | ||
|
|
a7c2f07361 | ||
|
|
ccf6dd84fb | ||
|
|
75675ed3aa | ||
|
|
73fee27e90 | ||
|
|
03021a6787 | ||
|
|
b6a5d7029f | ||
|
|
3a8202d2de | ||
|
|
7bbb75481c | ||
|
|
ee6c4f34db | ||
|
|
75daadf72d | ||
|
|
37aaa90239 | ||
|
|
03e6d784b6 | ||
|
|
762f1a2dd9 | ||
|
|
49f6b26ab8 | ||
|
|
c340b9d683 | ||
|
|
a0429fc91e | ||
|
|
174d6d643d | ||
|
|
fd6ba73f59 | ||
|
|
f5b22253b2 | ||
|
|
761f4c8903 | ||
|
|
091a1b7755 | ||
|
|
e2b1eced3c | ||
|
|
fe804fa40e | ||
|
|
18eb94e942 | ||
|
|
74c7ea0a2d | ||
|
|
648e3b04fd | ||
|
|
2ad8c2f245 | ||
|
|
85d190e98c | ||
|
|
70705269fd | ||
|
|
2aef0347ae | ||
|
|
a49c022308 | ||
|
|
2d3ba024a4 | ||
|
|
1c7157327c | ||
|
|
6c27097c96 | ||
|
|
a91325a04f | ||
|
|
ef826d1ed1 | ||
|
|
933836f0a6 | ||
|
|
e04d8f360b | ||
|
|
4685632294 | ||
|
|
769025c41b | ||
|
|
2005e9b85e | ||
|
|
25abd220ad | ||
|
|
3c6bf0783e | ||
|
|
2832ab3dc9 | ||
|
|
d38d62ba21 | ||
|
|
5890c17ec0 | ||
|
|
239f1094f9 | ||
|
|
055db7c059 | ||
|
|
2a8cbc6c15 | ||
|
|
7e14107361 | ||
|
|
e64043dca5 | ||
|
|
0efb8d0848 | ||
|
|
5fa5d79ad8 | ||
|
|
9dd7a2cb14 | ||
|
|
2811379684 | ||
|
|
902316566e | ||
|
|
1f94b4eab5 | ||
|
|
57e13800e0 | ||
|
|
1dc8c0936f | ||
|
|
071e38f464 | ||
|
|
28361ea97a | ||
|
|
a20e57e33a | ||
|
|
dc775ee34f | ||
|
|
c2122c43c2 | ||
|
|
f45d42cd30 | ||
|
|
0110526622 | ||
|
|
7d46d8e531 | ||
|
|
b839b059d0 | ||
|
|
9950cdea87 | ||
|
|
7329257830 | ||
|
|
bbfd544013 | ||
|
|
aadc030407 | ||
|
|
85756a161b | ||
|
|
ea3de70d05 | ||
|
|
0b35ea9bde | ||
|
|
a082c8398e | ||
|
|
a6cb152f55 | ||
|
|
85363b1014 | ||
|
|
20c527c8ed | ||
|
|
80b534cbab | ||
|
|
747c67766c | ||
|
|
c92e042e91 | ||
|
|
019b17ff97 | ||
|
|
55938c3173 | ||
|
|
761217cb5a | ||
|
|
ff6d31acbd | ||
|
|
2d7a5784c2 | ||
|
|
fa871219ae | ||
|
|
e61a0ff871 | ||
|
|
c9a1d80696 | ||
|
|
8a0670a3d2 | ||
|
|
2d9c42a7ea | ||
|
|
ff1205eb6b | ||
|
|
d12f67320b | ||
|
|
24a1b4d837 | ||
|
|
db96258f7d | ||
|
|
becc83d9e3 | ||
|
|
44220edbd1 | ||
|
|
fc13957000 | ||
|
|
60580fd67e | ||
|
|
2d83e4ad8b | ||
|
|
629a3d82ac | ||
|
|
9bb90a777a | ||
|
|
c3bb7985a2 | ||
|
|
debd96b16f | ||
|
|
486d7dedbb | ||
|
|
d5bcbb4814 | ||
|
|
54ebd57990 | ||
|
|
fb06ed928c | ||
|
|
08bb257791 | ||
|
|
44982a9b3c | ||
|
|
ddb1bfac6a | ||
|
|
4605698b02 | ||
|
|
1c11cb6a2f | ||
|
|
6cf75644d8 | ||
|
|
2c1de6705b | ||
|
|
2716bc62ff | ||
|
|
a9e1a46a2f | ||
|
|
245dfc4d73 | ||
|
|
6a94b574f2 | ||
|
|
a5e2256a44 | ||
|
|
9fb29aa517 |
+198
@@ -10,6 +10,204 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
||||
|
||||
---
|
||||
|
||||
## [0.2.3.0] — 2026-07-19 — Satelliten: ARIAs Augen & Hände in fremden Netzen 🛰️
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Neuer eigenständiger Container `satellite/`** — ein Außenposten, den du in einem beliebigen Netz (Büro, Werkstatt …) deployst. Er verbindet sich als RVS-Client in deinen Raum und gibt ARIA Zugriff auf **genau dieses Netz**, ohne dass der Haupt-Stack dort steht.
|
||||
- **Entdeckung (Info):** mDNS/Zeroconf (Chromecast, AirPlay, Sonos, Drucker, NAS …), SSDP/UPnP + **DIAL** (Smart-TVs, Fire TV), ARP-Tabelle (rohe Hosts) → Live-Inventar.
|
||||
- **Steuerung (mit Guards):** **DIAL-App-Launch** (z.B. „ARIA, spiel YouTube-Video X auf dem Büro-Stick" → Fire TV), **Wake-on-LAN**, generisches **HTTP**. Nur wenn `CONTROL_ENABLED=true`, nur Aktionen aus der `CONTROL_ALLOWLIST`, alles geloggt, read-only per `.env` abschaltbar. Reagiert nur auf den eigenen RVS-Raum (Token). Keine offenen Ports.
|
||||
- **Adressierung** über `SATELLITE_LOCATION` (z.B. „Büro") — mehrere Satelliten im selben Raum, jeder mit eigenem Namen.
|
||||
|
||||
**End-to-end verdrahtet:**
|
||||
- `satellite/`: eigener Stack (`docker compose` mit `network_mode: host`), `.env.example`, README.
|
||||
- RVS: neue Message-Typen `sat_hello / sat_discover / sat_devices / sat_command / sat_result`.
|
||||
- Bridge: Satelliten-Registry (`sat_hello`) + Future-Relay (`/internal/satellite`, `/internal/satellite-list`) analog zum flux-Muster.
|
||||
- Brain: Tools `satellite_list`, `satellite_devices`, `satellite_command` + Seed-Regel, die ARIA den Ablauf beibringt (erst list, dann devices, dann command).
|
||||
- **Diagnostic: neuer Tab „Satelliten"** — zeigt live welche Satelliten verbunden sind (online/offline, Standort, Capabilities, read-only vs. steuerbar) und pro Satellit ein „Geräte scannen" (löst `sat_discover` aus → erkannte Geräte mit Typ/IP/Modell/DIAL/MAC).
|
||||
|
||||
### Deploy
|
||||
Satellit im Ziel-Netz: `cd satellite && cp .env.example .env && docker compose up -d --build`. Haupt-Stack: `git pull && docker compose up -d --build brain bridge` + RVS-Stack `up -d --build`. Kein APK-Rebuild.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **`project_summary` fand fast nie Inhalte.** Es las nur ARIAs rollendes Kontextfenster (~50 Turns über ALLE Projekte) — ältere/andere Projekt-Chats sind da längst rausdistilliert. „Hol dir die Infos aus Projekt X" lieferte deshalb leere Ergebnisse, und ARIA wusste nicht, wie sie an die Historie kommt. Jetzt liest das Tool die **echte, volle Historie aus `chat_backup.jsonl`** (im Brain gemountet) — die letzten ~20 Turns des Zielprojekts, unabhängig davon wie lange man da nicht war. Standort-Hints in User-Turns werden rausgefiltert. Fallback aufs Fenster, falls das Backup fehlt. Tool-Beschreibung geschärft, damit ARIA es direkt aufruft.
|
||||
- Nebenbei bereinigt: `mac_os_update_fehler` war durch den alten `set_project_kind`-Bug (0.2.2.1) fälschlich `kind=code` — auf `chat` zurückgesetzt.
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain` (kein APK-Rebuild nötig).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.2] — 2026-07-17 — Voice-Projektwechsel zurück — aber gerätelokal
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **„ARIA, geh in Projekt X" per Sprache wechselt wieder das Projekt** — aber **nur auf dem Gerät, das den Befehl gab**. Andere App-Instanzen und das Diagnostic bleiben in ihrem Projekt.
|
||||
- Umsetzung: Jedes Gerät merkt sich die IDs seiner eigenen Anfragen (Text-`clientMsgId` + Voice-`audioRequestId`). Der Brain/Voice-Router hängt an jedes `project_changed`-Event die auslösende `clientMsgId` an; die App folgt dem Wechsel nur, wenn die ID eine **eigene** ist. Deckt beide Voice-Pfade ab (ARIAs `project_enter`/`exit` via `send_to_core` **und** den Bridge-Voice-Router „für Projekt X: …" / „zurück zum Hauptchat").
|
||||
- Manuelles Umschalten bleibt wie gehabt gerätelokal über den Drawer.
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build bridge` + APK 0.2.2.2 (kein Brain-Rebuild nötig).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.1] — 2026-07-17 — Projekt-Fokus wirklich pro Gerät unabhängig
|
||||
|
||||
### Behoben
|
||||
|
||||
- **`set_project_kind` markierte das falsche Projekt.** Es nutzte den **globalen** `active_project` (geräteübergreifend) statt des Projekts, in dem der aktuelle Request läuft. Folge: Obwohl auf der App „Basic os" aktiv war, bekam das global-aktive „Mac OS Update Fehler" `kind=code`. Jetzt wirkt das Tool auf die pro Request mitgesendete `project_id` (in `_dispatch_tool` durchgereicht) — jede App-/Diagnostic-Instanz markiert ihr eigenes Projekt.
|
||||
- **App wurde von fremden `project_changed`-Broadcasts ins andere Projekt gezogen.** Ein Projektwechsel/‑anlegen durch ARIA, das Diagnostic oder eine andere App-Instanz erzwang auf **allen** Geräten einen Fokuswechsel. Der Projekt-Fokus ist jetzt **rein gerätelokal**: Broadcasts aktualisieren nur Namen + Typ, wechseln aber nicht mehr das Projekt. Umschalten geht ausschließlich lokal über den Projekt-Drawer. So arbeitet jedes Gerät unabhängig in seinem eigenen Projekt (auch mehrere App-Instanzen).
|
||||
- Hinweis: „ARIA, geh in Projekt X" per Sprache wechselt das App-Projekt dadurch **nicht mehr** automatisch — bewusst, damit Geräte unabhängig bleiben. Wechseln über den Drawer.
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain` + APK 0.2.2.1 neu bauen.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole
|
||||
|
||||
### Geändert
|
||||
|
||||
**Cockpit-Navigation neu gedacht — Dock statt Pinch-Canvas**
|
||||
- Die Zoom-Landkarte (mit 2 Fingern rauszoomen, Kachel finden, reintippen) fühlte sich auf 5 Zoll fummelig an. Ersetzt durch eine **Taskleiste unten im Daumenbereich** (Chat · Code · Desktop): **ein Tap wechselt sofort** das Panel, ein animierter Indikator gleitet unter das aktive Icon. Desktop-*Umfang*, Handy-*Ergonomie*.
|
||||
- Jedes Panel ist **bildschirmfüllend** und Handy-optimiert; alle bleiben gemountet (nur das aktive ist sichtbar) → kein Remount, Chat behält RVS/Audio/Queue, WebViews ihren Zustand.
|
||||
- Das Dock **blendet bei offener Tastatur aus** (mehr Platz zum Tippen) und respektiert die Gesten-Navigationsleiste (Safe-Area).
|
||||
- **Aktivitäts-Punkte** am Dock: Code blau, sobald Dateien da sind; Desktop grün, wenn die VM verbunden ist.
|
||||
- Entfällt: Pinch/Pan-Canvas, Übersichts-Button im Header, die „Landkarten"-Thumbnails.
|
||||
|
||||
**noVNC-Konsole endlich bedienbar**
|
||||
- Steuerungs-Leiste im Desktop-Panel: **⌨ Tastatur** (blendet die Handy-Tastatur ein, tippt direkt in die VM — inkl. Enter/Backspace/Pfeiltasten), **Strg+Alt+Entf**, und **⤢ Fit ↔ 1:1**. Tippen/Ziehen steuert weiterhin die Maus.
|
||||
|
||||
### Deploy
|
||||
Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Ansichts-Umschalter im Header** („⧉ Kompakt" / „⧉ Cockpit"): Die App startet in **Kompakt** — der klassische Vollbild-Chat, **exakt wie vor dem Umbau** (Default, Mama-tauglich). Ein Tap auf den Button oben rechts schaltet auf **Cockpit** — den zoom-/verschiebbaren Kachel-Desktop. Persistiert über Neustart (`aria_view_mode`).
|
||||
- **Warum:** Nach dem 0.2.1.8-Deploy sah die App „unverändert" aus — korrekt, denn im normalen Chat gibt es nur eine Kachel (= Vollbild-Chat). Der Umschalter macht den Cockpit-Modus jetzt **explizit sichtbar/steuerbar**, statt nur bei Code-Projekten aufzutauchen.
|
||||
- Im Cockpit ist die **Übersicht jetzt immer erreichbar** (auch im Hauptchat): „⤢ Übersicht" sitzt **im Header links** (kollidiert nicht mehr mit dem Abbrechen-Button der „ARIA denkt"-Leiste), dazu 2-Finger-Pinch/Pan und Hardware-Back.
|
||||
- Im Cockpit werden **immer alle vier Kacheln** gezeigt (Chat/Editor/Desktop/Vorschau) — Editor/Desktop/Vorschau als Platzhalter mit Status-Untertitel („kein Code-Projekt" / „kein Desktop"), bis ARIA ein Code-Projekt startet bzw. eine VM läuft. Vorher wirkten sie „verschwunden", weil sie erst bei Code-Projekten auftauchten.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.8] — 2026-07-17 — Desktop-Workspace: zoombarer Canvas, Live-Code-Editor, QEMU/VNC
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Zoom-/verschiebbarer Workspace-Canvas (App)**
|
||||
- Die App ist jetzt eine desktop-artige Arbeitsfläche: rausgezoomt sieht man eine **Landkarte aus Kacheln** (Chat, Editor, Desktop, Vorschau), die man mit **2 Fingern zoomt und verschiebt**. Tippt man eine Kachel an, zoomt sie voll auf und wird **echt bedienbar** („Übersicht + Fokus"). „⤢ Übersicht" bzw. der Hardware-Back führen zurück zur Landkarte.
|
||||
- Technisch: `react-native-gesture-handler` + `react-native-reanimated` (60 fps auf dem UI-Thread). Zwei Ebenen — eine skalierte Thumbnail-Welt und eine **Identity-Content-Ebene** (Scale 1), in der die schweren Inhalte (ChatScreen + WebViews) immer gemountet sind und nur die fokussierte sichtbar ist. Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet beim Fokuswechsel. **Reiner Chat verhält sich exakt wie bisher** (eine Kachel, dauerhaft fokussiert).
|
||||
|
||||
**Live-Code-Editor für Code-Projekte (App + Bridge + Proxy)**
|
||||
- Wird ein Projekt zum Code-Projekt (ARIA ruft `set_project_kind('code')`), erscheinen Editor- und Desktop-Kachel. Der Editor (WebView, selbstenthaltener Highlight-Editor, offline) **zeigt live, was ARIA schreibt** — und Stefan kann selbst editieren; Änderungen gehen zurück an ARIA.
|
||||
- Fluss: ARIAs `Write`/`Edit` unter `/shared/projects/<projekt-id>/` werden im Proxy abgefangen und als `code_file` über die Bridge/RVS an die App gespiegelt; Stefans Edits kommen als `code_file_edit` pfad-sicher zurück ins selbe Verzeichnis.
|
||||
|
||||
**QEMU für alle Architekturen + Live-Desktop per VNC (Host + Bridge + App)**
|
||||
- ARIA kann jetzt VMs für **jede Architektur** bauen/testen (x86, ARM, MIPS, PPC, RISC-V, SPARC) — Host-Helper `aria-vm` (`create/boot/screenshot/list/stop`), installiert via `host-provisioning/qemu-setup.sh`. KVM für x86-Gäste, sonst TCG. Beispiel: ein Win-3.11-System bauen und in QEMU testen.
|
||||
- Der **VNC-Live-Desktop wird durch den RVS-Server getunnelt**: die Bridge brückt rohes RFB-TCP (QEMU `127.0.0.1:5901`) ↔ RVS (`vnc_data`/`vnc_input`, Base64-in-JSON), der noVNC-Client läuft in der App-WebView (`window.WebSocket`-Shim). Stefan bedient die VM **live mit Maus/Tastatur** in der Desktop-Kachel — NAT-sicher, kein offener Port am Host, kein websockify/noVNC auf dem Host nötig.
|
||||
|
||||
**Kleineres**
|
||||
- Pro-Projekt-Layout: die zuletzt fokussierte Kachel wird pro Projekt gemerkt (`aria_workspace_layout`).
|
||||
- Projekt-Modell bekommt `kind` ('chat'|'code'); Seed-Regel lehrt ARIA den Code-Projekt-Workflow (Arbeitsverzeichnis `/shared/projects/<id>/`, `aria-vm`, VNC landet automatisch in der App).
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain bridge proxy` · RVS-Stack `up -d --build` · Host: `bash host-provisioning/qemu-setup.sh` (einmalig, als root) · APK neu bauen (nach `npm install` einmalig `npm start --reset-cache` + `gradlew clean`, wegen der neuen nativen Module).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.5] — 2026-07-12 — Pro-Projekt-Queue mit Rückfrage-Loop
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Nachrichten-Queue pro Projekt (App + Diagnostic)**
|
||||
- Eine zweite Nachricht, während ARIA am aktuellen Task arbeitet, wird jetzt **angestellt** statt den laufenden Task abzubrechen (vorher: Barge-In-Cancel). Sie läuft der Reihe nach, **pro Projekt unabhängig** (paralleles Arbeiten in mehreren Projekten bleibt). Wartende Nachrichten zeigen als ⏸-Bubble — tippen entfernt sie aus der Warteschlange.
|
||||
- **Rückfrage-Loop:** Stellt ARIA eine echte, blockierende Rückfrage, **pausiert** die Queue und deine nächste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann läuft der nächste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". Der Stop-Button bricht den aktuellen Task ab und schaltet zum nächsten.
|
||||
- ARIA signalisiert eine Rückfrage über einen **unsichtbaren `[[AWAIT]]`-Marker** — wie speak/converse deklariert das Modell den Zustand selbst (kein „endet-mit-?"-Raten). Brain strippt ihn, gibt `awaiting_reply` durch `chat()` → `ChatOut` → Bridge-Chat-Payload. Local (tool-los) und Fast-Path markieren nie.
|
||||
|
||||
**Pro-Projekt-Textfeld-Entwürfe (App + Diagnostic)**
|
||||
- Der Feldinhalt bleibt beim Projektwechsel erhalten: in Projekt X tippen, zu Y wechseln (leeres Feld), zurück zu X → dein Entwurf steht wieder da. In Storage persistiert.
|
||||
|
||||
**TTS-Abspiel-Queue (App)**
|
||||
- Zwei fast gleichzeitig fertige Antworten sprechen jetzt garantiert **nacheinander** statt sich gegenseitig abzuschneiden. Vorher war das Timing-Glück (`PcmStreamPlayer.start()` ruft `stopInternal()` = flush/release, hätte die laufende gecuttet). Jetzt: „spielt hörbar" gilt bis zum echten `PcmPlaybackFinished` (nicht nur bis Stream-Ende); eine neue hörbare Antwort, die währenddessen ankommt, wird gepuffert und danach nachgespielt (Kette für 3, 4, …). Harter Stop/Barge-In/Mund-Button verwirft die Queue.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Voice bricht nicht mehr ab:** eine neue Sprachnachricht während ARIA arbeitet stoppt nur akustisch das TTS (sauberes Mikro) und wird über den Brain-Projekt-Lock serialisiert, statt den laufenden Task abzubrechen (passend zu „immer anstellen + Stop-Button"). Text-Senden erkennt Brain-busy als Fallback, damit auch nach einem voice-gestarteten Turn korrekt angestellt wird. Grenze: eine per Sprache gestartete Aufgabe erscheint nicht als löschbare ⏸-Bubble (Aufnahme wird live gestreamt, nicht app-seitig gepuffert).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.4] — 2026-07-12 — Lokales LLM: der ehrliche Rückbau
|
||||
|
||||
### Geändert
|
||||
|
||||
**Lokales LLM wieder tool-los (B1a) — ein 8B ist ein schlechter Tool-Caller**
|
||||
- B1b hatte dem lokalen Modell Werkzeuge (`run_*`/`web_search`) gegeben — die gemeinsame Wurzel von **zwei** Problemen: (1) ein 8B erfindet mit Werkzeug in der Hand lieber eine plausible Antwort („der Song ist X") statt es zu rufen → Halluzination; (2) das erzwang per-Skill-Guards (skaliert nicht). Local ist jetzt wieder **tool-los** = reines Reden; alles mit Grundwahrheit (Fakt/Live-Zustand/Gedächtnis/Aktion) gehört an Claude oder den deterministischen Fast-Path. Kein Skill-Ergebnis mehr fälschbar
|
||||
- **Keine Input-Wortliste im Router:** eine kurz eingeführte `_LIVE_HINTS`-Blacklist (Wetter/Musik/… → Claude) wieder entfernt — aus offenem Freitext die Absicht per Wortliste zu raten ist nie vollständig, jeder Miss = ein Halo (nur von per-Skill auf per-Wort verschoben). Generisch = das Modell entscheidet **selbst** (`<<ESCALATE>>`); ein stärkeres lokales Modell übernimmt die Selbst-Erkennung später, bis dahin ist local per Einstellung abschaltbar (aus, nicht raus)
|
||||
- Expliziter Nutzer-Wunsch „nimm Claude/Clodi" wird im Router respektiert (geht nie lokal)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Info-Halluzination:** local nannte manchmal aktuellen Song/Restzeit/Skip-Titel ohne `run_spotify` zu rufen (mal echt, mal frei erfunden — „Midnight City von M83" nie aufgerufen). Neuer Output-Guard `_claims_live_media_state` eskaliert behauptete Live-Auskünfte ohne echten Skill-Call an Claude; Local-Prompt zusätzlich gehärtet (nie Titel/Zeit/Gerät ohne Tool-Ergebnis; bei „OK: next" keinen Titel erfinden)
|
||||
- **Leeres `<voice></voice>` machte TTS stumm:** Claude hängt reflexartig manchmal ein leeres Voice-Tag an → `clean_text_for_tts` nahm den leeren Inhalt → gar keine Sprachausgabe (Playlist „Fliegen" gesprochen, „Prodigy" stumm — reiner Claude-Output-Zufall, nicht Skill/Playlist-Name). Leeres/whitespace-Tag wird jetzt ignoriert, der normale Anzeigetext gelesen
|
||||
- **Datei-Anhang erschien erst nach Seitenwechsel:** die Live-`chat`-Payload trug keine `files` (Anhänge kamen nur als separates `file_from_aria`-Event) → an der Nachricht tauchte die Datei erst nach Reload aus `chat_backup` auf. Bridge schickt die `files` jetzt in der chat-Payload, App hängt sie live an die Text-Bubble (wie der Reload-Pfad) und entfernt die redundante Solo-Bubble
|
||||
- **TDZ-Zeitbombe (App):** `sendTextMessage` stand vor seinen Dependencies (`interruptAriaIfBusy`, `sendPendingAttachments`) im deps-Array — Temporal Dead Zone; lief nur dank Babels `const`→`var`-Hebung, ein strengerer Bundler hätte beim Mount weißgescreent. Deklaration hinter die Deps verschoben
|
||||
- **QRScanner tsc-clean:** toter Prop `colorForScannerFrame` (existiert in `react-native-camera-kit` v13 nicht) entfernt; die fehlerhaften Lib-Typen (optionale Props als required markiert) lokal + dokumentiert umgangen → **Projekt komplett tsc-clean (0 Fehler)**
|
||||
|
||||
**Spotify-Skill — von ARIA live im Gespräch weiter geschärft**
|
||||
- Skip (`next`/`previous`) sagt jetzt den **echten** neuen Titel an (holt den Track nach dem Skip via API) statt local einen erfinden zu lassen
|
||||
- `playlist_play`/`search_and_play`/`play` nennen das **tatsächliche** Wiedergabegerät (aus `GET /v1/me/player`, kein Raten — verhinderte den Fehler, dass Claude ein falsch geratenes Gerät auch noch ansteuerte) und lesen konsistent vor; saubere „Sprach-Grammatik": Ansagen sprechen, Steuerbefehle (play/pause/transfer/volume) schweigen
|
||||
- `yt-dlp-download`-Skill um einen MP3-Modus erweitert — ARIA hat das fehlende Werkzeug **selbst gebaut**, als eine deutsche Titelmelodie nicht auf Spotify lag (Web-Suche → YouTube-Download → MP3 in den Chat, in <1 min)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.3] — 2026-07-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`skill_get`-Tool:** ARIA liest den echten Quellcode + Manifest + Readme eines Skills, **bevor** sie ihn ändert — kein Blind-Rewrite mehr (vorher wurde ein guter Skill durch eine schlechtere Neufassung ersetzt, weil das referenzierte `skill_get` gar nicht existierte)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **`converse` folgt dem Skill (Fast-Path):** auch ein Fast-Path-Befehl kann einen Skill auslösen, nach dem noch etwas zu sagen ist — `converse` kommt jetzt aus Manifest/Skill-Output statt hart auf `False`
|
||||
- **Sprachnachricht-Bubble verschwand nach manuellem Stop:** bei „ohne Ohr" aufgenommener Sprachnachricht + Stop entfernte ein leeres stream-end-Endpoint die schon gefüllte Bubble; jetzt wird nur noch der unaufgelöste Platzhalter entfernt
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.2] — 2026-07-11 — Standort-Intelligenz + Skill steuert seine Ausgabe
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**GPS → Ortsname im Standort-Präfix (keyless, keine Tokens)**
|
||||
- Reverse-Geocoding der Koordinaten in der Bridge (Nominatim zoom=18: Straße+Hausnr, PLZ+Ort, Bundesland; Straßen-Ref + Autobahn-km via Overpass) — damit das lokale Modell nicht „Berlin" für Oldenburg rät; Ortsname wird **vor** dem Präfix-Bau awaited (rechtzeitig für die erste Nachricht)
|
||||
- Fahrtrichtung als Himmelsrichtung **+ exakte Peilung in Grad** (Haversine/Bearing aus aufeinanderfolgenden Fixes, `MIN_MOVE_M`-Schwelle gegen Zittern)
|
||||
|
||||
**Skill steuert seine Ausgabe selbst — `speak` + `converse` pro Aufruf**
|
||||
- Ein Skill entscheidet per JSON-Output `{speak, converse}` pro Operation, ob vorgelesen wird und ob danach 30 s weitergelauscht wird (Manifest-Default, Output überschreibt) — z. B. „was läuft" vorlesen aber kein Dialog, „nächstes Lied" stumm. In der Skill-Bauanleitung **mit dem WARUM** dokumentiert, damit die KI die Flags beim Bauen versteht (kein Hardcode im Brain)
|
||||
|
||||
### Behoben / Geändert
|
||||
|
||||
- **Generischer Skill-Prompt (kein Hardcode):** der Router beschreibt `run_*`-Skills generisch (weiß nicht mehr, welche „schwer" sind); ein Stop im 30-s-Lauschen beendet dieses jetzt wirklich (kein zweiter Gong / erneutes Öffnen)
|
||||
- **Anti-Halluzination:** behauptet local eine Steuerbefehl-Quittung („Spotify: …", „Playlist abspielen") ohne das Tool wirklich zu rufen → Eskalation an Claude statt erfundene Bestätigung durchzulassen
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.1] — 2026-07-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Skill entscheidet selbst, ob vorgelesen wird (`manifest.speak`):** Grundlage der späteren `speak`/`converse`-Architektur — der `speak`-Flag greift sowohl im lokalen als auch im Claude-Pfad, statt am fragilen leeren `<voice></voice>`-Hack zu hängen
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0.6] — 2026-07-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -96,6 +96,7 @@ ARIA hat zwei Rollen:
|
||||
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
||||
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
|
||||
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
||||
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||
|
||||
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
||||
@@ -204,6 +205,37 @@ Die Diagnostic-UI hat sechs Top-Tabs:
|
||||
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
||||
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
||||
|
||||
### 6. (Optional) Desktop-Workspace: QEMU auf dem Host
|
||||
|
||||
Nur noetig, wenn ARIA VMs bauen/testen und du sie live in der Desktop-Kachel der
|
||||
App bedienen koennen sollst (Code-Projekte, z. B. ein Win-3.11-System). **Wird
|
||||
NICHT von `docker compose` mitinstalliert** — QEMU laeuft direkt auf dem Host
|
||||
(172.0.2.33), nicht in einem Container, weil dort KVM sitzt und die VNC binden
|
||||
kann. Einmalig als root:
|
||||
|
||||
```bash
|
||||
sudo bash host-provisioning/qemu-setup.sh
|
||||
```
|
||||
|
||||
Installiert `qemu-system-*` fuer **alle Architekturen** (x86/ARM/MIPS/PPC/SPARC/
|
||||
RISC-V), `qemu-utils`, Firmware, `socat`, `imagemagick` und den Helper
|
||||
`/usr/local/bin/aria-vm`. KVM-Beschleunigung gibt es nur fuer x86-Gaeste; andere
|
||||
Architekturen laufen emuliert (TCG). Danach testen:
|
||||
|
||||
```bash
|
||||
aria-vm list
|
||||
```
|
||||
|
||||
ARIA steuert VMs dann per `ssh aria-wohnung aria-vm ...` (create/boot/screenshot/
|
||||
list/stop). Der VNC-Desktop wird automatisch als RFB-Bytes durch die Bridge/RVS
|
||||
in die App getunnelt — **kein** Port am Host oeffnen, **kein** websockify/noVNC
|
||||
auf dem Host noetig (der noVNC-Client liegt in der App). Ohne diesen Schritt
|
||||
funktioniert alles andere normal; nur die Desktop-Kachel bleibt leer.
|
||||
|
||||
> **App-Rebuild noetig** fuer den Workspace: die neuen nativen Module
|
||||
> (gesture-handler, reanimated, webview) brauchen nach `npm install` einmalig
|
||||
> `npm start --reset-cache` + `./gradlew clean`, dann APK neu bauen.
|
||||
|
||||
---
|
||||
|
||||
## Proxy — Wie funktioniert das?
|
||||
@@ -469,12 +501,17 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
|
||||
### Features
|
||||
|
||||
- **Desktop-Workbench (Kompakt ↔ Cockpit)**: Umschalter oben rechts im Header. **Kompakt** = klassischer Vollbild-Chat (Default). **Cockpit** = Workbench mit **Taskleiste unten** (Chat · Code · Desktop) — ein Daumen-Tap wechselt sofort das Panel, animierter Indikator, Aktivitäts-Punkte (Code blau / Desktop grün). Jedes Panel bildschirmfüllend und Handy-optimiert; Dock blendet bei offener Tastatur aus. Desktop-Umfang, aber auf 5 Zoll bedienbar
|
||||
- **Live-Code-Editor** (Code-Projekte): Wird ein Projekt zum Code-Projekt (`set_project_kind('code')`), zeigt eine Editor-Kachel **live, was ARIA schreibt** (Syntax-Highlighting, offline) und du kannst selbst editieren → zurück an ARIA. ARIAs `Write`/`Edit` unter `/shared/projects/<id>/` werden im Proxy abgefangen und als `code_file` gespiegelt; deine Edits kommen als `code_file_edit` pfad-sicher zurück
|
||||
- **Live-Desktop per VNC (durch RVS getunnelt)**: ARIA baut/testet VMs mit **QEMU für jede Architektur** (x86/ARM/MIPS/PPC/RISC-V/SPARC, Host-Helper `aria-vm`, KVM für x86). Der QEMU-Desktop erscheint **live in der Desktop-Kachel** — noVNC in der WebView, RFB-Bytes werden als Base64 über RVS gebrückt (Bridge ↔ QEMU `127.0.0.1:5901`). Du bedienst die VM **live mit Maus/Tastatur**, NAT-sicher, kein offener Port am Host
|
||||
- Text-Chat mit ARIA
|
||||
- **Sprachaufnahme**: Tap-to-Talk (tippen startet, tippen stoppt, Auto-Stop bei Stille via VAD)
|
||||
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
|
||||
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
|
||||
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
|
||||
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
|
||||
- **Nachricht anstellen statt abbrechen** (Queue pro Projekt): Schickst du eine zweite Nachricht waehrend ARIA noch am aktuellen Task arbeitet, wird sie **angestellt** statt den laufenden abzubrechen — laeuft der Reihe nach, pro Projekt unabhaengig. Wartende zeigen als `⏸`-Bubble (tippen entfernt sie aus der Warteschlange). Explizites Abbrechen laeuft ueber den Stop-Button am „ARIA denkt". Eine neue Sprachnachricht stoppt nur akustisch das TTS (sauberes Mikro), bricht die laufende Arbeit aber nicht mehr ab
|
||||
- **Rueckfrage-Loop**: Stellt ARIA eine echte, blockierende Rueckfrage, **pausiert** die Queue und deine naechste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann laeuft der naechste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". ARIA signalisiert das ueber einen unsichtbaren `[[AWAIT]]`-Marker im Antworttext (das Modell deklariert den Zustand selbst, kein „endet-mit-?"-Raten; Brain strippt ihn und gibt `awaiting_reply` an App + Diagnostic durch)
|
||||
- **Pro-Projekt-Textfeld-Entwuerfe**: Der Feldinhalt bleibt beim Projektwechsel erhalten — in Projekt X tippen, zu Y wechseln (leeres Feld), zurueck zu X → dein Entwurf steht wieder da. Persistiert ueber Neustart. Gleiches Verhalten im Diagnostic
|
||||
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||
@@ -482,6 +519,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
||||
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
||||
- **TTS-Abspiel-Queue**: Zwei fast gleichzeitig fertige Antworten sprechen garantiert **nacheinander** statt sich abzuschneiden — eine neue hoerbare Antwort, die waehrend der Wiedergabe einer anderen ankommt, wird gepuffert und erst nach deren echtem Wiedergabe-Ende (`PcmPlaybackFinished`, nicht nur Stream-Ende) nachgespielt. Harter Stop / Barge-In / Mund-Button verwirft die Queue
|
||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||
@@ -994,6 +1032,9 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
|
||||
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
|
||||
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
|
||||
- [x] **Pro-Projekt-Nachrichten-Queue** (loest das alte Barge-In-Cancel ab): zweite Nachricht wird **angestellt** statt den laufenden Task abzubrechen; **Rueckfrage-Loop** via unsichtbarem `[[AWAIT]]`-Marker (Queue pausiert, naechste Eingabe beantwortet die Rueckfrage); Stop-Button schaltet zum naechsten; sichtbare `⏸`-Bubbles (loeschbar). Pro Projekt unabhaengig, App + Diagnostic
|
||||
- [x] Pro-Projekt-Textfeld-Entwuerfe (Feldinhalt bleibt beim Projektwechsel erhalten, persistiert; App + Diagnostic)
|
||||
- [x] **TTS-Abspiel-Queue**: zwei fast gleichzeitig fertige Antworten sprechen garantiert nacheinander statt sich abzuschneiden (Puffern bis `PcmPlaybackFinished` der laufenden)
|
||||
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
|
||||
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
|
||||
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
|
||||
@@ -1004,6 +1045,11 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||
- [x] **Desktop-Workbench**: Taskleisten-Dock (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, Aktivitäts-Badges, keyboard-aware; Kompakt↔Cockpit-Umschalter. (Ersetzte den anfänglichen Pinch-Zoom-Kachel-Canvas — auf 5 Zoll zu fummelig)
|
||||
- [x] **noVNC-Konsole bedienbar**: Tastatur-Einblendung (tippt in die VM), Strg+Alt+Entf, Fit↔1:1
|
||||
- [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects/<id>/` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`)
|
||||
- [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel
|
||||
- [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar
|
||||
|
||||
### Phase A — Refactor: OpenClaw raus, eigenes Brain rein
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# ai-box — KI-Box (gpubox) Bootstrap
|
||||
|
||||
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen
|
||||
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
|
||||
|
||||
## Was das Script tut
|
||||
|
||||
1. Basis-Pakete (curl, gnupg, git …)
|
||||
2. `contrib non-free non-free-firmware` aktivieren (Trixie-**deb822**-Format berücksichtigt)
|
||||
3. **NVIDIA-Treiber** installieren (`nvidia-driver` + firmware)
|
||||
4. **Docker** Engine + Compose-Plugin
|
||||
5. **NVIDIA Container Toolkit** + Docker-Runtime auf NVIDIA konfigurieren
|
||||
6. `xtts/.env` aus `.env.example` anlegen (RVS_TOKEN optional gleich setzen)
|
||||
7. **GPU-im-Container-Test** (`docker run --gpus all … nvidia-smi`)
|
||||
8. optional (`--up`): den `xtts`-Stack hochziehen
|
||||
|
||||
Alles **idempotent** — mehrfach ausführbar.
|
||||
|
||||
## Ablauf
|
||||
|
||||
```bash
|
||||
git clone <repo> ARIA-AGENT
|
||||
cd ARIA-AGENT/ai-box
|
||||
|
||||
sudo ./bootstrap.sh
|
||||
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
|
||||
sudo reboot
|
||||
# … nach dem Boot:
|
||||
cd ARIA-AGENT/ai-box
|
||||
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
|
||||
```
|
||||
|
||||
Der Treiber-Reboot ist normal (Kernel-Modul wird erst beim Boot geladen). Beim
|
||||
zweiten Lauf überspringt das Script alles Erledigte und macht nur noch den
|
||||
GPU-Test + Stack-Start.
|
||||
|
||||
## Optionen
|
||||
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--up` | am Ende `docker compose up -d --build` (Default-Profil) |
|
||||
| `--token <TOK>` | `RVS_TOKEN` in `xtts/.env` eintragen (auch via `RVS_TOKEN=…` env) |
|
||||
| `--rvs-host <H>` | `RVS_HOST` setzen |
|
||||
|
||||
## Wichtig
|
||||
|
||||
- **Stimm-Daten** (nicht in git): falls von der alten Box noch vorhanden,
|
||||
`xtts/voice-id/` (Speaker-Fingerprint) + `xtts/voices/` (F5-Referenz) herkopieren.
|
||||
Sonst egal — in der App neu anlegen: Stimme neu enrollen (ohne Fingerprint läuft
|
||||
die Speaker-ID fail-open, alles geht durch) + F5-Voice-Referenz neu hochladen.
|
||||
- **Voxtral bleibt aus** auf der 3060 (braucht ≥16 GB VRAM). Das Default-Profil
|
||||
fährt Whisper (mit dem M0.1-Fix) + F5-TTS + lokales LLM. Voxtral erst mit der
|
||||
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
|
||||
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
|
||||
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
|
||||
|
||||
## Verifizieren
|
||||
|
||||
```bash
|
||||
nvidia-smi # Host sieht die GPU
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
|
||||
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
|
||||
```
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ARIA KI-Box (gpubox) Bootstrap — frisches Debian Trixie → startklarer
|
||||
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
|
||||
#
|
||||
# Ablauf nach `git clone`:
|
||||
# cd ARIA-AGENT/ai-box
|
||||
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
|
||||
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
|
||||
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
|
||||
#
|
||||
# Optionen:
|
||||
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral)
|
||||
# --upgrade-driver NVIDIA-Treiber aus trixie-backports (modernes CUDA fuer Voxtral).
|
||||
# Danach REBOOT noetig. Default-Bootstrap laesst 550 unangetastet.
|
||||
# --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
|
||||
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
|
||||
#
|
||||
# IDEMPOTENT: bereits erledigte Schritte werden uebersprungen. Nach dem
|
||||
# Treiber-Reboot einfach nochmal ausfuehren — der Rest laeuft dann durch.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ── CLI ──
|
||||
DO_UP=0
|
||||
DO_UPGRADE_DRIVER=0
|
||||
RVS_TOKEN_ARG="${RVS_TOKEN:-}"
|
||||
RVS_HOST_ARG="${RVS_HOST:-}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--up) DO_UP=1; shift ;;
|
||||
--upgrade-driver) DO_UPGRADE_DRIVER=1; shift ;;
|
||||
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
|
||||
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "Unbekannte Option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Log-Helfer ──
|
||||
c_g="\033[1;32m"; c_y="\033[1;33m"; c_r="\033[1;31m"; c_b="\033[1;34m"; c_0="\033[0m"
|
||||
STEP=0
|
||||
step() { STEP=$((STEP+1)); echo -e "\n${c_b}[${STEP}] $*${c_0}"; }
|
||||
ok() { echo -e " ${c_g}✓${c_0} $*"; }
|
||||
warn() { echo -e " ${c_y}!${c_0} $*"; }
|
||||
die() { echo -e "${c_r}✗ $*${c_0}" >&2; exit 1; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
XTTS_DIR="$REPO_ROOT/xtts"
|
||||
|
||||
# ── Preflight ──
|
||||
[[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausfuehren (sudo ./bootstrap.sh)."
|
||||
command -v apt-get >/dev/null || die "Kein apt-get — dieses Script ist fuer Debian/Trixie."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo -e "${c_b}=== ARIA KI-Box Bootstrap ===${c_0}"
|
||||
echo "Repo: $REPO_ROOT"
|
||||
echo "xtts: $XTTS_DIR"
|
||||
|
||||
# ── 1. Basis-Pakete ──
|
||||
step "Basis-Pakete"
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl gnupg git lsb-release >/dev/null
|
||||
ok "ca-certificates, curl, gnupg, git"
|
||||
|
||||
# ── 2. non-free Repos aktivieren (Trixie deb822 + Legacy) ──
|
||||
step "APT-Komponenten (contrib non-free non-free-firmware)"
|
||||
# Ergaenzt die drei Komponenten in JEDER Components:-Zeile einer deb822-Datei —
|
||||
# pro Zeile nur was fehlt, reihenfolge-robust, idempotent. Die Adressen pruefen
|
||||
# ganze Woerter (non-free-firmware zaehlt NICHT als non-free).
|
||||
add_components() {
|
||||
sed -i -E '/^[Cc]omponents:/{
|
||||
/(^|[[:space:]])contrib([[:space:]]|$)/!s/$/ contrib/
|
||||
/(^|[[:space:]])non-free([[:space:]]|$)/!s/$/ non-free/
|
||||
/(^|[[:space:]])non-free-firmware([[:space:]]|$)/!s/$/ non-free-firmware/
|
||||
}' "$1"
|
||||
}
|
||||
ENABLED_ANY=0
|
||||
shopt -s nullglob
|
||||
for f in /etc/apt/sources.list.d/*.sources; do
|
||||
grep -qE '^[Cc]omponents:' "$f" || continue
|
||||
b="$(md5sum "$f")"; add_components "$f"; a="$(md5sum "$f")"
|
||||
if [[ "$b" != "$a" ]]; then ENABLED_ANY=1; ok "aktualisiert: $(basename "$f")"; fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
# Legacy /etc/apt/sources.list (deb-Zeilen)
|
||||
if [[ -f /etc/apt/sources.list ]] && grep -qE '^deb ' /etc/apt/sources.list; then
|
||||
if ! grep -qE '^deb .*[[:space:]]non-free([[:space:]]|$)' /etc/apt/sources.list; then
|
||||
sed -i -E '/^deb .*debian/ s/$/ contrib non-free non-free-firmware/' /etc/apt/sources.list
|
||||
ENABLED_ANY=1; ok "aktualisiert: sources.list"
|
||||
fi
|
||||
fi
|
||||
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
|
||||
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist
|
||||
|
||||
# ── 2b. (opt-in) Treiber-Upgrade via trixie-backports — fuer Voxtral/modernes CUDA ──
|
||||
# Der Trixie-Standardtreiber (550, CUDA 12.4) ist zu alt fuer den modernen Stack
|
||||
# (torch 2.13, vLLM). Backports bringt einen neueren, apt-verwalteten Treiber.
|
||||
# NUR mit --upgrade-driver, damit ein laufendes Setup nicht ungewollt angefasst wird.
|
||||
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
|
||||
step "NVIDIA-Treiber-Upgrade (trixie-backports)"
|
||||
BP_FILE="/etc/apt/sources.list.d/backports.sources"
|
||||
if ! grep -rqs "trixie-backports" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||
cat > "$BP_FILE" <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian
|
||||
Suites: trixie-backports
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
ok "trixie-backports hinzugefuegt"
|
||||
else
|
||||
ok "trixie-backports bereits aktiv"
|
||||
fi
|
||||
apt-get update
|
||||
apt-get install -y linux-headers-amd64 || true
|
||||
apt-get install -y "linux-headers-$(uname -r)" || true
|
||||
if apt-get install -y -t trixie-backports nvidia-driver; then
|
||||
dkms autoinstall >/dev/null 2>&1 || true
|
||||
NEWV="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
|
||||
ok "nvidia-driver aus backports installiert: ${NEWV}"
|
||||
echo
|
||||
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
|
||||
echo -e "${c_y} sudo reboot && danach: cd ai-box && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
|
||||
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
|
||||
exit 0
|
||||
else
|
||||
warn "backports-Install fehlgeschlagen — 550er bleibt aktiv."
|
||||
warn "Alternative fuer den neuesten Treiber: NVIDIAs CUDA-Repo fuer debian13"
|
||||
warn " (developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64) → Paket 'cuda-drivers'."
|
||||
die "Treiber-Upgrade nicht moeglich — siehe oben."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. NVIDIA-Treiber ──
|
||||
step "NVIDIA-Treiber"
|
||||
if nvidia-smi >/dev/null 2>&1; then
|
||||
ok "Treiber aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
|
||||
DRIVER_ACTIVE=1
|
||||
else
|
||||
if dpkg -l | grep -q '^ii nvidia-driver '; then
|
||||
warn "Treiber installiert, aber nvidia-smi antwortet nicht → REBOOT noetig."
|
||||
DRIVER_ACTIVE=0
|
||||
else
|
||||
# KEIN separater Kandidaten-Check — die apt-cache-Ausgabe ist locale-/pipe-
|
||||
# fragil (hat faelschlich "kein Kandidat" gemeldet). Der Install IST der Test:
|
||||
# direkt installieren; schlaegt er fehl, einmal volles apt-get update + Retry,
|
||||
# dann erst mit Diagnose abbrechen.
|
||||
# Kernel-Header ZUERST — sonst ueberspringt DKMS den Modulbau ("No kernel
|
||||
# headers were found") und nvidia-smi kann spaeter nicht mit dem Treiber reden.
|
||||
warn "Kernel-Header + nvidia-driver installieren (DKMS-Build, dauert)…"
|
||||
apt-get install -y linux-headers-amd64 || true
|
||||
apt-get install -y "linux-headers-$(uname -r)" || true
|
||||
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
|
||||
warn "Install fehlgeschlagen — volles 'apt-get update' + zweiter Versuch…"
|
||||
apt-get update
|
||||
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
|
||||
echo
|
||||
warn "nvidia-driver liess sich nicht installieren. Aktive Quellen:"
|
||||
grep -rhE '^[Cc]omponents:' /etc/apt/sources.list.d/*.sources 2>/dev/null | sed 's/^/ /' || true
|
||||
grep -E '^deb ' /etc/apt/sources.list 2>/dev/null | sed 's/^/ /' || true
|
||||
die "Pruefe 'apt-cache policy nvidia-driver' + Netz/non-free."
|
||||
fi
|
||||
fi
|
||||
# Falls nvidia-kernel-dkms schon (ohne Header) installiert war: Modul jetzt bauen.
|
||||
dkms autoinstall >/dev/null 2>&1 || true
|
||||
ok "nvidia-driver + Kernel-Modul installiert"
|
||||
DRIVER_ACTIVE=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Docker Engine + Compose-Plugin ──
|
||||
step "Docker"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ok "Docker vorhanden: $(docker --version)"
|
||||
else
|
||||
warn "Installiere Docker (offizielles get.docker.com)…"
|
||||
curl -fsSL https://get.docker.com | sh >/dev/null
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
ok "Docker installiert: $(docker --version)"
|
||||
fi
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
ok "Compose-Plugin: $(docker compose version | head -1)"
|
||||
else
|
||||
warn "Compose-Plugin fehlt — installiere docker-compose-plugin…"
|
||||
apt-get install -y -qq docker-compose-plugin >/dev/null || \
|
||||
warn "Konnte docker-compose-plugin nicht via apt holen — get.docker.com bringt es normalerweise mit."
|
||||
fi
|
||||
|
||||
# ── 5. NVIDIA Container Toolkit ──
|
||||
step "NVIDIA Container Toolkit"
|
||||
NCT_LIST="/etc/apt/sources.list.d/nvidia-container-toolkit.list"
|
||||
NCT_KEY="/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg"
|
||||
if ! dpkg -l | grep -q '^ii nvidia-container-toolkit '; then
|
||||
[[ -f "$NCT_KEY" ]] || curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
||||
| gpg --dearmor -o "$NCT_KEY"
|
||||
if [[ ! -f "$NCT_LIST" ]]; then
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||
| sed "s#deb https://#deb [signed-by=${NCT_KEY}] https://#g" > "$NCT_LIST"
|
||||
fi
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nvidia-container-toolkit >/dev/null
|
||||
ok "nvidia-container-toolkit installiert"
|
||||
else
|
||||
ok "nvidia-container-toolkit vorhanden"
|
||||
fi
|
||||
# Docker-Runtime auf NVIDIA konfigurieren (idempotent)
|
||||
if ! grep -q '"nvidia"' /etc/docker/daemon.json 2>/dev/null; then
|
||||
nvidia-ctk runtime configure --runtime=docker >/dev/null
|
||||
systemctl restart docker
|
||||
ok "Docker-Runtime auf NVIDIA konfiguriert + Docker neugestartet"
|
||||
else
|
||||
ok "Docker-Runtime bereits NVIDIA-konfiguriert"
|
||||
fi
|
||||
|
||||
# ── 6. xtts/.env vorbereiten ──
|
||||
step "xtts/.env"
|
||||
if [[ -f "$XTTS_DIR/.env" ]]; then
|
||||
ok ".env existiert bereits (unangetastet)"
|
||||
else
|
||||
cp "$XTTS_DIR/.env.example" "$XTTS_DIR/.env"
|
||||
ok ".env aus .env.example erstellt"
|
||||
fi
|
||||
if [[ -n "$RVS_TOKEN_ARG" ]]; then
|
||||
sed -i -E "s#^RVS_TOKEN=.*#RVS_TOKEN=${RVS_TOKEN_ARG}#" "$XTTS_DIR/.env"
|
||||
ok "RVS_TOKEN eingetragen"
|
||||
fi
|
||||
if [[ -n "$RVS_HOST_ARG" ]]; then
|
||||
sed -i -E "s#^RVS_HOST=.*#RVS_HOST=${RVS_HOST_ARG}#" "$XTTS_DIR/.env"
|
||||
ok "RVS_HOST=${RVS_HOST_ARG} eingetragen"
|
||||
fi
|
||||
if grep -q '^RVS_TOKEN=dein_token_hier' "$XTTS_DIR/.env"; then
|
||||
warn "RVS_TOKEN ist noch der Platzhalter — vor dem Start setzen:"
|
||||
warn " nano $XTTS_DIR/.env (oder: sudo ./bootstrap.sh --token <TOKEN>)"
|
||||
fi
|
||||
warn "Stimm-Daten (nicht in git): falls von der alten Box noch vorhanden, xtts/voice-id/"
|
||||
warn " + xtts/voices/ herkopieren. Sonst egal — in der App neu anlegen:"
|
||||
warn " Sprache neu enrollen (Speaker-ID, sonst fail-open) + F5-Referenz neu hochladen."
|
||||
|
||||
# ── 7. GPU-im-Container verifizieren ──
|
||||
step "GPU-im-Container Test"
|
||||
if [[ "${DRIVER_ACTIVE:-0}" -eq 1 ]]; then
|
||||
if docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi >/dev/null 2>&1; then
|
||||
ok "Docker sieht die GPU — KI-Box ist einsatzbereit."
|
||||
GPU_READY=1
|
||||
else
|
||||
warn "Host-Treiber ok, aber Container sieht die GPU nicht — Toolkit/Runtime pruefen."
|
||||
GPU_READY=0
|
||||
fi
|
||||
else
|
||||
warn "Treiber noch nicht aktiv → Test uebersprungen."
|
||||
echo
|
||||
echo -e "${c_y}==> REBOOT noetig, dann Script erneut ausfuehren:${c_0}"
|
||||
echo -e "${c_y} sudo reboot && (nach dem Boot) sudo ./bootstrap.sh${c_0}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 8. Optional: xtts-Stack hochziehen ──
|
||||
if [[ $DO_UP -eq 1 && "${GPU_READY:-0}" -eq 1 ]]; then
|
||||
step "xtts-Stack starten (Default-Profil: Whisper + F5 + LLM — OHNE voxtral)"
|
||||
warn "Erster Start laedt Modelle (mehrere GB via HuggingFace) — kann dauern."
|
||||
( cd "$XTTS_DIR" && docker compose up -d --build )
|
||||
ok "Stack laeuft. Logs: docker logs -f aria-whisper-bridge"
|
||||
echo
|
||||
echo " voxtral (STT via Voxtral) braucht >=16 GB VRAM → erst mit der 24-GB-Karte:"
|
||||
echo " cd $XTTS_DIR && docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build"
|
||||
fi
|
||||
|
||||
# ── Abschluss ──
|
||||
echo
|
||||
echo -e "${c_g}=== Fertig. KI-Box startklar. ===${c_0}"
|
||||
if [[ $DO_UP -eq 0 ]]; then
|
||||
echo "Naechster Schritt — Stack starten:"
|
||||
echo " cd $XTTS_DIR && docker compose up -d --build"
|
||||
echo "oder direkt: sudo ./bootstrap.sh --up"
|
||||
fi
|
||||
+10
-4
@@ -8,11 +8,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import ChatScreen from './src/screens/ChatScreen';
|
||||
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
@@ -132,7 +134,7 @@ const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
|
||||
<NavigationContainer theme={DarkTheme}>
|
||||
<Tab.Navigator
|
||||
@@ -165,10 +167,11 @@ const App: React.FC = () => {
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
component={WorkspaceScreen}
|
||||
options={{
|
||||
title: 'ARIA Chat',
|
||||
headerTitle: 'ARIA Cockpit',
|
||||
headerRight: () => <ViewModeToggle />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -180,13 +183,16 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
</>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#12122A',
|
||||
elevation: 0,
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20009
|
||||
versionName "0.2.0.9"
|
||||
versionCode 20406
|
||||
versionName "0.2.4.6"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
|
||||
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
||||
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
||||
private const val STARTUP_SUPPRESSION_MS = 1500L
|
||||
private const val STARTUP_SUPPRESSION_MS = 600L
|
||||
}
|
||||
|
||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
presets: ['module:metro-react-native-babel-preset'],
|
||||
// react-native-reanimated/plugin MUSS das LETZTE Plugin sein (Worklet-Transform).
|
||||
// Nach dem Hinzufuegen einmalig Metro-Cache leeren: `npm start --reset-cache`.
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// react-native-gesture-handler MUSS als allererstes importiert werden
|
||||
// (vor allem anderen), sonst crasht die Gesten-Erkennung auf Android.
|
||||
import 'react-native-gesture-handler';
|
||||
import { AppRegistry } from 'react-native';
|
||||
import App from './App';
|
||||
import { name as appName } from './app.json';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.0.9",
|
||||
"version": "0.2.4.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
@@ -20,12 +20,15 @@
|
||||
"react-native-camera-kit": "^13.0.0",
|
||||
"react-native-document-picker": "^9.1.1",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "2.14.1",
|
||||
"react-native-image-picker": "^7.1.0",
|
||||
"react-native-permissions": "^4.1.4",
|
||||
"react-native-reanimated": "3.6.2",
|
||||
"react-native-safe-area-context": "^4.8.2",
|
||||
"react-native-screens": "3.27.0",
|
||||
"react-native-sound": "^0.11.2",
|
||||
"react-native-svg": "^14.1.0"
|
||||
"react-native-svg": "^14.1.0",
|
||||
"react-native-webview": "13.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native/eslint-config": "^0.73.2",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
|
||||
import brainApi, { Project } from '../services/brainApi';
|
||||
import rvs from '../services/rvs';
|
||||
import projectFocus from '../services/projectFocus';
|
||||
|
||||
interface Props {
|
||||
/** Optional — wenn als Modal genutzt, sonst inline */
|
||||
@@ -75,6 +76,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
const [editKind, setEditKind] = useState<'code' | 'chat'>('chat');
|
||||
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
|
||||
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
@@ -148,18 +150,25 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
setEditing(p);
|
||||
setEditName(p.name);
|
||||
setEditDesc(p.description || '');
|
||||
setEditKind(p.kind === 'code' ? 'code' : 'chat');
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(() => {
|
||||
if (!editing) return;
|
||||
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
|
||||
const patch: Partial<Pick<Project, 'name' | 'description' | 'kind'>> = {};
|
||||
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
|
||||
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
|
||||
const curKind = editing.kind === 'code' ? 'code' : 'chat';
|
||||
if (editKind !== curKind) patch.kind = editKind;
|
||||
if (Object.keys(patch).length === 0) { setEditing(null); return; }
|
||||
brainApi.updateProject(editing.id, patch)
|
||||
.then(() => { setEditing(null); load(); })
|
||||
.then(() => {
|
||||
// Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels).
|
||||
if (patch.kind) projectFocus.setKind(editing.id, patch.kind);
|
||||
setEditing(null); load();
|
||||
})
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
}, [editing, editName, editDesc, load]);
|
||||
}, [editing, editName, editDesc, editKind, load]);
|
||||
|
||||
const endProject = useCallback((p: Project) => {
|
||||
Alert.alert(`"${p.name}" beenden?`,
|
||||
@@ -216,6 +225,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||
)}
|
||||
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||||
{item.has_files && (
|
||||
<Text style={{ fontSize: 12 }} accessibilityLabel="hat Dateien">📄{item.file_count ? ` ${item.file_count}` : ''}</Text>
|
||||
)}
|
||||
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
|
||||
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||||
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||
@@ -375,6 +387,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
style={[s.input, { height: 70 }]}
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
|
||||
>
|
||||
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
|
||||
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
|
||||
</Text>
|
||||
<View style={{
|
||||
width: 46, height: 26, borderRadius: 13, padding: 3,
|
||||
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
|
||||
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||||
|
||||
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
|
||||
<View style={styles.container}>
|
||||
{hasPermission ? (
|
||||
<>
|
||||
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
|
||||
CameraScreen-Props faelschlich als required (defaultProps fuellen
|
||||
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
|
||||
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
|
||||
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
|
||||
zu umgehen, ohne echten Code zu veraendern. */}
|
||||
<CameraScreen
|
||||
scanBarcode={true}
|
||||
onReadCode={handleBarcodeScan}
|
||||
showFrame={true}
|
||||
frameColor="#0096FF"
|
||||
laserColor="#0096FF"
|
||||
colorForScannerFrame="#0096FF"
|
||||
{...({
|
||||
scanBarcode: true,
|
||||
onReadCode: handleBarcodeScan,
|
||||
showFrame: true,
|
||||
frameColor: '#0096FF',
|
||||
laserColor: '#0096FF',
|
||||
} as any)}
|
||||
/>
|
||||
|
||||
{/* Overlay oben */}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* ViewModeToggle — kleiner Header-Button zum Umschalten zwischen Kompakt-
|
||||
* Ansicht (klassischer Chat) und Cockpit (Kachel-Desktop).
|
||||
*
|
||||
* Sitzt rechts im Navigations-Header ("ARIA Cockpit"), kollidiert also mit
|
||||
* nichts in der Chat-Ansicht. Zeigt das Ziel des naechsten Taps.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
|
||||
const ViewModeToggle: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
useEffect(() => viewMode.subscribe(setMode), []);
|
||||
|
||||
const isCockpit = mode === 'cockpit';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => viewMode.toggle()}
|
||||
style={[styles.pill, isCockpit && styles.pillActive]}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={[styles.text, isCockpit && styles.textActive]}>
|
||||
{isCockpit ? '⧉ Cockpit' : '⧉ Kompakt'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
marginRight: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
backgroundColor: '#12122A',
|
||||
},
|
||||
pillActive: { borderColor: '#0096FF', backgroundColor: '#0A1F33' },
|
||||
text: { color: '#9090B0', fontSize: 13, fontWeight: '700' },
|
||||
textActive: { color: '#0096FF' },
|
||||
});
|
||||
|
||||
export default ViewModeToggle;
|
||||
+624
-124
File diff suppressed because it is too large
Load Diff
@@ -63,14 +63,16 @@ import {
|
||||
VAD_SILENCE_MIN_SEC,
|
||||
VAD_SILENCE_MAX_SEC,
|
||||
VAD_SILENCE_STORAGE_KEY,
|
||||
CONV_WINDOW_DEFAULT_SEC,
|
||||
CONV_WINDOW_MIN_SEC,
|
||||
CONV_WINDOW_MAX_SEC,
|
||||
CONV_WINDOW_STORAGE_KEY,
|
||||
STT_ENDPOINT_DEFAULT_MS,
|
||||
STT_ENDPOINT_MIN_MS,
|
||||
STT_ENDPOINT_MAX_MS,
|
||||
STT_ENDPOINT_STORAGE_KEY,
|
||||
MAX_RECORDING_DEFAULT_SEC,
|
||||
MAX_RECORDING_MIN_SEC,
|
||||
MAX_RECORDING_MAX_SEC,
|
||||
MAX_RECORDING_STORAGE_KEY,
|
||||
loadBargeInEnabled,
|
||||
saveBargeInEnabled,
|
||||
VAD_SILENCE_DB_DEFAULT,
|
||||
VAD_SILENCE_DB_MIN,
|
||||
VAD_SILENCE_DB_MAX,
|
||||
@@ -105,6 +107,13 @@ import wakeWordService, {
|
||||
KEYWORD_LABELS,
|
||||
DEFAULT_KEYWORD,
|
||||
WAKE_KEYWORD_STORAGE,
|
||||
WAKE_THRESHOLD_DEFAULT,
|
||||
WAKE_THRESHOLD_MIN,
|
||||
WAKE_THRESHOLD_MAX,
|
||||
loadWakeThreshold,
|
||||
saveWakeThreshold,
|
||||
loadBgWakeEnabled,
|
||||
saveBgWakeEnabled,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
@@ -187,8 +196,12 @@ const SettingsScreen: React.FC = () => {
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||||
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
|
||||
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
|
||||
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
|
||||
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
|
||||
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
|
||||
const [bargeIn, setBargeIn] = useState<boolean>(false);
|
||||
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
|
||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||
@@ -200,6 +213,9 @@ const SettingsScreen: React.FC = () => {
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
|
||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
||||
const [bgWake, setBgWake] = useState<boolean>(false);
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
@@ -288,11 +304,11 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
|
||||
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
setConvWindowSec(n);
|
||||
const n = parseInt(saved, 10);
|
||||
if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) {
|
||||
setSttEndpointSec(n / 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -304,6 +320,7 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
loadBargeInEnabled().then(setBargeIn).catch(() => {});
|
||||
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
|
||||
if (saved != null && saved !== '') {
|
||||
const n = parseFloat(saved);
|
||||
@@ -322,6 +339,8 @@ const SettingsScreen: React.FC = () => {
|
||||
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
|
||||
});
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||
loadBgWakeEnabled().then(setBgWake).catch(() => {});
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
@@ -1639,72 +1658,56 @@ const SettingsScreen: React.FC = () => {
|
||||
{currentSection === 'voice_input' && (<>
|
||||
<Text style={styles.sectionTitle}>Spracheingabe</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Barge-in (unterbrechen)</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das
|
||||
Mikro auf — sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du
|
||||
kannst sie waehrend des Sprechens per Wake-Wort unterbrechen.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={bargeIn}
|
||||
onValueChange={(v) => { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={bargeIn ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Stille-Toleranz</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
|
||||
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
|
||||
Nachdenken; niedriger = schnelleres Senden.
|
||||
Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s.
|
||||
Nachdenken (z.B. im Auto); niedriger = schnelleres Senden.
|
||||
Default: {(STT_ENDPOINT_DEFAULT_MS / 1000).toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10);
|
||||
setVadSilenceSec(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
|
||||
const next = Math.max(STT_ENDPOINT_MIN_MS / 1000, Math.round((sttEndpointSec - 0.5) * 10) / 10);
|
||||
setSttEndpointSec(next);
|
||||
AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000)));
|
||||
}}
|
||||
disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC}
|
||||
disabled={sttEndpointSec <= STT_ENDPOINT_MIN_MS / 1000}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.5</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
|
||||
<Text style={styles.prerollValue}>{sttEndpointSec.toFixed(1)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10);
|
||||
setVadSilenceSec(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
|
||||
const next = Math.min(STT_ENDPOINT_MAX_MS / 1000, Math.round((sttEndpointSec + 0.5) * 10) / 10);
|
||||
setSttEndpointSec(next);
|
||||
AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000)));
|
||||
}}
|
||||
disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC}
|
||||
disabled={sttEndpointSec >= STT_ENDPOINT_MAX_MS / 1000}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
|
||||
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
|
||||
Sprichst du nichts → Mikrofon zu.
|
||||
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
|
||||
@@ -1737,56 +1740,10 @@ const SettingsScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
|
||||
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
|
||||
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
|
||||
<Text style={styles.infoBtnText}>i</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.toggleHint}>
|
||||
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
|
||||
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = vadSilenceDb == null
|
||||
? VAD_SILENCE_DB_DEFAULT - 1
|
||||
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
|
||||
setVadSilenceDb(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
|
||||
}}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>
|
||||
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = vadSilenceDb == null
|
||||
? VAD_SILENCE_DB_DEFAULT + 1
|
||||
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
|
||||
setVadSilenceDb(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
|
||||
}}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{vadSilenceDb != null && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setVadSilenceDb(null);
|
||||
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
|
||||
}}
|
||||
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
|
||||
>
|
||||
<Text style={{color: '#0096FF', fontSize: 13}}>↻ Auf automatisch zuruecksetzen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* "Stille-Pegel (dB)"-Regler entfernt: der aktive Streaming-STT nutzt
|
||||
einen adaptiven Rausch-Boden (automatisch), ein manueller dB-Wert war
|
||||
wirkungslos. Rauschen-als-Wort verhindert das STT-Modell selbst
|
||||
(no_speech_prob-Filter), nicht die dB-Schwelle. */}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
@@ -1862,6 +1819,40 @@ const SettingsScreen: React.FC = () => {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Empfindlichkeit</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie leicht das Wake-Word anspringt. Hoeher = strenger = weniger
|
||||
Fehlauslösung (z.B. durch Musik/Radio, die das Mikro mithoert — der
|
||||
Echo-Canceler kann nur ARIAs eigene Stimme rausrechnen, nicht Spotify),
|
||||
aber du musst evtl. deutlicher sprechen. Default: {WAKE_THRESHOLD_DEFAULT.toFixed(2)}.
|
||||
Wird beim „Speichern + Aktivieren" uebernommen.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(WAKE_THRESHOLD_MIN, Math.round((wakeThreshold - 0.05) * 100) / 100);
|
||||
setWakeThreshold(next);
|
||||
saveWakeThreshold(next);
|
||||
}}
|
||||
disabled={wakeThreshold <= WAKE_THRESHOLD_MIN}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.05</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{wakeThreshold.toFixed(2)}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(WAKE_THRESHOLD_MAX, Math.round((wakeThreshold + 0.05) * 100) / 100);
|
||||
setWakeThreshold(next);
|
||||
saveWakeThreshold(next);
|
||||
}}
|
||||
disabled={wakeThreshold >= WAKE_THRESHOLD_MAX}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.05</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {flex: 1}]}
|
||||
@@ -1907,6 +1898,37 @@ const SettingsScreen: React.FC = () => {
|
||||
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Auch bei gesperrtem Bildschirm zuhören</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist —
|
||||
im Hintergrund sind die meisten „Trigger" Fehlalarme (TV, Husten).
|
||||
AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf
|
||||
„{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" — mehr Fehlauslöser möglich.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={bgWake}
|
||||
onValueChange={(val) => {
|
||||
setBgWake(val);
|
||||
saveBgWakeEnabled(val).catch(() => {});
|
||||
wakeWordService.setBgWakeEnabled(val);
|
||||
}}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
|
||||
Wake-Word weiterreden. Fängst du nicht innerhalb der „Stille-Toleranz"
|
||||
(Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine
|
||||
Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht
|
||||
mehr — es zählt überall derselbe Stille-Wert.
|
||||
</Text>
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ariaView — Empfaenger der von ARIA komponierten RAEUMLICHEN Ansichten (M1).
|
||||
*
|
||||
* Fluss: ARIA ruft im Brain `present_view` → Brain-Event `aria_view` → Bridge →
|
||||
* RVS `aria_view` → hier gepuffert → WorkspaceCanvas rendert Orb + Karten, die
|
||||
* auf der Flaeche materialisieren.
|
||||
*
|
||||
* Der Service haelt pro Projekt die AKTUELLE View-Spec, damit eine spaet
|
||||
* gemountete Canvas-Kachel sofort den Ist-Stand bekommt. Muster wie
|
||||
* services/codeFile.ts (Singleton, rvs.onMessage).
|
||||
*
|
||||
* Die Karten-Typen sind bewusst offen (string), damit spaetere Renderer (vnc,
|
||||
* chart, file …) ohne Service-Aenderung dazukommen. Der jeweilige Client-Renderer
|
||||
* entscheidet, was er mit einem unbekannten Typ macht (i.d.R. ignorieren).
|
||||
*/
|
||||
|
||||
import rvs, { RVSMessage } from './rvs';
|
||||
|
||||
export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'working';
|
||||
|
||||
export interface ViewMarker {
|
||||
lat: number;
|
||||
lon: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ViewCard {
|
||||
type: 'text' | 'image' | 'map' | 'code' | 'list' | string;
|
||||
title?: string;
|
||||
md?: string; // text/list
|
||||
src?: string; // image
|
||||
markers?: ViewMarker[]; // map
|
||||
path?: string; // code
|
||||
lang?: string; // code
|
||||
// Zukuenftige Kartenfelder ohne Service-Aenderung:
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export interface ViewSpec {
|
||||
cards: ViewCard[];
|
||||
orb?: OrbState;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AriaView {
|
||||
projectId: string;
|
||||
view: ViewSpec;
|
||||
clientMsgId?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
type ViewSub = (v: AriaView) => void;
|
||||
|
||||
class AriaViewService {
|
||||
private views = new Map<string, AriaView>();
|
||||
private subs: ViewSub[] = [];
|
||||
|
||||
constructor() {
|
||||
rvs.onMessage((m) => this.onMessage(m));
|
||||
}
|
||||
|
||||
private onMessage(m: RVSMessage): void {
|
||||
if (m.type !== 'aria_view') return;
|
||||
const p = (m.payload || {}) as any;
|
||||
const raw = (p.view || {}) as any;
|
||||
const cards: ViewCard[] = Array.isArray(raw.cards) ? raw.cards : [];
|
||||
if (cards.length === 0) return; // leere Ansicht ignorieren
|
||||
const view: ViewSpec = {
|
||||
cards,
|
||||
orb: raw.orb || 'speaking',
|
||||
title: raw.title || '',
|
||||
};
|
||||
const projectId: string = p.projectId || '';
|
||||
const entry: AriaView = {
|
||||
projectId,
|
||||
view,
|
||||
clientMsgId: p.clientMsgId || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
this.views.set(projectId, entry);
|
||||
this.subs.forEach((cb) => {
|
||||
try { cb(entry); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Aktuelle Ansicht eines Projekts (leer = Hauptchat). */
|
||||
getView(projectId: string): AriaView | undefined {
|
||||
return this.views.get(projectId || '');
|
||||
}
|
||||
|
||||
/** Registriert einen Listener fuer neue Ansichten. */
|
||||
subscribe(cb: ViewSub): () => void {
|
||||
this.subs.push(cb);
|
||||
return () => { this.subs = this.subs.filter((s) => s !== cb); };
|
||||
}
|
||||
|
||||
/** Ansicht eines Projekts verwerfen (z.B. wenn der User sie wegwischt). */
|
||||
clear(projectId: string): void {
|
||||
this.views.delete(projectId || '');
|
||||
}
|
||||
}
|
||||
|
||||
const ariaView = new AriaViewService();
|
||||
export default ariaView;
|
||||
+180
-27
@@ -143,23 +143,34 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
|
||||
export const VAD_SILENCE_MAX_SEC = 8.0;
|
||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
||||
|
||||
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
|
||||
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
|
||||
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
|
||||
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
|
||||
export const CONV_WINDOW_MIN_SEC = 3.0;
|
||||
export const CONV_WINDOW_MAX_SEC = 20.0;
|
||||
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
||||
|
||||
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
|
||||
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die
|
||||
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv;
|
||||
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings.
|
||||
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
|
||||
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
|
||||
// unter "Stille-Toleranz" konfigurierbar.
|
||||
export const STT_ENDPOINT_DEFAULT_MS = 2400;
|
||||
export const STT_ENDPOINT_MIN_MS = 1000;
|
||||
export const STT_ENDPOINT_MAX_MS = 4000;
|
||||
export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
|
||||
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
|
||||
|
||||
// Barge-in-Modus: darf man ARIA waehrend ihrer TTS-Antwort unterbrechen (reden)?
|
||||
// Default AUS = sauberes Halb-Duplex (ARIA spricht aus, DANN oeffnet das Mikro —
|
||||
// kein Selbst-Echo, kein Mischmasch). AN = waehrend TTS auf Wake-Wort lauschen.
|
||||
export const BARGE_IN_STORAGE_KEY = 'aria_barge_in_enabled';
|
||||
|
||||
export async function loadBargeInEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function loadSttEndpointMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||
@@ -189,18 +200,6 @@ export async function loadTtsSpeed(): Promise<number> {
|
||||
return TTS_SPEED_DEFAULT;
|
||||
}
|
||||
|
||||
export async function loadConvWindowMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
return Math.round(n * 1000);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
|
||||
}
|
||||
|
||||
async function loadVadSilenceMs(): Promise<number> {
|
||||
try {
|
||||
@@ -284,6 +283,13 @@ class AudioService {
|
||||
private pcmSampleRate: number = 24000;
|
||||
private pcmChannels: number = 1;
|
||||
private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build
|
||||
// ── TTS-Abspiel-Queue: zwei back-to-back-Antworten sollen sich NICHT
|
||||
// gegenseitig abschneiden. Eine neue hoerbare Antwort, die reinkommt waehrend
|
||||
// eine andere noch HOERBAR spielt, wird gepuffert und nach PcmPlaybackFinished
|
||||
// nachgespielt (statt via start()→stopInternal() die laufende zu cutten). ──
|
||||
private pcmAudiblePlaying: boolean = false; // eine hoerbare Antwort spielt (bis PcmPlaybackFinished)
|
||||
private pcmPlayingMsgId: string = ''; // deren messageId
|
||||
private pcmPendingStreams: Array<{ messageId: string; sampleRate: number; channels: number; chunks: string[]; final: boolean }> = [];
|
||||
private pcmBytesCollected: number = 0;
|
||||
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
|
||||
|
||||
@@ -374,6 +380,16 @@ class AudioService {
|
||||
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
|
||||
emitter.addListener('PcmPlaybackFinished', () => {
|
||||
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
|
||||
this.pcmAudiblePlaying = false;
|
||||
this.pcmPlayingMsgId = '';
|
||||
// TTS-Abspiel-Queue: steht eine naechste Antwort bereit? Dann NICHT
|
||||
// "fertig" melden (kein Wake-Word-Re-Arm / Conversation-Ende) — ARIA
|
||||
// spricht gleich weiter. Die naechste gepufferte Antwort direkt spielen.
|
||||
if (this.pcmPendingStreams.length > 0) {
|
||||
this._promoteNextPendingStream().catch(err =>
|
||||
console.warn('[Audio] promote next pending stream err:', err));
|
||||
return;
|
||||
}
|
||||
this._releaseFocusDeferred();
|
||||
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
|
||||
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
|
||||
@@ -1128,7 +1144,7 @@ class AudioService {
|
||||
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
|
||||
interrupted: !!opts.interrupted,
|
||||
location: opts.location || null,
|
||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
|
||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS,
|
||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
@@ -1401,6 +1417,23 @@ class AudioService {
|
||||
const base64 = payload.base64 || '';
|
||||
const isFinal = !!payload.final;
|
||||
|
||||
// ── TTS-Abspiel-Queue ──
|
||||
// Kommt eine NEUE hoerbare Antwort rein, waehrend eine andere noch hoerbar
|
||||
// spielt? Dann NICHT starten (start()→stopInternal() wuerde die laufende
|
||||
// abschneiden) — puffern und nach deren PcmPlaybackFinished nachspielen.
|
||||
if (!silent && this.pcmAudiblePlaying && messageId && messageId !== this.pcmPlayingMsgId) {
|
||||
let entry = this.pcmPendingStreams.find(e => e.messageId === messageId);
|
||||
if (!entry) {
|
||||
entry = { messageId, sampleRate, channels, chunks: [], final: false };
|
||||
this.pcmPendingStreams.push(entry);
|
||||
console.log('[Audio] TTS-Queue: Antwort %s wird gepuffert (spielt gerade %s)',
|
||||
messageId, this.pcmPlayingMsgId);
|
||||
}
|
||||
if (base64) entry.chunks.push(base64);
|
||||
if (isFinal) entry.final = true;
|
||||
return ''; // Live-Player nicht anfassen
|
||||
}
|
||||
|
||||
// Neuer Stream? (messageId Wechsel oder nicht aktiv)
|
||||
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
|
||||
if (this.pcmStreamActive && !silent) {
|
||||
@@ -1448,6 +1481,8 @@ class AudioService {
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestDuck().catch(() => {});
|
||||
this._firePlaybackStarted();
|
||||
this.pcmAudiblePlaying = true;
|
||||
this.pcmPlayingMsgId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,6 +1525,73 @@ class AudioService {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Naechste gepufferte TTS-Antwort abspielen (TTS-Abspiel-Queue). Wird nach
|
||||
* PcmPlaybackFinished der vorherigen aufgerufen — so sprechen zwei
|
||||
* back-to-back-Antworten NACHEINANDER statt sich abzuschneiden. */
|
||||
private async _promoteNextPendingStream(): Promise<void> {
|
||||
const entry = this.pcmPendingStreams.shift();
|
||||
if (!entry) return;
|
||||
// Inzwischen global gemutet / im Anruf / vom User gestoppt? Dann NICHT
|
||||
// hoerbar abspielen — nur cachen und die naechste promoten.
|
||||
const mutedNow = this._muted || this._pausedForCall ||
|
||||
(!!this._stoppedMessageId && this._stoppedMessageId === entry.messageId);
|
||||
console.log('[Audio] TTS-Queue: spiele gepufferte Antwort %s (%d chunks, final=%s, muted=%s)',
|
||||
entry.messageId, entry.chunks.length, entry.final, mutedNow);
|
||||
// SOFORT als "spielt" markieren (vor jedem await) — sonst koennte ein
|
||||
// gleichzeitig eintreffender Chunk einer DRITTEN Antwort in der await-Luecke
|
||||
// einen konkurrierenden Stream starten statt zu puffern.
|
||||
this.pcmPlayingMsgId = entry.messageId;
|
||||
this.pcmAudiblePlaying = !mutedNow;
|
||||
// Cache-State fuer den WAV-Build (Mund-Button-Replay) setzen.
|
||||
this.pcmMessageId = entry.messageId;
|
||||
this.pcmSampleRate = entry.sampleRate;
|
||||
this.pcmChannels = entry.channels;
|
||||
this.pcmBuffer = entry.chunks.slice();
|
||||
this.pcmBytesCollected = entry.chunks.reduce((n, c) => n + Math.floor(c.length * 0.75), 0);
|
||||
this.pcmStreamActive = true;
|
||||
if (!mutedNow && PcmStreamPlayer) {
|
||||
try {
|
||||
const prerollSec = await loadPrerollSec();
|
||||
await PcmStreamPlayer.start(entry.sampleRate, entry.channels, prerollSec);
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestDuck().catch(() => {});
|
||||
this._firePlaybackStarted();
|
||||
this.pcmAudiblePlaying = true;
|
||||
this.pcmPlayingMsgId = entry.messageId;
|
||||
for (const c of entry.chunks) {
|
||||
try { await PcmStreamPlayer.writeChunk(c); } catch (err) { console.warn('[Audio] promote writeChunk', err); }
|
||||
}
|
||||
if (entry.final) { try { await PcmStreamPlayer.end(); } catch {} }
|
||||
} catch (err) {
|
||||
console.error('[Audio] TTS-Queue promote start fehlgeschlagen:', err);
|
||||
this.pcmAudiblePlaying = false;
|
||||
this.pcmPlayingMsgId = '';
|
||||
}
|
||||
}
|
||||
// War die Antwort schon komplett (final) da: WAV cachen + State wie im
|
||||
// Normalpfad zuruecksetzen. Bei NICHT-final laeuft der Rest live ueber
|
||||
// _handlePcmChunkImpl (messageId == pcmPlayingMsgId → Normalpfad).
|
||||
if (entry.final) {
|
||||
this.pcmStreamActive = false;
|
||||
if (this.pcmBuffer.length > 0) {
|
||||
const audioPath = await this._savePcmBufferAsWav(entry.messageId).catch(() => '');
|
||||
if (audioPath) {
|
||||
this.pcmCachedListeners.forEach(cb => {
|
||||
try { cb(entry.messageId, audioPath); } catch (e) { console.warn('[Audio] pcmCached cb err:', e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
this.pcmBuffer = [];
|
||||
this.pcmBytesCollected = 0;
|
||||
this.pcmMessageId = '';
|
||||
// Nicht hoerbar abgespielt (gemutet)? Dann feuert PcmPlaybackFinished nicht
|
||||
// → die naechste gepufferte Antwort selbst nachziehen (Kette).
|
||||
if (!this.pcmAudiblePlaying) {
|
||||
await this._promoteNextPendingStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */
|
||||
private async _savePcmBufferAsWav(messageId: string): Promise<string> {
|
||||
try {
|
||||
@@ -1578,6 +1680,19 @@ class AudioService {
|
||||
// Callback wenn alle Audio-Teile abgespielt sind
|
||||
private playbackFinishedListeners: (() => void)[] = [];
|
||||
private playbackStartedListeners: (() => void)[] = [];
|
||||
// Feuert wenn eine aus der TTS-Queue NACHgespielte Antwort ihren WAV-Cache
|
||||
// geschrieben hat — der Normalpfad meldet den Pfad ueber den handlePcmChunk-
|
||||
// Rueckgabewert, gepufferte (zweite) Antworten koennen das aber nicht (ihre
|
||||
// Chunks returnen '' waehrend sie warten). Damit setzt die App auch fuer die
|
||||
// nachgespielte Antwort m.audioPath (Mund-Button-Replay).
|
||||
private pcmCachedListeners: Array<(messageId: string, audioPath: string) => void> = [];
|
||||
|
||||
onPcmCached(callback: (messageId: string, audioPath: string) => void): () => void {
|
||||
this.pcmCachedListeners.push(callback);
|
||||
return () => {
|
||||
this.pcmCachedListeners = this.pcmCachedListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
onPlaybackFinished(callback: () => void): () => void {
|
||||
this.playbackFinishedListeners.push(callback);
|
||||
@@ -1731,18 +1846,51 @@ class AudioService {
|
||||
this._stoppedMessageId = activeMsgId;
|
||||
console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId);
|
||||
}
|
||||
this.stopPlayback();
|
||||
// NUR die hoerbare Wiedergabe stoppen — Cache-Buffer behalten, damit die
|
||||
// Nachricht spaeter ueber das Lautsprecher-Symbol nachgehoert werden kann.
|
||||
this._silenceAudibleOutput();
|
||||
}
|
||||
}
|
||||
isMuted(): boolean { return this._muted; }
|
||||
|
||||
/** Mund-Button: laufendes Vorlesen SOFORT verstummen lassen, aber den
|
||||
* PCM-Cache NICHT verwerfen (der Stream cached zu Ende → Nachhoeren via
|
||||
* Lautsprecher-Symbol bleibt moeglich). Unterschied zu stopPlayback():
|
||||
* - stopt den AudioTrack IMMER (auch wenn pcmStreamActive schon false ist,
|
||||
* weil der Stream fertig empfangen wurde aber der AudioTrack seinen Buffer
|
||||
* noch sekundenlang ausspielt — genau da war der Mund-Button wirkungslos),
|
||||
* - laesst pcmBuffer/pcmMessageId/pcmStreamActive stehen (Cache laeuft weiter). */
|
||||
private _silenceAudibleOutput(): void {
|
||||
console.log('[Audio] _silenceAudibleOutput: AudioTrack+WAV stoppen, Cache behalten');
|
||||
this.audioQueue = [];
|
||||
this.isPlaying = false;
|
||||
if (this.currentSound) {
|
||||
try { this.currentSound.stop(); this.currentSound.release(); } catch {}
|
||||
this.currentSound = null;
|
||||
}
|
||||
if (this.resumeSound) {
|
||||
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
|
||||
this.resumeSound = null;
|
||||
}
|
||||
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
|
||||
PcmStreamPlayer?.stop().catch(() => {});
|
||||
// Wartende TTS-Antworten verwerfen (Mund-Button = still sein).
|
||||
this.pcmPendingStreams = [];
|
||||
this.pcmAudiblePlaying = false;
|
||||
this.pcmPlayingMsgId = '';
|
||||
stopBackgroundAudio().catch(() => {});
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.release().catch(() => {});
|
||||
}
|
||||
|
||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||
stopPlayback(): void {
|
||||
// Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/
|
||||
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
|
||||
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
|
||||
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound
|
||||
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying);
|
||||
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying
|
||||
|| this.pcmPendingStreams.length);
|
||||
if (!hasAnything) return;
|
||||
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
|
||||
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
|
||||
@@ -1776,6 +1924,11 @@ class AudioService {
|
||||
this.pcmBuffer = [];
|
||||
this.pcmBytesCollected = 0;
|
||||
this.pcmMessageId = '';
|
||||
// TTS-Abspiel-Queue verwerfen — harter Stop/Abbruch/Barge-In soll auch
|
||||
// wartende Antworten fallenlassen (sonst sprechen sie nach dem Stop weiter).
|
||||
this.pcmPendingStreams = [];
|
||||
this.pcmAudiblePlaying = false;
|
||||
this.pcmPlayingMsgId = '';
|
||||
// Audio-Focus sofort freigeben — User hat explizit abgebrochen.
|
||||
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
|
||||
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
|
||||
|
||||
@@ -162,6 +162,15 @@ export interface Project {
|
||||
updated_at: number;
|
||||
last_activity_at: number;
|
||||
turn_count: number;
|
||||
// Workspace: 'code' blendet Editor-/VNC-Kacheln ein. ARIA setzt das selbst
|
||||
// via set_project_kind; fehlt/undefined = 'chat' (nur Chat-Kachel).
|
||||
kind?: 'code' | 'chat';
|
||||
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
|
||||
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
|
||||
desktop_url?: string;
|
||||
// Automatisch: hat das Projekt Dateien in /shared/projects/<id>/? → Datei-Symbol.
|
||||
has_files?: boolean;
|
||||
file_count?: number;
|
||||
}
|
||||
|
||||
export interface ProjectStatus {
|
||||
@@ -170,6 +179,19 @@ export interface ProjectStatus {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
/** QEMU-VM eines Projekts (Registry + Live-Status). */
|
||||
export interface ProjectVm {
|
||||
name: string;
|
||||
arch: string;
|
||||
iso?: string;
|
||||
vnc_display: number;
|
||||
mem: number;
|
||||
running?: boolean;
|
||||
vnc_port?: number;
|
||||
boot_cmd?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
||||
* Key "__main__" = Hauptchat, sonst project_id. */
|
||||
export interface QueueContextStatus {
|
||||
@@ -594,14 +616,58 @@ export const brainApi = {
|
||||
});
|
||||
},
|
||||
|
||||
/** Projekt-Metadaten patchen (name / description / hidden). */
|
||||
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
|
||||
/** Projekt-Metadaten patchen (name / description / hidden / kind). */
|
||||
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden' | 'kind'>>): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
});
|
||||
},
|
||||
|
||||
/** Projekt manuell als Code-Projekt / normalen Chat markieren. */
|
||||
setProjectKind(projectId: string, kind: 'code' | 'chat'): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
body: { kind },
|
||||
});
|
||||
},
|
||||
|
||||
/** Vorhandene Code-Dateien eines Projekts auflisten (/shared/projects/<id>/). */
|
||||
listProjectFiles(projectId: string): Promise<{ projectId: string; files: { path: string; size: number }[] }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
|
||||
},
|
||||
|
||||
/** Inhalt einer Projekt-Datei laden (Text). */
|
||||
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
/** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */
|
||||
readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 });
|
||||
},
|
||||
|
||||
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
|
||||
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
|
||||
},
|
||||
addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise<ProjectVm> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 });
|
||||
},
|
||||
removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' });
|
||||
},
|
||||
bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 });
|
||||
},
|
||||
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
|
||||
},
|
||||
/** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */
|
||||
screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 });
|
||||
},
|
||||
|
||||
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
|
||||
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* desktop — Desktop-/VNC-Anbindung fuer Code-Projekte.
|
||||
*
|
||||
* Zwei Aufgaben:
|
||||
* 1. Verfuegbarkeit: `check_desktop` triggert die Bridge, `desktop_status`
|
||||
* meldet zurueck ob eine QEMU-VNC laeuft (und ggf. eine direkte URL).
|
||||
* 2. VNC-Tunnel: der noVNC-Client in der App-WebView spricht kein eigenes
|
||||
* WebSocket, sondern schickt RFB-Bytes als `vnc_input` (Base64) ueber RVS;
|
||||
* die Bridge oeffnet die TCP-Verbindung zu QEMU (host:5901) und streamt die
|
||||
* Antwort als `vnc_data` zurueck. Base64-in-JSON wie audio_pcm.
|
||||
*
|
||||
* Eine Session = ein Desktop; wir nutzen die Projekt-ID als Session-Key (leer =
|
||||
* 'main'). Muster wie services/rvs.ts (Singleton mit Listener-Listen).
|
||||
*/
|
||||
|
||||
import rvs, { RVSMessage } from './rvs';
|
||||
|
||||
export interface DesktopStatus {
|
||||
available: boolean;
|
||||
session: string;
|
||||
/** optionale direkte noVNC-URL (falls Host direkt erreichbar) */
|
||||
url?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type StatusSub = (s: DesktopStatus) => void;
|
||||
type VncDataSub = (b64: string) => void;
|
||||
|
||||
const DEFAULT_VNC_PORT = 5901;
|
||||
const sessionOf = (projectId: string) => projectId || 'main';
|
||||
|
||||
class DesktopService {
|
||||
private status: DesktopStatus = { available: false, session: '' };
|
||||
private statusSubs: StatusSub[] = [];
|
||||
private vncDataSubs: VncDataSub[] = [];
|
||||
private currentSession = '';
|
||||
|
||||
constructor() {
|
||||
rvs.onMessage((m) => this.onMessage(m));
|
||||
}
|
||||
|
||||
private onMessage(m: RVSMessage): void {
|
||||
const p = (m.payload || {}) as any;
|
||||
if (m.type === 'desktop_status') {
|
||||
this.status = {
|
||||
available: !!p.available,
|
||||
session: p.session || '',
|
||||
url: typeof p.url === 'string' ? p.url : undefined,
|
||||
message: p.message,
|
||||
};
|
||||
const s = this.status;
|
||||
this.statusSubs.forEach((cb) => cb(s));
|
||||
} else if (m.type === 'vnc_data') {
|
||||
if (this.currentSession && p.session && p.session !== this.currentSession) return;
|
||||
const b64 = typeof p.b64 === 'string' ? p.b64 : '';
|
||||
if (b64) this.vncDataSubs.forEach((cb) => cb(b64));
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): DesktopStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
subscribeStatus(cb: StatusSub): () => void {
|
||||
this.statusSubs.push(cb);
|
||||
cb(this.status);
|
||||
return () => { this.statusSubs = this.statusSubs.filter((s) => s !== cb); };
|
||||
}
|
||||
|
||||
/** Bridge fragen, ob fuer dieses Projekt ein QEMU-Desktop laeuft. */
|
||||
requestCheck(projectId: string, port: number = DEFAULT_VNC_PORT): void {
|
||||
rvs.send('check_desktop', { projectId: projectId || '', session: sessionOf(projectId), port });
|
||||
}
|
||||
|
||||
/** VNC-Tunnel oeffnen — Bridge verbindet TCP zu QEMU. */
|
||||
openVnc(projectId: string, port: number = DEFAULT_VNC_PORT): string {
|
||||
const session = sessionOf(projectId);
|
||||
this.currentSession = session;
|
||||
rvs.send('vnc_open', { session, port });
|
||||
return session;
|
||||
}
|
||||
|
||||
closeVnc(): void {
|
||||
if (this.currentSession) rvs.send('vnc_close', { session: this.currentSession });
|
||||
this.currentSession = '';
|
||||
}
|
||||
|
||||
/** RFB-Bytes (Base64) aus der noVNC-WebView an die Bridge weiterreichen. */
|
||||
sendInput(b64: string): void {
|
||||
if (!this.currentSession) return;
|
||||
rvs.send('vnc_input', { session: this.currentSession, b64 });
|
||||
}
|
||||
|
||||
/** Listener fuer eingehende RFB-Bytes (Base64) — die noVNC-WebView. */
|
||||
onVncData(cb: VncDataSub): () => void {
|
||||
this.vncDataSubs.push(cb);
|
||||
return () => { this.vncDataSubs = this.vncDataSubs.filter((s) => s !== cb); };
|
||||
}
|
||||
}
|
||||
|
||||
const desktop = new DesktopService();
|
||||
export default desktop;
|
||||
@@ -145,6 +145,17 @@ class GpsTrackingService {
|
||||
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
|
||||
const bgEnabled = await isBackgroundGpsEnabled();
|
||||
if (bgEnabled) {
|
||||
// Ohne ACCESS_BACKGROUND_LOCATION liefert watchPosition im Hintergrund
|
||||
// NICHTS (Android 10+) → der Foreground-Service allein bringt nichts, und
|
||||
// genau der Fall "Ankunft waehrend der Fahrt, Screen aus" faellt durch.
|
||||
// Deshalb erst die Permission sicherstellen (oeffnet ggf. die Android-
|
||||
// Settings fuer "Immer erlauben"), DANN den Location-Foreground-Service
|
||||
// hochziehen — der haelt den Prozess wach, sodass watchPosition + der
|
||||
// 60s-Heartbeat auch unter Doze weiterlaufen.
|
||||
const bgOk = await ensureBackgroundLocationPermission();
|
||||
if (!bgOk) {
|
||||
console.warn('[gps-track] Background-Permission fehlt — Tracking nur im Vordergrund zuverlaessig');
|
||||
}
|
||||
try { await acquireBackgroundAudio('location'); } catch {}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform, DeviceEventEmitter } from 'react-native';
|
||||
import { Platform, DeviceEventEmitter, AppState } from 'react-native';
|
||||
import rvs from './rvs';
|
||||
|
||||
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
|
||||
@@ -38,6 +38,23 @@ const noop = () => {};
|
||||
let _verbose = true;
|
||||
let _debugLogsToBridge = false;
|
||||
|
||||
// ─── Crash-Kontext ohne adb ─────────────────────────────────────────
|
||||
// Ein RUN_MARKER bleibt gesetzt, solange die App AKTIV laeuft; bei sauberem
|
||||
// Wechsel in den Hintergrund wird er geloescht. Ist er beim naechsten Start
|
||||
// noch da, ist der vorige Lauf unsauber gestorben (nativer Crash/OOM — der
|
||||
// schreibt KEINEN JS-Fehler, taucht also sonst nirgends auf). Wir melden das
|
||||
// dann via RVS mit dem letzten Breadcrumb (was die App zuletzt tat).
|
||||
const RUN_MARKER_KEY = 'aria_run_marker';
|
||||
const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
||||
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||
let _breadcrumbDirty = false;
|
||||
|
||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
||||
export function noteBreadcrumb(scope: string, message: string): void {
|
||||
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||
_breadcrumbDirty = true;
|
||||
}
|
||||
|
||||
function applyState(): void {
|
||||
console.log = _verbose ? originalLog : noop;
|
||||
}
|
||||
@@ -53,6 +70,45 @@ export async function initLogger(): Promise<void> {
|
||||
_debugLogsToBridge = d === 'true'; // default: false
|
||||
} catch {}
|
||||
applyState();
|
||||
await _initCrashDetection();
|
||||
}
|
||||
|
||||
// Native-Crash-Erkennung (ohne adb) — siehe RUN_MARKER-Kommentar oben.
|
||||
async function _initCrashDetection(): Promise<void> {
|
||||
try {
|
||||
const marker = await AsyncStorage.getItem(RUN_MARKER_KEY);
|
||||
if (marker) {
|
||||
let bc: any = {};
|
||||
try { bc = JSON.parse((await AsyncStorage.getItem(BREADCRUMB_KEY)) || '{}'); } catch {}
|
||||
const gap = bc && bc.ts ? Math.round((Date.now() - bc.ts) / 1000) : -1;
|
||||
// Verzoegert melden — RVS ist beim Boot oft noch nicht verbunden.
|
||||
setTimeout(() => {
|
||||
reportAppError({
|
||||
scope: 'app.crash-detected',
|
||||
level: 'warn',
|
||||
message: `Voriger Lauf ohne sauberes Shutdown beendet (nativer Crash/OOM?). `
|
||||
+ `Letzte Aktivitaet: [${(bc && bc.scope) || '?'}] ${(bc && bc.message) || '?'}`
|
||||
+ (gap >= 0 ? ` (vor ~${gap}s)` : ''),
|
||||
});
|
||||
}, 6000);
|
||||
}
|
||||
await AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now()));
|
||||
} catch {}
|
||||
// Breadcrumb throttled persistieren (alle 5s, nur wenn geaendert).
|
||||
setInterval(() => {
|
||||
if (_breadcrumbDirty) {
|
||||
_breadcrumbDirty = false;
|
||||
AsyncStorage.setItem(BREADCRUMB_KEY, JSON.stringify(_breadcrumb)).catch(() => {});
|
||||
}
|
||||
}, 5000);
|
||||
// Sauberer Hintergrund-Wechsel → Marker weg (kein Crash). Rueckkehr → wieder
|
||||
// scharf. So melden nur echte Aktiv-Crashes, kein normales Backgrounden.
|
||||
try {
|
||||
AppState.addEventListener('change', (s) => {
|
||||
if (s === 'background') AsyncStorage.removeItem(RUN_MARKER_KEY).catch(() => {});
|
||||
else if (s === 'active') AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now())).catch(() => {});
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function isVerboseLogging(): boolean {
|
||||
@@ -94,6 +150,7 @@ let _reportingInstalled = false;
|
||||
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||
export function reportAppError(ev: AppErrorEvent): void {
|
||||
const ts = Date.now();
|
||||
noteBreadcrumb(ev.scope, ev.message);
|
||||
try {
|
||||
rvs.send('app_log' as any, {
|
||||
ts,
|
||||
@@ -128,6 +185,9 @@ export function reportAppError(ev: AppErrorEvent): void {
|
||||
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
|
||||
* (reportAppError) gehen weiterhin IMMER durch. */
|
||||
export function reportAppDebug(scope: string, message: string): void {
|
||||
// Breadcrumb IMMER aktualisieren (auch wenn Debug-Logs-an-Bridge aus ist) —
|
||||
// fuer den Crash-Kontext beim naechsten Boot.
|
||||
noteBreadcrumb(scope, message);
|
||||
if (!_debugLogsToBridge) return;
|
||||
const ts = Date.now();
|
||||
const trimmed = String(message).slice(0, 2000);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* projectFocus — leichter Publish/Subscribe-Spiegel des aktuell fokussierten
|
||||
* Projekt-Kontexts.
|
||||
*
|
||||
* ChatScreen bleibt die Quelle der Wahrheit fuer sein eigenes Rendering und
|
||||
* publiziert hier bei jedem Focus-/Namens-/Kind-Wechsel EINWEG hinein. Der
|
||||
* Workspace-Canvas liest/abonniert das Singleton, um zu wissen welches Projekt
|
||||
* gerade aktiv ist und ob es ein Code-Projekt ist — ohne dass ChatScreen den
|
||||
* Workspace kennen oder umgebaut werden muss.
|
||||
*
|
||||
* Muster wie services/rvs.ts (Singleton mit Listener-Liste + Unsubscribe).
|
||||
*/
|
||||
|
||||
export type ProjectKind = 'code' | 'chat';
|
||||
|
||||
export interface FocusSnapshot {
|
||||
/** '' = Hauptchat, sonst Projekt-ID */
|
||||
focusedProjectId: string;
|
||||
projectNameById: Record<string, string>;
|
||||
projectKindById: Record<string, ProjectKind>;
|
||||
}
|
||||
|
||||
type Sub = (snap: FocusSnapshot) => void;
|
||||
|
||||
class ProjectFocus {
|
||||
private snap: FocusSnapshot = {
|
||||
focusedProjectId: '',
|
||||
projectNameById: {},
|
||||
projectKindById: {},
|
||||
};
|
||||
private subs: Sub[] = [];
|
||||
|
||||
// --- Getter (synchron, fuer Nicht-Reaktive Leser) ---
|
||||
|
||||
get(): FocusSnapshot {
|
||||
return this.snap;
|
||||
}
|
||||
|
||||
getFocusedProjectId(): string {
|
||||
return this.snap.focusedProjectId;
|
||||
}
|
||||
|
||||
getProjectName(id: string): string {
|
||||
return this.snap.projectNameById[id] || id;
|
||||
}
|
||||
|
||||
/** Default 'chat' — ein Projekt ist erst 'code' wenn es explizit so
|
||||
* markiert wurde (set_project_kind) oder ein Code-/Desktop-Signal kam. */
|
||||
getProjectKind(id: string): ProjectKind {
|
||||
return this.snap.projectKindById[id] || 'chat';
|
||||
}
|
||||
|
||||
// --- Publisher (von ChatScreen aufgerufen) ---
|
||||
|
||||
setFocus(id: string): void {
|
||||
if (this.snap.focusedProjectId === id) return;
|
||||
this.snap = { ...this.snap, focusedProjectId: id };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setNames(map: Record<string, string>): void {
|
||||
// Flacher Merge — behaelt bereits bekannte Namen, ueberschreibt neue.
|
||||
this.snap = {
|
||||
...this.snap,
|
||||
projectNameById: { ...this.snap.projectNameById, ...map },
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setKind(id: string, kind: ProjectKind): void {
|
||||
if (this.snap.projectKindById[id] === kind) return;
|
||||
this.snap = {
|
||||
...this.snap,
|
||||
projectKindById: { ...this.snap.projectKindById, [id]: kind },
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setKinds(map: Record<string, ProjectKind>): void {
|
||||
this.snap = {
|
||||
...this.snap,
|
||||
projectKindById: { ...this.snap.projectKindById, ...map },
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// --- Abo ---
|
||||
|
||||
/** Registriert einen Listener und liefert sofort den aktuellen Snapshot. */
|
||||
subscribe(cb: Sub): () => void {
|
||||
this.subs.push(cb);
|
||||
cb(this.snap);
|
||||
return () => {
|
||||
this.subs = this.subs.filter(s => s !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const s = this.snap;
|
||||
this.subs.forEach(cb => cb(s));
|
||||
}
|
||||
}
|
||||
|
||||
const projectFocus = new ProjectFocus();
|
||||
export default projectFocus;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* viewMode — App-Ansicht: 'compact' (klassischer Vollbild-Chat wie vor 0.2.2.0)
|
||||
* oder 'cockpit' (zoombarer Kachel-Desktop).
|
||||
*
|
||||
* Default 'compact' → fuer normale Nutzung aendert sich nichts (Mama-tauglich).
|
||||
* Umschaltbar ueber den Header-Button; persistiert in AsyncStorage. Muster wie
|
||||
* services/rvs.ts (Singleton mit Listener-Liste).
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type ViewModeValue = 'compact' | 'cockpit';
|
||||
|
||||
const KEY = 'aria_view_mode';
|
||||
type Sub = (mode: ViewModeValue) => void;
|
||||
|
||||
class ViewMode {
|
||||
private mode: ViewModeValue = 'compact';
|
||||
private subs: Sub[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor() {
|
||||
AsyncStorage.getItem(KEY).then((v) => {
|
||||
if (v === 'cockpit' || v === 'compact') this.mode = v;
|
||||
this.loaded = true;
|
||||
this.emit();
|
||||
}).catch(() => { this.loaded = true; });
|
||||
}
|
||||
|
||||
get(): ViewModeValue { return this.mode; }
|
||||
isLoaded(): boolean { return this.loaded; }
|
||||
|
||||
set(mode: ViewModeValue): void {
|
||||
if (this.mode === mode) return;
|
||||
this.mode = mode;
|
||||
AsyncStorage.setItem(KEY, mode).catch(() => {});
|
||||
this.emit();
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.set(this.mode === 'compact' ? 'cockpit' : 'compact');
|
||||
}
|
||||
|
||||
subscribe(cb: Sub): () => void {
|
||||
this.subs.push(cb);
|
||||
cb(this.mode);
|
||||
return () => { this.subs = this.subs.filter((s) => s !== cb); };
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const m = this.mode;
|
||||
this.subs.forEach((cb) => cb(m));
|
||||
}
|
||||
}
|
||||
|
||||
const viewMode = new ViewMode();
|
||||
export default viewMode;
|
||||
@@ -30,29 +30,63 @@ type PassiveListenCallback = () => void;
|
||||
|
||||
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
|
||||
|
||||
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
|
||||
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
|
||||
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
|
||||
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
|
||||
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
|
||||
|
||||
export async function loadPassiveListenMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
|
||||
}
|
||||
} catch {}
|
||||
return PASSIVE_LISTEN_DEFAULT_MS;
|
||||
}
|
||||
|
||||
export async function savePassiveListenMs(ms: number): Promise<void> {
|
||||
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
|
||||
}
|
||||
/** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
|
||||
* die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
|
||||
* sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) → ChatScreen ruft
|
||||
* dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden —
|
||||
* deshalb LÄNGER als der Hard-Cap (nur falls ein Endpoint-Event mal verloren geht
|
||||
* und der State sonst ewig 'listening' bliebe). Das alte 30s-Fenster, das lange
|
||||
* Antworten mitten im Satz kappte, ist damit raus. */
|
||||
const PASSIVE_BACKSTOP_MS = 10 * 60_000;
|
||||
|
||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||
|
||||
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
|
||||
// weniger Fehlauslösung, aber man muss deutlicher/lauter sprechen (fuehlt sich
|
||||
// "traege" an). Fehlausloeser werden ueber Speaker-ID (E3) ohnehin verworfen,
|
||||
// deshalb darf der Default empfindlicher sein. 0.45 (war 0.6/0.5). 0..1.
|
||||
export const WAKE_THRESHOLD_DEFAULT = 0.45;
|
||||
export const WAKE_THRESHOLD_MIN = 0.3;
|
||||
export const WAKE_THRESHOLD_MAX = 0.9;
|
||||
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
|
||||
|
||||
export async function loadWakeThreshold(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(WAKE_THRESHOLD_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= WAKE_THRESHOLD_MIN && n <= WAKE_THRESHOLD_MAX) return n;
|
||||
}
|
||||
} catch {}
|
||||
return WAKE_THRESHOLD_DEFAULT;
|
||||
}
|
||||
|
||||
export async function saveWakeThreshold(v: number): Promise<void> {
|
||||
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
|
||||
}
|
||||
|
||||
// Hintergrund-Wake: darf das Wake-Wort auch triggern, wenn die App im
|
||||
// Hintergrund / der Bildschirm gesperrt ist? Default AUS — im Hintergrund
|
||||
// sind die meisten „Trigger" Fehlalarme (TV, Husten, AudioFocus-Spikes).
|
||||
// AN = auch bei gesperrtem Bildschirm zuhoeren. Die native Erkennung laeuft
|
||||
// ohnehin durch (Foreground-Service + Wake-Locks) — dieser Schalter oeffnet
|
||||
// nur das JS-Gate in onWakeDetected.
|
||||
export const BG_WAKE_STORAGE_KEY = 'aria_bg_wake_enabled';
|
||||
|
||||
export async function loadBgWakeEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await AsyncStorage.getItem(BG_WAKE_STORAGE_KEY)) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(BG_WAKE_STORAGE_KEY, String(enabled));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||
@@ -76,9 +110,12 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||
hey_rhasspy: 'Hey Rhasspy',
|
||||
};
|
||||
|
||||
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
|
||||
const DEFAULT_THRESHOLD = 0.5;
|
||||
const DEFAULT_PATIENCE = 2;
|
||||
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
|
||||
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
|
||||
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
|
||||
// patience=1 statt 2: nur EIN Frame ueber Threshold noetig → deutlich schneller.
|
||||
// Speaker-ID filtert Fehlausloeser, also ist das vertretbar.
|
||||
const DEFAULT_PATIENCE = 1;
|
||||
const DEFAULT_DEBOUNCE_MS = 1500;
|
||||
|
||||
interface OpenWakeWordModule {
|
||||
@@ -118,6 +155,10 @@ class WakeWordService {
|
||||
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
|
||||
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
|
||||
private inBackground: boolean = false;
|
||||
/** Wenn true: Wake-Wort triggert auch im Hintergrund / bei gesperrtem
|
||||
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
|
||||
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
||||
private bgWakeEnabled: boolean = false;
|
||||
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
|
||||
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
||||
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
||||
@@ -125,8 +166,9 @@ class WakeWordService {
|
||||
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
|
||||
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
|
||||
private detectionInProgress: boolean = false;
|
||||
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
|
||||
* beendet den listening-State und geht zurueck zu armed. */
|
||||
/** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
|
||||
* das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
|
||||
* trotzdem, zurueck zu armed. */
|
||||
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
|
||||
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
||||
@@ -197,7 +239,10 @@ class WakeWordService {
|
||||
if (this.initInProgress) return this.initInProgress;
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
const threshold = await loadWakeThreshold();
|
||||
this.bgWakeEnabled = await loadBgWakeEnabled();
|
||||
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
|
||||
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||
@@ -283,7 +328,7 @@ class WakeWordService {
|
||||
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
|
||||
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
|
||||
* die openWakeWord faelschlich als Trigger interpretiert. */
|
||||
setResumeCooldown(ms: number = 1500): void {
|
||||
setResumeCooldown(ms: number = 500): void {
|
||||
this.cooldownUntilMs = Date.now() + ms;
|
||||
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
||||
}
|
||||
@@ -293,23 +338,37 @@ class WakeWordService {
|
||||
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
|
||||
setBackground(): void {
|
||||
this.inBackground = true;
|
||||
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
|
||||
console.log('[WakeWord] App im Hintergrund — Detections %s',
|
||||
this.bgWakeEnabled ? 'AKTIV (Hintergrund-Wake an)' : 'gesperrt');
|
||||
}
|
||||
|
||||
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
|
||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
|
||||
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
|
||||
/** Hintergrund-Wake ein/aus schalten (aus den Einstellungen). */
|
||||
setBgWakeEnabled(enabled: boolean): void {
|
||||
this.bgWakeEnabled = enabled;
|
||||
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
|
||||
}
|
||||
|
||||
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
|
||||
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
|
||||
isBgWakeEnabled(): boolean {
|
||||
return this.bgWakeEnabled;
|
||||
}
|
||||
|
||||
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
|
||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
||||
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
||||
* App-Oeffnen wurden verschluckt). */
|
||||
setForeground(): void {
|
||||
this.inBackground = false;
|
||||
this.cooldownUntilMs = Date.now() + 3000;
|
||||
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
|
||||
this.cooldownUntilMs = Date.now() + 1000;
|
||||
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
if (this.inBackground) {
|
||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
|
||||
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
|
||||
if (this.inBackground && !this.bgWakeEnabled) {
|
||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
|
||||
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
|
||||
return;
|
||||
}
|
||||
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
|
||||
@@ -441,7 +500,10 @@ class WakeWordService {
|
||||
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
|
||||
* ist, und unseren frisch re-armierten Listener killen.
|
||||
*/
|
||||
async endConversation(): Promise<void> {
|
||||
/** @param skipPassive true = KEIN passives Lauschen, direkt zurueck aufs
|
||||
* Wake-Word (armed). Fuer klare Steuerbefehle (Fast-Path) — nach
|
||||
* "nächster Titel" will Stefan kein 30s-Fenster, sondern Stop. */
|
||||
async endConversation(skipPassive: boolean = false): Promise<void> {
|
||||
if (this.state !== 'conversing') {
|
||||
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
|
||||
@@ -456,13 +518,12 @@ class WakeWordService {
|
||||
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
|
||||
|
||||
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
|
||||
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
|
||||
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
|
||||
// Anrede weitersprechen.
|
||||
const passiveMs = await loadPassiveListenMs();
|
||||
if (passiveMs > 0 && this.nativeReady) {
|
||||
this.enterPassiveListening(passiveMs);
|
||||
// Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
|
||||
// noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
|
||||
// der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
|
||||
// Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
|
||||
if (!skipPassive && this.nativeReady) {
|
||||
this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -504,10 +565,10 @@ class WakeWordService {
|
||||
this.cancelPassiveListenTimer();
|
||||
this.setState('listening');
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
|
||||
console.log('[WakeWord] Passive-Listen aktiv (Backstop %ds) — Speaker-ID gefiltert', seconds);
|
||||
import('./logger').then(m => m.reportAppDebug('wake.passive',
|
||||
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
||||
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
|
||||
`entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
||||
ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
|
||||
this.passiveListenTimer = setTimeout(() => {
|
||||
this.passiveListenTimer = null;
|
||||
this.exitPassiveListening('timeout').catch(() => {});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* AriaViewCanvas — die pannbare Flaeche, auf der ARIAs komponierte Ansicht
|
||||
* (aria_view) MATERIALISIERT: Orb oben, darunter die Karten. Erscheint als
|
||||
* Overlay ueber dem Chat, sobald ARIA present_view aufruft ("sag was → Orb denkt
|
||||
* → Karte fliegt rein"). Der erste, greifbare Vorgeschmack aufs generative
|
||||
* Cockpit (M1).
|
||||
*
|
||||
* Bedienung (NoMachine-Prinzip): 2-Finger halten + schieben bewegt die Welt,
|
||||
* Pinch zoomt. Ein-Finger-Touch geht an die Karten durch (Scrollen). Die Welt
|
||||
* traegt gerenderte/gestreamte Inhalte — interaktive native Panels rasten
|
||||
* spaeter bei Scale 1 ein (Chat bleibt separat darunter).
|
||||
*
|
||||
* Geraete-agnostisch gehalten: liest nur die ViewSpec, damit ein spaeterer Web-/
|
||||
* AR-Renderer dieselbe Spec konsumieren kann.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { ViewSpec } from '../services/ariaView';
|
||||
import Orb from './Orb';
|
||||
import CardView from './CardView';
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
interface Props {
|
||||
view: ViewSpec;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AriaViewCanvas: React.FC<Props> = ({ view, onClose }) => {
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
const savedTx = useSharedValue(0);
|
||||
const savedTy = useSharedValue(0);
|
||||
const savedScale = useSharedValue(1);
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.minPointers(2)
|
||||
.maxPointers(2)
|
||||
.onUpdate((e) => {
|
||||
tx.value = savedTx.value + e.translationX;
|
||||
ty.value = savedTy.value + e.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
savedTx.value = tx.value;
|
||||
savedTy.value = ty.value;
|
||||
});
|
||||
|
||||
const pinch = Gesture.Pinch()
|
||||
.onUpdate((e) => {
|
||||
const next = savedScale.value * e.scale;
|
||||
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next));
|
||||
})
|
||||
.onEnd(() => {
|
||||
savedScale.value = scale.value;
|
||||
});
|
||||
|
||||
const composed = Gesture.Simultaneous(pan, pinch);
|
||||
|
||||
const worldStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: tx.value },
|
||||
{ translateY: ty.value },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
}));
|
||||
|
||||
const resetCamera = () => {
|
||||
tx.value = withTiming(0);
|
||||
ty.value = withTiming(0);
|
||||
scale.value = withTiming(1);
|
||||
savedTx.value = 0;
|
||||
savedTy.value = 0;
|
||||
savedScale.value = 1;
|
||||
};
|
||||
|
||||
const cards = Array.isArray(view.cards) ? view.cards : [];
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<GestureDetector gesture={composed}>
|
||||
<Animated.View style={[styles.world, worldStyle]}>
|
||||
<View style={styles.orbWrap}>
|
||||
<Orb state={view.orb} size={110} />
|
||||
</View>
|
||||
{!!view.title && <Text style={styles.worldTitle}>{view.title}</Text>}
|
||||
<View style={styles.cards}>
|
||||
{cards.map((c, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
entering={FadeInDown.duration(420).delay(120 + i * 90)}
|
||||
>
|
||||
<CardView card={c} />
|
||||
</Animated.View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Steuerung — ausserhalb des Transforms, immer bei Scale 1 bedienbar */}
|
||||
<View style={styles.topBar} pointerEvents="box-none">
|
||||
<TouchableOpacity style={styles.iconBtn} onPress={resetCamera}>
|
||||
<Text style={styles.icon}>⤢</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconBtn} onPress={onClose}>
|
||||
<Text style={styles.icon}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.hintWrap} pointerEvents="none">
|
||||
<Text style={styles.hint}>2 Finger: schieben · Pinch: zoomen</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(6,6,16,0.94)',
|
||||
zIndex: 50,
|
||||
},
|
||||
world: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
paddingTop: 48,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
orbWrap: { marginTop: 8, marginBottom: 6 },
|
||||
worldTitle: {
|
||||
color: '#C9C9FF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
cards: { width: '100%', maxWidth: 560 },
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 12,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
iconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginLeft: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(30,30,60,0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(123,92,255,0.4)',
|
||||
},
|
||||
icon: { color: '#C9C9FF', fontSize: 18 },
|
||||
hintWrap: {
|
||||
position: 'absolute',
|
||||
bottom: 14,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
hint: {
|
||||
color: '#6A6A90',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default AriaViewCanvas;
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* CardView — rendert EINE Karte einer aria_view-Spec (M1). Schaltet nach
|
||||
* card.type auf den passenden Renderer. Unbekannte Typen werden als Text-
|
||||
* Fallback gezeigt (nie crashen).
|
||||
*
|
||||
* Bewusst dependency-leicht (v1): Markdown wird als Klartext dargestellt, Map
|
||||
* als Marker-Liste (kein Karten-Lib), Code als Monospace-Block. Spaeter koennen
|
||||
* einzelne Renderer aufgebohrt werden, ohne die Spec/den Fluss zu aendern.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { ViewCard, ViewMarker } from '../services/ariaView';
|
||||
|
||||
const ImageBody: React.FC<{ src?: string }> = ({ src }) => {
|
||||
const isUrl = !!src && /^https?:\/\//i.test(src);
|
||||
if (isUrl) {
|
||||
return <Image source={{ uri: src }} style={styles.image} resizeMode="contain" />;
|
||||
}
|
||||
return <Text style={styles.muted}>🖼️ {src || '(kein Bild)'}</Text>;
|
||||
};
|
||||
|
||||
const ListBody: React.FC<{ md?: string }> = ({ md }) => {
|
||||
const lines = (md || '')
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^\s*[-*•]\s?/, '').trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return <Text style={styles.muted}>(leer)</Text>;
|
||||
return (
|
||||
<View>
|
||||
{lines.map((l, i) => (
|
||||
<View key={i} style={styles.listRow}>
|
||||
<Text style={styles.bullet}>•</Text>
|
||||
<Text style={styles.text}>{l}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const MapBody: React.FC<{ markers?: ViewMarker[] }> = ({ markers }) => {
|
||||
const ms = Array.isArray(markers) ? markers : [];
|
||||
return (
|
||||
<View style={styles.map}>
|
||||
<Text style={styles.mapHint}>🗺️ Karte ({ms.length} Orte)</Text>
|
||||
{ms.map((m, i) => (
|
||||
<Text key={i} style={styles.text}>
|
||||
📍 {m.label || `${m.lat?.toFixed?.(4)}, ${m.lon?.toFixed?.(4)}`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBody: React.FC<{ md?: string; path?: string; lang?: string }> = ({ md, path, lang }) => (
|
||||
<View>
|
||||
{(path || lang) && (
|
||||
<Text style={styles.codeCaption}>
|
||||
{path || ''}{lang ? ` · ${lang}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
<ScrollView horizontal style={styles.codeScroll}>
|
||||
<Text style={styles.code}>{md || ''}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
const CardView: React.FC<{ card: ViewCard }> = ({ card }) => {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
{!!card.title && <Text style={styles.cardTitle}>{card.title}</Text>}
|
||||
{card.type === 'image' ? (
|
||||
<ImageBody src={card.src} />
|
||||
) : card.type === 'list' ? (
|
||||
<ListBody md={card.md} />
|
||||
) : card.type === 'map' ? (
|
||||
<MapBody markers={card.markers} />
|
||||
) : card.type === 'code' ? (
|
||||
<CodeBody md={card.md} path={card.path} lang={card.lang} />
|
||||
) : (
|
||||
<Text style={styles.text}>{card.md || ''}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: 'rgba(18,18,42,0.92)',
|
||||
borderColor: 'rgba(123,92,255,0.35)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
marginVertical: 8,
|
||||
shadowColor: '#7B5CFF',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 6,
|
||||
},
|
||||
cardTitle: { color: '#C9C9FF', fontSize: 15, fontWeight: '700', marginBottom: 8 },
|
||||
text: { color: '#E6E6F0', fontSize: 14, lineHeight: 20, flexShrink: 1 },
|
||||
muted: { color: '#8A8AB0', fontSize: 13, fontStyle: 'italic' },
|
||||
image: { width: '100%', height: 200, borderRadius: 8, backgroundColor: '#0D0D1A' },
|
||||
listRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 2 },
|
||||
bullet: { color: '#7B5CFF', marginRight: 8, fontSize: 14, lineHeight: 20 },
|
||||
map: { backgroundColor: '#0D0D1A', borderRadius: 8, padding: 10 },
|
||||
mapHint: { color: '#00B4D8', fontSize: 13, fontWeight: '600', marginBottom: 6 },
|
||||
codeCaption: { color: '#8A8AB0', fontSize: 12, marginBottom: 6 },
|
||||
codeScroll: { backgroundColor: '#0A0A14', borderRadius: 8, padding: 10 },
|
||||
code: { color: '#B9F5C9', fontFamily: 'monospace', fontSize: 12.5, lineHeight: 18 },
|
||||
});
|
||||
|
||||
export default React.memo(CardView);
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Orb — ARIAs Praesenz-Avatar (M1). Zeigt ihren Zustand (idle/listening/
|
||||
* thinking/speaking/working) als pulsierender Leucht-Kern und ist das
|
||||
* verbindende Element ueber alle Oberflaechen (App/Web/spaeter Brille).
|
||||
*
|
||||
* Reine Optik, keine Logik — der Zustand kommt von aussen (aria_view.orb bzw.
|
||||
* spaeter direkt von Audio/Wake-Word-Signalen). Dependency-leicht: nur
|
||||
* reanimated (schon installiert), kein SVG/Gradient noetig.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { OrbState } from '../services/ariaView';
|
||||
|
||||
const COLORS: Record<OrbState, string> = {
|
||||
idle: '#3A6EA5',
|
||||
listening: '#00B4D8',
|
||||
thinking: '#7B5CFF',
|
||||
speaking: '#34C759',
|
||||
working: '#FF9500',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
state?: OrbState;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const Orb: React.FC<Props> = ({ state = 'idle', size = 120 }) => {
|
||||
const pulse = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
const fast = state === 'thinking' || state === 'working';
|
||||
cancelAnimation(pulse);
|
||||
pulse.value = 1;
|
||||
pulse.value = withRepeat(
|
||||
withTiming(fast ? 1.14 : 1.07, {
|
||||
duration: fast ? 620 : 1500,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
}),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
return () => cancelAnimation(pulse);
|
||||
}, [state, pulse]);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] }));
|
||||
const color = COLORS[state] || COLORS.idle;
|
||||
|
||||
return (
|
||||
<View style={[styles.wrap, { width: size, height: size }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glow,
|
||||
{ width: size, height: size, borderRadius: size / 2, backgroundColor: color },
|
||||
animStyle,
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.ring,
|
||||
{
|
||||
width: size * 0.72,
|
||||
height: size * 0.72,
|
||||
borderRadius: size * 0.36,
|
||||
borderColor: color,
|
||||
},
|
||||
animStyle,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.core,
|
||||
{
|
||||
width: size * 0.44,
|
||||
height: size * 0.44,
|
||||
borderRadius: size * 0.22,
|
||||
backgroundColor: color,
|
||||
shadowColor: color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { alignItems: 'center', justifyContent: 'center' },
|
||||
glow: { position: 'absolute', opacity: 0.22 },
|
||||
ring: { position: 'absolute', borderWidth: 2, opacity: 0.55 },
|
||||
core: {
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
elevation: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(Orb);
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* WorkspaceDeck — die Workbench: Vollbild-Panels + Taskleisten-Dock unten.
|
||||
*
|
||||
* Statt einer Zoom-Landkarte: jedes Panel ist bildschirmfuellend und Handy-
|
||||
* optimiert, das Dock wechselt per Daumen-Tap sofort. Alle Panels sind IMMER
|
||||
* gemountet (nur das aktive ist via display sichtbar) → kein Remount, Chat
|
||||
* behaelt RVS/Audio/Queue, WebViews behalten ihren Zustand.
|
||||
*
|
||||
* Bei offener Tastatur blendet das Dock aus (mehr Platz zum Tippen).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Keyboard, StyleSheet, View } from 'react-native';
|
||||
import { TileId } from './layout';
|
||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import WorkspaceDock from './WorkspaceDock';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import FilesTile from './tiles/FilesTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import DesktopTile from './tiles/DesktopTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
panels: TileId[];
|
||||
badges?: Partial<Record<TileId, string>>;
|
||||
}
|
||||
|
||||
const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
||||
const [active, setActive] = useState<TileId>('chat');
|
||||
const [kbVisible, setKbVisible] = useState(false);
|
||||
const { loaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
||||
|
||||
// Aktives Panel pro Projekt wiederherstellen.
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const stored = getFocus();
|
||||
if (stored && panels.includes(stored)) setActive(stored);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, loaded]);
|
||||
|
||||
// Falls das aktive Panel wegfaellt → erstes nehmen.
|
||||
useEffect(() => {
|
||||
if (!panels.includes(active)) setActive(panels[0] || 'chat');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panels.join(',')]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) saveFocus(active);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const s1 = Keyboard.addListener('keyboardDidShow', () => setKbVisible(true));
|
||||
const s2 = Keyboard.addListener('keyboardDidHide', () => setKbVisible(false));
|
||||
return () => { s1.remove(); s2.remove(); };
|
||||
}, []);
|
||||
|
||||
const render = (id: TileId) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.stack}>
|
||||
{panels.map((id) => (
|
||||
<View
|
||||
key={id}
|
||||
style={[StyleSheet.absoluteFill, { display: active === id ? 'flex' : 'none' }]}
|
||||
>
|
||||
{render(id)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!kbVisible && (
|
||||
<WorkspaceDock panels={panels} active={active} badges={badges} onSelect={setActive} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
stack: { flex: 1, position: 'relative' },
|
||||
});
|
||||
|
||||
export default WorkspaceDeck;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* WorkspaceDock — die Taskleiste unten (Daumenzone). Ein Tap wechselt sofort
|
||||
* das Panel; ein animierter Indikator gleitet unter das aktive Icon. Kleine
|
||||
* Aktivitaets-Punkte (Badges) zeigen z.B. „Desktop verbunden" / „Code da".
|
||||
*
|
||||
* Das ist der Kern des Workbench-Gefuehls: Desktop-Umfang, aber Handy-schnell
|
||||
* per Daumen erreichbar — statt einer Zoom-Landkarte.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LayoutChangeEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { TileId, TILE_META } from './layout';
|
||||
|
||||
interface Props {
|
||||
panels: TileId[];
|
||||
active: TileId;
|
||||
badges?: Partial<Record<TileId, string>>; // TileId → Punkt-Farbe (undefined = kein Punkt)
|
||||
onSelect: (id: TileId) => void;
|
||||
}
|
||||
|
||||
const WorkspaceDock: React.FC<Props> = ({ panels, active, badges, onSelect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [rowW, setRowW] = useState(0);
|
||||
const n = Math.max(1, panels.length);
|
||||
const idx = Math.max(0, panels.indexOf(active));
|
||||
const slot = rowW / n;
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width);
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => ({
|
||||
width: slot,
|
||||
transform: [{ translateX: withTiming(slot * idx, { duration: 200 }) }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={[styles.dock, { paddingBottom: Math.max(insets.bottom, 6) }]}>
|
||||
<View style={styles.row} onLayout={onLayout}>
|
||||
{rowW > 0 && <Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />}
|
||||
{panels.map((id) => {
|
||||
const meta = TILE_META[id];
|
||||
const isActive = id === active;
|
||||
const badge = badges?.[id];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={id}
|
||||
style={styles.item}
|
||||
onPress={() => onSelect(id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.icon, isActive && styles.iconActive]}>{meta.icon}</Text>
|
||||
{!!badge && <View style={[styles.badge, { backgroundColor: badge }]} />}
|
||||
</View>
|
||||
<Text style={[styles.label, isActive && styles.labelActive]} numberOfLines={1}>
|
||||
{meta.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dock: {
|
||||
backgroundColor: '#0B0B18',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1E1E2E',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
height: 58,
|
||||
position: 'relative',
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: 3,
|
||||
backgroundColor: '#0096FF',
|
||||
borderBottomLeftRadius: 3,
|
||||
borderBottomRightRadius: 3,
|
||||
},
|
||||
item: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
icon: { fontSize: 22, opacity: 0.55 },
|
||||
iconActive: { opacity: 1 },
|
||||
label: { color: '#6A6A85', fontSize: 10, fontWeight: '600' },
|
||||
labelActive: { color: '#0096FF' },
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -6,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#0B0B18',
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkspaceDock;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* WorkspaceScreen — Screen-Wrapper fuer die Workbench.
|
||||
*
|
||||
* Kompakt-Modus → klassischer Vollbild-Chat (wie vor dem Umbau).
|
||||
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||
*
|
||||
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||
* Desktop gruen NUR, wenn im aktiven Projekt eine VM laeuft.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||
import codeFile from '../services/codeFile';
|
||||
import brainApi from '../services/brainApi';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import { TileId } from './layout';
|
||||
import WorkspaceDeck from './WorkspaceDeck';
|
||||
import ariaView, { AriaView } from '../services/ariaView';
|
||||
import AriaViewCanvas from './AriaViewCanvas';
|
||||
|
||||
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
|
||||
|
||||
const WorkspaceScreen: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
|
||||
const [hasCode, setHasCode] = useState(false);
|
||||
const [hasDesktop, setHasDesktop] = useState(false);
|
||||
const [view, setView] = useState<AriaView | undefined>(undefined);
|
||||
|
||||
useEffect(() => viewMode.subscribe(setMode), []);
|
||||
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||
|
||||
const pid = focus.focusedProjectId;
|
||||
|
||||
// aria_view: ARIAs komponierte Ansicht fuers fokussierte Projekt spiegeln.
|
||||
useEffect(() => {
|
||||
setView(ariaView.getView(pid));
|
||||
return ariaView.subscribe((v) => {
|
||||
if ((v.projectId || '') === (pid || '')) setView(v);
|
||||
});
|
||||
}, [pid]);
|
||||
|
||||
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||
useEffect(() => {
|
||||
setHasCode(codeFile.getFiles(pid).length > 0);
|
||||
return codeFile.subscribe((u) => {
|
||||
if ((u.projectId || '') === (pid || '')) setHasCode(true);
|
||||
});
|
||||
}, [pid]);
|
||||
|
||||
// Desktop-Signal: gruener Punkt NUR, wenn im AKTIVEN Projekt wirklich eine VM
|
||||
// laeuft (nicht generell irgendwo). Quelle ist die projektbezogene VM-Liste;
|
||||
// leichtes Nachfassen, damit Start/Stop sich zeitnah zeigt.
|
||||
useEffect(() => {
|
||||
if (!pid) { setHasDesktop(false); return; }
|
||||
let alive = true;
|
||||
const check = () => {
|
||||
brainApi.listProjectVms(pid)
|
||||
.then(r => { if (alive) setHasDesktop((r.vms || []).some(v => v.running)); })
|
||||
.catch(() => { if (alive) setHasDesktop(false); });
|
||||
};
|
||||
check();
|
||||
const t = setInterval(check, 6000);
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
}, [pid]);
|
||||
|
||||
const badges = useMemo(() => ({
|
||||
editor: hasCode ? '#0096FF' : undefined,
|
||||
vnc: hasDesktop ? '#34C759' : undefined,
|
||||
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
|
||||
|
||||
// Kompakt-Ansicht: klassischer Vollbild-Chat; Cockpit: Workbench mit Dock.
|
||||
const content =
|
||||
mode === 'compact' ? (
|
||||
<ChatScreen />
|
||||
) : (
|
||||
<WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />
|
||||
);
|
||||
|
||||
// Generative Flaeche als Overlay, sobald ARIA fuer dieses Projekt eine Ansicht
|
||||
// komponiert hat (present_view → aria_view). Chat/Cockpit bleiben darunter.
|
||||
const showView = !!view && (view.projectId || '') === (pid || '');
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{content}
|
||||
{showView && view && (
|
||||
<AriaViewCanvas
|
||||
view={view.view}
|
||||
onClose={() => {
|
||||
ariaView.clear(pid);
|
||||
setView(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceScreen;
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* editorHtml — selbstenthaltener Live-Code-Editor fuer die WebView (offline,
|
||||
* kein CDN/Bundler). Eine transparente <textarea> ueber einer <pre>-Highlight-
|
||||
* Ebene: man sieht Syntax-Highlighting UND kann tippen. Bewusst leichtgewichtig
|
||||
* (Regex-Highlighter fuer C-artige/JS/Python/Shell), damit es ohne Build-Schritt
|
||||
* inline passt.
|
||||
*
|
||||
* Bridge-Protokoll:
|
||||
* RN -> WebView window.ariaBridge.onMessage(jsonString):
|
||||
* {cmd:'setContent', content, language, version}
|
||||
* {cmd:'applyPatch', from, to, insert, version}
|
||||
* {cmd:'setLanguage', language}
|
||||
* {cmd:'setReadOnly', value}
|
||||
* WebView -> RN window.ReactNativeWebView.postMessage(jsonString):
|
||||
* {event:'ready'}
|
||||
* {event:'onEditFromUser', from, to, insert, fullText, version}
|
||||
*/
|
||||
|
||||
export const EDITOR_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #0D0D1A; }
|
||||
#wrap { position: relative; height: 100%; width: 100%; }
|
||||
#hl, #ed {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
margin: 0; border: 0; padding: 10px 12px;
|
||||
font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.45;
|
||||
white-space: pre; word-wrap: normal; overflow: auto; tab-size: 2;
|
||||
}
|
||||
#hl { color: #C8C8E0; z-index: 1; pointer-events: none; }
|
||||
#ed {
|
||||
z-index: 2; color: transparent; background: transparent; caret-color: #0096FF;
|
||||
resize: none; outline: none;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
#ed::selection { background: rgba(0,150,255,0.3); }
|
||||
.tok-cmt { color: #6A7A6A; font-style: italic; }
|
||||
.tok-str { color: #C6A972; }
|
||||
.tok-num { color: #B58BE0; }
|
||||
.tok-kw { color: #4F9CE8; font-weight: bold; }
|
||||
</style></head><body>
|
||||
<div id="wrap">
|
||||
<pre id="hl"></pre>
|
||||
<textarea id="ed" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var ed = document.getElementById('ed');
|
||||
var hl = document.getElementById('hl');
|
||||
var lang = 'text';
|
||||
var version = 0;
|
||||
var lastValue = '';
|
||||
var applyingProgrammatic = false;
|
||||
|
||||
var KW = {
|
||||
common: ['if','else','for','while','do','return','break','continue','switch','case','default','function','var','let','const','class','new','this','import','from','export','try','catch','finally','throw','typeof','instanceof','void','delete','in','of','yield','async','await','def','elif','end','then','fi','esac','local','echo','extends','implements','interface','public','private','protected','static','struct','enum','include','define','null','true','false','undefined','None','True','False','print','with','as','pass','lambda','not','and','or','is']
|
||||
};
|
||||
|
||||
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function highlight(code){
|
||||
// Token-Scan: Kommentare, Strings, Zahlen, Keywords. Bewusst simpel.
|
||||
var out = '';
|
||||
var i = 0, n = code.length;
|
||||
var kwRe = /[A-Za-z_][A-Za-z0-9_]*/;
|
||||
while(i < n){
|
||||
var c = code[i];
|
||||
var two = code.substr(i,2);
|
||||
// Zeilenkommentar // oder #
|
||||
if(two === '//' || (c === '#')){
|
||||
var j = code.indexOf('\\n', i); if(j<0) j=n;
|
||||
out += '<span class="tok-cmt">'+esc(code.slice(i,j))+'</span>'; i=j; continue;
|
||||
}
|
||||
// Blockkommentar
|
||||
if(two === '/*'){
|
||||
var k = code.indexOf('*/', i+2); k = (k<0)? n : k+2;
|
||||
out += '<span class="tok-cmt">'+esc(code.slice(i,k))+'</span>'; i=k; continue;
|
||||
}
|
||||
// Strings
|
||||
if(c === '"' || c === "'" || c === '\`'){
|
||||
var q=c, m=i+1;
|
||||
while(m<n){ if(code[m]==='\\\\'){m+=2;continue;} if(code[m]===q){m++;break;} m++; }
|
||||
out += '<span class="tok-str">'+esc(code.slice(i,m))+'</span>'; i=m; continue;
|
||||
}
|
||||
// Zahl
|
||||
if(c>='0' && c<='9'){
|
||||
var p=i+1; while(p<n && /[0-9a-fA-F.xX_]/.test(code[p])) p++;
|
||||
out += '<span class="tok-num">'+esc(code.slice(i,p))+'</span>'; i=p; continue;
|
||||
}
|
||||
// Wort / Keyword
|
||||
if(/[A-Za-z_]/.test(c)){
|
||||
var rest = code.slice(i);
|
||||
var mm = rest.match(kwRe);
|
||||
var w = mm[0];
|
||||
if(KW.common.indexOf(w) >= 0){ out += '<span class="tok-kw">'+esc(w)+'</span>'; }
|
||||
else { out += esc(w); }
|
||||
i += w.length; continue;
|
||||
}
|
||||
out += esc(c); i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function render(){
|
||||
hl.innerHTML = highlight(ed.value) + '\\n';
|
||||
hl.scrollTop = ed.scrollTop; hl.scrollLeft = ed.scrollLeft;
|
||||
}
|
||||
|
||||
function post(obj){ if(window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(obj)); }
|
||||
|
||||
// Minimalen Diff (gemeinsamer Prefix/Suffix) zwischen alt und neu.
|
||||
function diff(a, b){
|
||||
var s = 0; var maxS = Math.min(a.length, b.length);
|
||||
while(s < maxS && a[s] === b[s]) s++;
|
||||
var e = 0;
|
||||
while(e < (maxS - s) && a[a.length-1-e] === b[b.length-1-e]) e++;
|
||||
return { from: s, to: a.length - e, insert: b.slice(s, b.length - e) };
|
||||
}
|
||||
|
||||
var editTimer = null;
|
||||
ed.addEventListener('input', function(){
|
||||
render();
|
||||
if(applyingProgrammatic) return;
|
||||
if(editTimer) clearTimeout(editTimer);
|
||||
editTimer = setTimeout(function(){
|
||||
var nv = ed.value;
|
||||
var d = diff(lastValue, nv);
|
||||
lastValue = nv; version++;
|
||||
post({ event:'onEditFromUser', from:d.from, to:d.to, insert:d.insert, fullText:nv, version:version });
|
||||
}, 160);
|
||||
});
|
||||
ed.addEventListener('scroll', function(){ hl.scrollTop=ed.scrollTop; hl.scrollLeft=ed.scrollLeft; });
|
||||
|
||||
window.ariaBridge = {
|
||||
onMessage: function(json){
|
||||
var m; try { m = JSON.parse(json); } catch(e){ return; }
|
||||
if(m.cmd === 'setContent'){
|
||||
applyingProgrammatic = true;
|
||||
ed.value = m.content || '';
|
||||
lastValue = ed.value;
|
||||
if(typeof m.version === 'number') version = m.version;
|
||||
if(m.language) lang = m.language;
|
||||
render();
|
||||
applyingProgrammatic = false;
|
||||
} else if(m.cmd === 'applyPatch'){
|
||||
applyingProgrammatic = true;
|
||||
var v = ed.value;
|
||||
var from = Math.max(0, Math.min(m.from, v.length));
|
||||
var to = Math.max(from, Math.min(m.to, v.length));
|
||||
ed.value = v.slice(0, from) + (m.insert||'') + v.slice(to);
|
||||
lastValue = ed.value;
|
||||
if(typeof m.version === 'number') version = m.version;
|
||||
render();
|
||||
applyingProgrammatic = false;
|
||||
} else if(m.cmd === 'setLanguage'){
|
||||
lang = m.language || 'text'; render();
|
||||
} else if(m.cmd === 'setReadOnly'){
|
||||
ed.readOnly = !!m.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
post({ event:'ready' });
|
||||
})();
|
||||
</script></body></html>`;
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* novncHtml — noVNC-Client fuer die WebView, dessen WebSocket durch den
|
||||
* RVS-Tunnel gebrueckt wird.
|
||||
*
|
||||
* Trick: window.WebSocket wird VOR dem Laden von noVNC durch einen Shim
|
||||
* ersetzt. noVNC (RFB) glaubt, ein echtes WebSocket zu benutzen; tatsaechlich
|
||||
* gehen die RFB-Bytes als Base64 per postMessage an RN → RVS → Bridge → QEMU
|
||||
* (und zurueck). Da RFB "server-speaks-first" ist, ist die Reihenfolge robust.
|
||||
*
|
||||
* noVNC wird vom CDN geladen (das Telefon hat Internet, da es ohnehin am RVS
|
||||
* haengt). Voll-offline-Bundling waere ein spaeterer Schritt.
|
||||
*
|
||||
* Protokoll:
|
||||
* RN -> WebView window.ariaVnc.onData(b64) RFB-Bytes vom Server
|
||||
* WebView -> RN {event:'ready'} RFB initialisiert → Tunnel oeffnen
|
||||
* {event:'vnc_send', b64} RFB-Bytes an den Server
|
||||
* {event:'vnc_close'} RFB hat geschlossen
|
||||
* {event:'vnc_state', state} connected|disconnected
|
||||
*/
|
||||
|
||||
export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<style>
|
||||
* { margin:0; padding:0; }
|
||||
html, body { height:100%; background:#000; overflow:hidden; }
|
||||
#screen { width:100%; height:100%; }
|
||||
#msg { position:absolute; top:8px; left:0; right:0; text-align:center;
|
||||
color:#9090B0; font-family:sans-serif; font-size:12px; pointer-events:none; }
|
||||
</style></head><body>
|
||||
<div id="screen"></div>
|
||||
<div id="msg">Verbinde mit Desktop …</div>
|
||||
<script>
|
||||
(function(){
|
||||
function post(o){ if(window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(o)); }
|
||||
function b64FromBytes(bytes){
|
||||
var CHUNK=0x8000, parts=[];
|
||||
for(var i=0;i<bytes.length;i+=CHUNK){ parts.push(String.fromCharCode.apply(null, bytes.subarray(i,i+CHUNK))); }
|
||||
return btoa(parts.join(''));
|
||||
}
|
||||
function bytesFromB64(b64){
|
||||
var s=atob(b64), a=new Uint8Array(s.length);
|
||||
for(var i=0;i<s.length;i++) a[i]=s.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
|
||||
// --- WebSocket-Shim ---
|
||||
function BridgeSocket(url, protocols){
|
||||
this.url=url; this.protocol=''; this.readyState=0; this.binaryType='arraybuffer';
|
||||
this.onopen=null; this.onclose=null; this.onerror=null; this.onmessage=null;
|
||||
var self=this; window.__vncSocket=self;
|
||||
setTimeout(function(){ self.readyState=1; if(self.onopen) self.onopen({type:'open'}); }, 0);
|
||||
}
|
||||
BridgeSocket.CONNECTING=0; BridgeSocket.OPEN=1; BridgeSocket.CLOSING=2; BridgeSocket.CLOSED=3;
|
||||
BridgeSocket.prototype.send=function(data){
|
||||
var bytes;
|
||||
if(data instanceof ArrayBuffer) bytes=new Uint8Array(data);
|
||||
else if(ArrayBuffer.isView(data)) bytes=new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
else bytes=new Uint8Array(0);
|
||||
post({event:'vnc_send', b64:b64FromBytes(bytes)});
|
||||
};
|
||||
BridgeSocket.prototype.close=function(){
|
||||
if(this.readyState===3) return;
|
||||
this.readyState=3; if(this.onclose) this.onclose({type:'close'}); post({event:'vnc_close'});
|
||||
};
|
||||
BridgeSocket.prototype.addEventListener=function(t,fn){ this['on'+t]=fn; };
|
||||
BridgeSocket.prototype.removeEventListener=function(t){ this['on'+t]=null; };
|
||||
window.WebSocket = BridgeSocket;
|
||||
|
||||
// Eingehende Server-Bytes → in den Shim einspeisen.
|
||||
window.ariaVnc = {
|
||||
onData:function(b64){
|
||||
var sock=window.__vncSocket;
|
||||
if(!sock || !sock.onmessage) return;
|
||||
sock.onmessage({ type:'message', data: bytesFromB64(b64).buffer });
|
||||
}
|
||||
};
|
||||
|
||||
var msg=document.getElementById('msg');
|
||||
|
||||
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
|
||||
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
|
||||
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
|
||||
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
|
||||
// cp<0x100 → Keysym == Codepoint (Latin-1)
|
||||
// sonst → X11-Unicode-Keysym 0x01000000+cp
|
||||
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
|
||||
|
||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||
var RFB = mod.default;
|
||||
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
|
||||
var fit=true;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.clipViewport = false;
|
||||
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
|
||||
rfb.addEventListener('disconnect', function(e){
|
||||
msg.style.display='block'; msg.textContent='Desktop getrennt';
|
||||
post({event:'vnc_state', state:'disconnected'});
|
||||
});
|
||||
window.__rfb = rfb;
|
||||
|
||||
// Down+Up einer Taste an die VM schicken.
|
||||
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||
|
||||
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
|
||||
// per injectJavaScript.
|
||||
// char(cp) druckbares Zeichen (Codepoint)
|
||||
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
|
||||
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
|
||||
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
|
||||
window.ariaVncKey = {
|
||||
char: function(cp){ tap(cpToKeysym(cp)); },
|
||||
keysym: function(ks){ tap(ks); },
|
||||
combo: function(mods, ks){
|
||||
try{
|
||||
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
|
||||
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
|
||||
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
|
||||
}catch(_){}
|
||||
}
|
||||
};
|
||||
|
||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||
window.ariaVncCtl = {
|
||||
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||
};
|
||||
|
||||
post({event:'ready'});
|
||||
}).catch(function(err){
|
||||
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
|
||||
post({event:'vnc_state', state:'error', error:String(err)});
|
||||
});
|
||||
})();
|
||||
</script></body></html>`;
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
||||
*/
|
||||
|
||||
export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview';
|
||||
|
||||
export interface TileDef { id: TileId; title: string; icon: string }
|
||||
|
||||
export const TILE_META: Record<TileId, TileDef> = {
|
||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||
files: { id: 'files', title: 'Dateien', icon: '📁' },
|
||||
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* ChatTile — hostet die bestehende ChatScreen unveraendert als Workspace-Kachel.
|
||||
*
|
||||
* ChatScreen bleibt genau EINE Instanz (der Workspace-Tab ersetzt den alten
|
||||
* Chat-Tab) und wird nie beim Fokuswechsel remountet — sie liegt in der
|
||||
* Identity-Content-Ebene und wird nur per display ein-/ausgeblendet. So
|
||||
* behaelt sie RVS-Abos, Audio, Queue-State und Keyboard-Verhalten wie bisher.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ChatScreen from '../../screens/ChatScreen';
|
||||
|
||||
const ChatTile: React.FC = () => <ChatScreen />;
|
||||
|
||||
export default React.memo(ChatTile);
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
|
||||
*
|
||||
* Zeigt die Dateien eines Code-Projekts aus /shared/projects/<id>/:
|
||||
* - beim Oeffnen werden die BEREITS vorhandenen Dateien vom Brain geladen
|
||||
* (listProjectFiles/readProjectFile) — sonst waere der Editor leer, obwohl
|
||||
* ARIA schon Dateien geschrieben hat.
|
||||
* - live schreibt ARIA weiter → code_file-Stream aktualisiert die offene Datei.
|
||||
* Stefan kann selbst editieren → code_file_edit zurueck an die Bridge.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import codeFile from '../../services/codeFile';
|
||||
import brainApi from '../../services/brainApi';
|
||||
import { EDITOR_HTML } from '../assets/editorHtml';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function guessLang(path: string): string {
|
||||
const ext = (path.split('.').pop() || '').toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
js: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python',
|
||||
c: 'c', h: 'c', cpp: 'cpp', asm: 'asm', s: 'asm', sh: 'shell', bash: 'shell',
|
||||
html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
|
||||
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', txt: 'text',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
}
|
||||
|
||||
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
// Pfade aus dem Brain (vorhandene Dateien) — mit Live-Dateien gemergt.
|
||||
const [serverPaths, setServerPaths] = useState<string[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [loadErr, setLoadErr] = useState<string>('');
|
||||
|
||||
const readyRef = useRef(false);
|
||||
const currentPathRef = useRef<string | null>(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
|
||||
// Vereinigte, sortierte Dateiliste (Live-Spiegel + Server-Dateien).
|
||||
const files = useMemo(() => {
|
||||
const set = new Set<string>(serverPaths);
|
||||
for (const f of codeFile.getFiles(projectId)) set.add(f.path);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [serverPaths, projectId]);
|
||||
|
||||
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
|
||||
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
}, []);
|
||||
|
||||
const loadFileIntoEditor = useCallback(async (path: string | null) => {
|
||||
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
|
||||
// Live-Version bevorzugen (falls ARIA gerade schreibt), sonst vom Brain holen.
|
||||
const live = codeFile.getFile(projectId, path);
|
||||
if (live) {
|
||||
sendToWeb({ cmd: 'setContent', content: live.content, language: live.language, version: live.version });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await brainApi.readProjectFile(projectId, path);
|
||||
sendToWeb({ cmd: 'setContent', content: res.content ?? '', language: guessLang(path), version: 0 });
|
||||
} catch (e: any) {
|
||||
sendToWeb({ cmd: 'setContent', content: `// Konnte ${path} nicht laden: ${e?.message || e}`, language: 'text', version: 0 });
|
||||
}
|
||||
}, [projectId, sendToWeb]);
|
||||
|
||||
// Projektwechsel: vorhandene Dateien vom Brain laden.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadErr('');
|
||||
brainApi.listProjectFiles(projectId)
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
const paths = (res.files || []).map(f => f.path);
|
||||
setServerPaths(paths);
|
||||
setCurrentPath(prev => (prev && paths.includes(prev)) ? prev : (paths[0] ?? codeFile.getFiles(projectId)[0]?.path ?? null));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setLoadErr(String(e?.message || e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
|
||||
// Live-Updates aus dem Spiegel.
|
||||
useEffect(() => {
|
||||
return codeFile.subscribe((u) => {
|
||||
if ((u.projectId || '') !== (projectId || '')) return;
|
||||
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
|
||||
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
|
||||
if (u.path !== currentPathRef.current || !readyRef.current) return;
|
||||
if (u.patch) {
|
||||
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
|
||||
} else {
|
||||
sendToWeb({ cmd: 'setContent', content: u.content ?? '', language: u.language, version: u.version });
|
||||
}
|
||||
});
|
||||
}, [projectId, sendToWeb]);
|
||||
|
||||
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
|
||||
useEffect(() => {
|
||||
if (readyRef.current) loadFileIntoEditor(currentPath);
|
||||
}, [currentPath, loadFileIntoEditor]);
|
||||
|
||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
if (m.event === 'ready') {
|
||||
readyRef.current = true;
|
||||
loadFileIntoEditor(currentPathRef.current);
|
||||
} else if (m.event === 'onEditFromUser') {
|
||||
const path = currentPathRef.current;
|
||||
if (!path) return;
|
||||
codeFile.sendEdit(projectId, path, { from: m.from, to: m.to, insert: m.insert }, m.fullText, m.version);
|
||||
}
|
||||
}, [projectId, loadFileIntoEditor]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.tabsRow}>
|
||||
{files.length === 0 ? (
|
||||
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
|
||||
) : (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
|
||||
{files.map((path) => {
|
||||
const active = path === currentPath;
|
||||
const name = path.split('/').pop() || path;
|
||||
return (
|
||||
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
|
||||
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
<WebView
|
||||
ref={webRef}
|
||||
style={styles.web}
|
||||
originWhitelist={['*']}
|
||||
source={{ html: EDITOR_HTML, baseUrl: '' }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
androidLayerType="hardware"
|
||||
setBuiltInZoomControls={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
tabsRow: { height: 40, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1, justifyContent: 'center' },
|
||||
tabs: { alignItems: 'center', paddingHorizontal: 6 },
|
||||
noFiles: { color: '#9090B0', fontSize: 13, paddingHorizontal: 12 },
|
||||
tab: { paddingHorizontal: 12, paddingVertical: 6, marginHorizontal: 3, borderRadius: 12, backgroundColor: '#0D0D1A', maxWidth: 180 },
|
||||
tabActive: { backgroundColor: '#0096FF' },
|
||||
tabText: { color: '#9090B0', fontSize: 12, fontWeight: '600' },
|
||||
tabTextActive: { color: '#FFFFFF' },
|
||||
web: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
});
|
||||
|
||||
export default CodeEditorTile;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* DesktopTile — das Desktop-Panel eines Code-Projekts.
|
||||
*
|
||||
* Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per
|
||||
* vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden"
|
||||
* oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import brainApi, { ProjectVm } from '../../services/brainApi';
|
||||
import VncTile from './VncTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [vms, setVms] = useState<ProjectVm[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
|
||||
const [connected, setConnected] = useState<ProjectVm | null>(null);
|
||||
const [shotBusy, setShotBusy] = useState('');
|
||||
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
|
||||
setLoading(true); setErr('');
|
||||
brainApi.listProjectVms(projectId)
|
||||
.then(r => setVms(r.vms || []))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focused && !connected) load();
|
||||
}, [focused, projectId, connected, load]);
|
||||
|
||||
const boot = useCallback((vm: ProjectVm) => {
|
||||
setBusy(vm.name);
|
||||
brainApi.bootProjectVm(projectId, vm.name)
|
||||
.then(() => load())
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setBusy(''));
|
||||
}, [projectId, load]);
|
||||
|
||||
const stop = useCallback((vm: ProjectVm) => {
|
||||
setBusy(vm.name);
|
||||
brainApi.stopProjectVm(projectId, vm.name)
|
||||
.then(() => load())
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setBusy(''));
|
||||
}, [projectId, load]);
|
||||
|
||||
const screenshot = useCallback((vm: ProjectVm) => {
|
||||
setShotBusy(vm.name); setErr('');
|
||||
brainApi.screenshotProjectVm(projectId, vm.name)
|
||||
.then(r => setShot({ name: vm.name, b64: r.base64 }))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setShotBusy(''));
|
||||
}, [projectId]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>🖥️</Text>
|
||||
<Text style={styles.text}>Desktop</Text>
|
||||
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.barTitle}>Virtuelle Maschinen</Text>
|
||||
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={{ padding: 12 }}>
|
||||
{loading && vms.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||
) : err ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : !projectId ? (
|
||||
<Text style={styles.empty}>Kein aktives Projekt — wechsle in ein Projekt für dessen VMs.</Text>
|
||||
) : vms.length === 0 ? (
|
||||
<Text style={styles.empty}>
|
||||
Noch keine VM in diesem Projekt.{'\n'}
|
||||
Sag ARIA z.B. „bau eine QEMU-VM zum Testen" — sie registriert sie hier,
|
||||
dann kannst du sie starten und verbinden.
|
||||
</Text>
|
||||
) : (
|
||||
vms.map(vm => {
|
||||
const isBusy = busy === vm.name;
|
||||
return (
|
||||
<View key={vm.name} style={styles.vmRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.vmName}>
|
||||
<Text style={{ color: vm.running ? '#34C759' : '#555570' }}>●</Text> {vm.name}
|
||||
<Text style={styles.vmMeta}> {vm.arch} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
|
||||
</Text>
|
||||
<Text style={styles.vmCmd} numberOfLines={2}>{vm.boot_cmd || `aria-vm boot ${vm.name} --vnc-display ${vm.vnc_display}`}</Text>
|
||||
</View>
|
||||
<View style={styles.vmBtns}>
|
||||
{isBusy ? (
|
||||
<ActivityIndicator color="#0096FF" />
|
||||
) : vm.running ? (
|
||||
<>
|
||||
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
|
||||
{shotBusy === vm.name
|
||||
? <ActivityIndicator color="#8888AA" size="small" />
|
||||
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#E55C5C' }]}>Stop</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#34C759' }]}>Start</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
|
||||
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
|
||||
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
|
||||
{shot && (
|
||||
<Image
|
||||
source={{ uri: `data:image/png;base64,${shot.b64}` }}
|
||||
style={styles.shotImg}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
|
||||
{connected && (
|
||||
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
|
||||
<View style={styles.fs}>
|
||||
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||||
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
|
||||
<Text style={styles.fsBackText}>‹ VMs</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 },
|
||||
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 },
|
||||
vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 },
|
||||
vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' },
|
||||
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
|
||||
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
|
||||
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
||||
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||||
fs: { flex: 1, backgroundColor: '#000000' },
|
||||
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
|
||||
fsBackText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
||||
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
||||
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
||||
shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 },
|
||||
});
|
||||
|
||||
export default DesktopTile;
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
|
||||
*
|
||||
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
|
||||
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
|
||||
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import brainApi from '../../services/brainApi';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
interface FileEntry { path: string; size: number }
|
||||
|
||||
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
|
||||
|
||||
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
|
||||
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
|
||||
function iconFor(path: string): string {
|
||||
const e = ext(path);
|
||||
if (isImage(path)) return '🖼️';
|
||||
if (['md', 'txt', 'readme'].includes(e)) return '📄';
|
||||
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
|
||||
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
|
||||
return '📄';
|
||||
}
|
||||
function humanSize(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
|
||||
const [previewBusy, setPreviewBusy] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
|
||||
setLoading(true); setErr('');
|
||||
brainApi.listProjectFiles(projectId)
|
||||
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
|
||||
|
||||
const open = useCallback((f: FileEntry) => {
|
||||
setPreviewBusy(f.path); setErr('');
|
||||
if (isImage(f.path)) {
|
||||
brainApi.readProjectFileBinary(projectId, f.path)
|
||||
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setPreviewBusy(''));
|
||||
} else {
|
||||
brainApi.readProjectFile(projectId, f.path)
|
||||
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setPreviewBusy(''));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>📁</Text>
|
||||
<Text style={styles.text}>Dateien</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
|
||||
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={{ padding: 8 }}>
|
||||
{loading && files.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||
) : err ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
|
||||
) : (
|
||||
files.map(f => (
|
||||
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
|
||||
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
|
||||
{previewBusy === f.path
|
||||
? <ActivityIndicator color="#8888AA" size="small" />
|
||||
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
|
||||
<View style={styles.pvOverlay}>
|
||||
<View style={styles.pvBar}>
|
||||
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
|
||||
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}>✕</Text></TouchableOpacity>
|
||||
</View>
|
||||
{preview?.kind === 'image' ? (
|
||||
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
|
||||
) : (
|
||||
<ScrollView style={styles.pvTextWrap} horizontal>
|
||||
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 56, marginBottom: 10 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
|
||||
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
|
||||
rowIcon: { fontSize: 18 },
|
||||
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
|
||||
rowSize: { color: '#555570', fontSize: 11 },
|
||||
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
|
||||
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
|
||||
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
|
||||
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
|
||||
pvImg: { flex: 1, width: '100%' },
|
||||
pvTextWrap: { flex: 1, padding: 12 },
|
||||
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
|
||||
});
|
||||
|
||||
export default FilesTile;
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||
*
|
||||
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf
|
||||
* dem Handy voll bedienbar:
|
||||
* - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1.
|
||||
* - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur
|
||||
* liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1–F12 und
|
||||
* Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import desktop from '../../services/desktop';
|
||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||
}
|
||||
|
||||
// X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern.
|
||||
const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 };
|
||||
const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 };
|
||||
const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp);
|
||||
|
||||
// Sondertasten fuer die Fn-Leiste (Label → Keysym).
|
||||
const NAV_KEYS: { label: string; ks: number }[] = [
|
||||
{ label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 },
|
||||
{ label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 },
|
||||
{ label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 },
|
||||
{ label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 },
|
||||
{ label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d },
|
||||
];
|
||||
const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i }));
|
||||
|
||||
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const kbdRef = useRef<TextInput>(null);
|
||||
const bufRef = useRef(''); // Spiegel des TextInput-Textes
|
||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||
const [kbdOn, setKbdOn] = useState(false);
|
||||
const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar?
|
||||
const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false });
|
||||
const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures
|
||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||
|
||||
const teardown = useCallback(() => {
|
||||
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
|
||||
desktop.closeVnc();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) { teardown(); setStatus('idle'); }
|
||||
return () => teardown();
|
||||
}, [focused, teardown]);
|
||||
|
||||
// Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids
|
||||
// Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren —
|
||||
// ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck.
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true));
|
||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false));
|
||||
return () => { show.remove(); hide.remove(); };
|
||||
}, [focused]);
|
||||
|
||||
const ctl = useCallback((fn: string) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||
}, []);
|
||||
|
||||
const sendKeysym = useCallback((ks: number) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
|
||||
}, []);
|
||||
const sendCombo = useCallback((modKeysyms: number[], ks: number) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`);
|
||||
}, []);
|
||||
|
||||
// Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen.
|
||||
const activeMods = useCallback(() => {
|
||||
const m = modRef.current; const a: number[] = [];
|
||||
if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift);
|
||||
return a;
|
||||
}, []);
|
||||
const clearMods = useCallback(() => {
|
||||
if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) {
|
||||
modRef.current = { ctrl: false, alt: false, shift: false };
|
||||
setMods(modRef.current);
|
||||
}
|
||||
}, []);
|
||||
const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => {
|
||||
modRef.current = { ...modRef.current, [k]: !modRef.current[k] };
|
||||
setMods(modRef.current);
|
||||
}, []);
|
||||
|
||||
// Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern.
|
||||
const pressKey = useCallback((ks: number) => {
|
||||
const m = activeMods();
|
||||
if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks);
|
||||
}, [activeMods, sendCombo, clearMods, sendKeysym]);
|
||||
|
||||
// Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.).
|
||||
const pressChar = useCallback((cp: number) => {
|
||||
const m = activeMods();
|
||||
if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); }
|
||||
else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
|
||||
}, [activeMods, sendCombo, clearMods]);
|
||||
|
||||
// Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch
|
||||
// dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste
|
||||
// versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der
|
||||
// keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt.
|
||||
const toggleKbd = useCallback(() => {
|
||||
if (kbdOn) { Keyboard.dismiss(); }
|
||||
else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); }
|
||||
}, [kbdOn]);
|
||||
|
||||
// Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an
|
||||
// die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts.
|
||||
const onKbdChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
|
||||
const text = e.nativeEvent.text || '';
|
||||
const prev = bufRef.current;
|
||||
let i = 0;
|
||||
const min = Math.min(prev.length, text.length);
|
||||
while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++;
|
||||
for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); }
|
||||
bufRef.current = text;
|
||||
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
|
||||
}, [pressChar]);
|
||||
|
||||
// Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig
|
||||
// als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.).
|
||||
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const k = e.nativeEvent.key;
|
||||
if (k === 'Backspace') pressKey(KEYSYM.Backspace);
|
||||
else if (k === 'Enter') pressKey(KEYSYM.Enter);
|
||||
}, [pressKey]);
|
||||
|
||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
if (m.event === 'ready') {
|
||||
setStatus('connecting');
|
||||
unsubDataRef.current = desktop.onVncData((b64) => {
|
||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
});
|
||||
desktop.openVnc(projectId, port);
|
||||
} else if (m.event === 'vnc_send') {
|
||||
desktop.sendInput(m.b64);
|
||||
} else if (m.event === 'vnc_close') {
|
||||
desktop.closeVnc();
|
||||
} else if (m.event === 'vnc_state') {
|
||||
if (m.state === 'connected') setStatus('connected');
|
||||
else if (m.state === 'disconnected') setStatus('disconnected');
|
||||
}
|
||||
}, [projectId, port]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>🖥️</Text>
|
||||
<Text style={styles.text}>Desktop</Text>
|
||||
<Text style={styles.sub}>Panel öffnen zum Verbinden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const connected = status === 'connected';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<WebView
|
||||
ref={webRef}
|
||||
style={styles.web}
|
||||
originWhitelist={['*']}
|
||||
source={{ html: NOVNC_HTML, baseUrl: 'https://aria-vnc.local/' }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
mixedContentMode="always"
|
||||
androidLayerType="hardware"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
/>
|
||||
|
||||
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
|
||||
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
|
||||
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
|
||||
<TextInput
|
||||
ref={kbdRef}
|
||||
style={styles.hiddenInput}
|
||||
onChange={onKbdChange}
|
||||
onKeyPress={onKbdKeyPress}
|
||||
onSubmitEditing={() => pressKey(KEYSYM.Enter)}
|
||||
keyboardType="visible-password"
|
||||
returnKeyType="send"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
blurOnSubmit={false}
|
||||
caretHidden
|
||||
contextMenuHidden
|
||||
multiline={false}
|
||||
/>
|
||||
|
||||
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||
{connected && (
|
||||
<View style={styles.ctlBar}>
|
||||
<TouchableOpacity style={[styles.ctlBtn, keyBar && styles.ctlBtnOn]} onPress={() => setKeyBar(v => !v)} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>Fn</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⌨</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⤢</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */}
|
||||
{connected && keyBar && (
|
||||
<View style={styles.keyBar} pointerEvents="box-none">
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||
<TouchableOpacity style={[styles.key, mods.ctrl && styles.keyOn]} onPress={() => toggleMod('ctrl')} activeOpacity={0.7}><Text style={styles.keyText}>Strg</Text></TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.key, mods.alt && styles.keyOn]} onPress={() => toggleMod('alt')} activeOpacity={0.7}><Text style={styles.keyText}>Alt</Text></TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.key, mods.shift && styles.keyOn]} onPress={() => toggleMod('shift')} activeOpacity={0.7}><Text style={styles.keyText}>Shift</Text></TouchableOpacity>
|
||||
{NAV_KEYS.map(k => (
|
||||
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||
{F_KEYS.map(k => (
|
||||
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||
))}
|
||||
<TouchableOpacity style={styles.key} onPress={() => ctl('cad')} activeOpacity={0.7}><Text style={styles.keyTextSm}>Strg+Alt+Entf</Text></TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!connected && (
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<Text style={styles.overlayText}>
|
||||
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
web: { flex: 1, backgroundColor: '#000000' },
|
||||
placeholder: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
ctlBar: {
|
||||
position: 'absolute',
|
||||
top: 34,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
},
|
||||
ctlBtn: {
|
||||
backgroundColor: 'rgba(18,18,42,0.9)',
|
||||
borderColor: '#2A2A3E',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 7,
|
||||
minWidth: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||
// Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben.
|
||||
hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 },
|
||||
keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 },
|
||||
keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' },
|
||||
key: {
|
||||
backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1,
|
||||
borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||
keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' },
|
||||
keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' },
|
||||
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||
});
|
||||
|
||||
export default VncTile;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* useWorkspaceLayout — merkt sich pro Projekt die zuletzt fokussierte Kachel,
|
||||
* damit man beim Zurueckkehren in ein Code-Projekt wieder dort landet (Editor/
|
||||
* Desktop) statt immer im Chat. Persistiert nach AsyncStorage (Muster wie
|
||||
* aria_project_drafts).
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { TileId } from './layout';
|
||||
|
||||
const KEY = 'aria_workspace_layout';
|
||||
|
||||
interface Entry { focus: TileId | null }
|
||||
type LayoutMap = Record<string, Entry>;
|
||||
|
||||
const keyOf = (projectId: string) => projectId || '__main__';
|
||||
|
||||
export function useWorkspaceLayout(projectId: string) {
|
||||
const mapRef = useRef<LayoutMap>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(KEY).then((v) => {
|
||||
if (v) { try { mapRef.current = JSON.parse(v) || {}; } catch { /* ignore */ } }
|
||||
setLoaded(true);
|
||||
}).catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
const getFocus = useCallback((): TileId | null | undefined => {
|
||||
return mapRef.current[keyOf(projectId)]?.focus;
|
||||
}, [projectId]);
|
||||
|
||||
const saveFocus = useCallback((focus: TileId | null) => {
|
||||
mapRef.current = { ...mapRef.current, [keyOf(projectId)]: { focus } };
|
||||
AsyncStorage.setItem(KEY, JSON.stringify(mapRef.current)).catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
return { loaded, getFocus, saveFocus };
|
||||
}
|
||||
+957
-40
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope`
|
||||
(system | personal) zu. Sicher & reversibel — Stefan kann pro Eintrag in der
|
||||
Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden.
|
||||
|
||||
Heuristik (datengetrieben aus dem realen Bestand):
|
||||
- type=preference / fact / conversation / reminder -> personal
|
||||
- source in (seed, auto-feedback) -> system
|
||||
- type=identity -> system
|
||||
- type in (rule, tool, skill) und category in SYSTEM_CATS -> system
|
||||
- sonst -> personal (sicher: nichts leakt)
|
||||
|
||||
Aufruf im Brain-Container:
|
||||
docker exec aria-brain python3 /app/backfill_scope.py # dry-run
|
||||
docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
COLLECTION = "aria_memory"
|
||||
SYSTEM_CATS = {
|
||||
"sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten",
|
||||
"voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit",
|
||||
"pentest", "ausgabe",
|
||||
}
|
||||
|
||||
|
||||
def compute_scope(pl: dict) -> str:
|
||||
typ = pl.get("type")
|
||||
src = pl.get("source")
|
||||
cat = (pl.get("category") or "").lower()
|
||||
if typ == "preference":
|
||||
return "personal"
|
||||
if typ in ("fact", "conversation", "reminder"):
|
||||
return "personal"
|
||||
if src in ("seed", "auto-feedback"):
|
||||
return "system"
|
||||
if typ == "identity":
|
||||
return "system"
|
||||
if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS:
|
||||
return "system"
|
||||
return "personal"
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben
|
||||
c = QdrantClient(
|
||||
host=os.environ.get("QDRANT_HOST", "aria-qdrant"),
|
||||
port=int(os.environ.get("QDRANT_PORT", "6333")),
|
||||
)
|
||||
pts, _ = c.scroll(collection_name=COLLECTION, limit=5000,
|
||||
with_payload=True, with_vectors=False)
|
||||
|
||||
per_scope: dict[str, list] = {"system": [], "personal": []}
|
||||
pinned_examples = Counter()
|
||||
skipped = 0
|
||||
for p in pts:
|
||||
pl = p.payload or {}
|
||||
if pl.get("scope") in ("system", "personal") and not force:
|
||||
skipped += 1
|
||||
continue
|
||||
scope = compute_scope(pl)
|
||||
per_scope[scope].append(p.id)
|
||||
if pl.get("pinned"):
|
||||
pinned_examples[(scope, pl.get("source"), pl.get("type"),
|
||||
pl.get("category"))] += 1
|
||||
|
||||
print(f"total={len(pts)} skipped(already set)={skipped}")
|
||||
print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
|
||||
print("pinned split (scope, source, type, category):")
|
||||
for k, v in sorted(pinned_examples.items()):
|
||||
print(" ", k, v)
|
||||
|
||||
if not apply:
|
||||
print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
|
||||
return
|
||||
|
||||
for scope, ids in per_scope.items():
|
||||
if not ids:
|
||||
continue
|
||||
c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids)
|
||||
print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -149,14 +149,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
||||
)
|
||||
|
||||
try:
|
||||
agent = agent_factory()
|
||||
reply, _, _ = agent.chat(prompt, source="trigger")
|
||||
events = agent.pop_events()
|
||||
# WICHTIG: agent.chat() ist ein SYNCHRONER, blockierender Aufruf (Proxy-
|
||||
# HTTP mit bis zu 24h Read-Timeout). NIEMALS direkt im async-Loop —
|
||||
# sonst friert ein einziger getriggerter Turn den GESAMTEN Brain ein
|
||||
# (kein /health, kein weiterer Request). Wie der /chat-Pfad in den
|
||||
# Executor auslagern, damit der Event-Loop frei bleibt.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _run_turn():
|
||||
a = agent_factory()
|
||||
rep, *_rest = a.chat(prompt, source="trigger")
|
||||
return rep, a.pop_events()
|
||||
|
||||
reply, events = await loop.run_in_executor(None, _run_turn)
|
||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
||||
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
||||
except Exception as e:
|
||||
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
||||
|
||||
+344
-16
@@ -39,6 +39,7 @@ import background as background_mod
|
||||
import oauth as oauth_mod
|
||||
import seed_rules as seed_rules_mod
|
||||
import projects as projects_mod
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -189,6 +190,7 @@ class MemoryIn(BaseModel):
|
||||
pinned: bool = False
|
||||
category: str = ""
|
||||
source: str = "manual"
|
||||
scope: str = "personal" # system | personal — steuert Bootstrap-Export
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
conversation_id: Optional[str] = None
|
||||
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
|
||||
@@ -202,6 +204,7 @@ class MemoryUpdate(BaseModel):
|
||||
content: Optional[str] = None
|
||||
pinned: Optional[bool] = None
|
||||
category: Optional[str] = None
|
||||
scope: Optional[str] = None # system | personal
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@@ -213,6 +216,7 @@ class MemoryOut(BaseModel):
|
||||
pinned: bool
|
||||
category: str
|
||||
source: str
|
||||
scope: str = "personal"
|
||||
tags: List[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -327,6 +331,7 @@ def memory_save(body: MemoryIn):
|
||||
pinned=body.pinned,
|
||||
category=body.category,
|
||||
source=body.source,
|
||||
scope=body.scope,
|
||||
tags=body.tags,
|
||||
conversation_id=body.conversation_id,
|
||||
attachments=body.attachments or [],
|
||||
@@ -352,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
|
||||
existing.pinned = body.pinned
|
||||
if body.category is not None:
|
||||
existing.category = body.category
|
||||
if body.scope is not None:
|
||||
existing.scope = body.scope
|
||||
if body.tags is not None:
|
||||
existing.tags = body.tags
|
||||
|
||||
@@ -536,12 +543,23 @@ def memory_import_files():
|
||||
# Wiederherstellen einer schlanken ARIA nach Wipe.
|
||||
|
||||
@app.get("/memory/export-bootstrap")
|
||||
def memory_export_bootstrap():
|
||||
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download."""
|
||||
def memory_export_bootstrap(scope: str = "system"):
|
||||
"""Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
|
||||
|
||||
scope='system' → nur generische Regeln (fuer ein frisches System),
|
||||
scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
|
||||
scope='all' → alles pinned (Vollbackup).
|
||||
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
|
||||
s = store()
|
||||
pinned = s.list_pinned()
|
||||
if scope == "all":
|
||||
pinned = s.list_pinned()
|
||||
elif scope in ("system", "personal"):
|
||||
pinned = s.list_pinned_by_scope(scope)
|
||||
else:
|
||||
raise HTTPException(400, f"Ungueltiger scope: {scope}")
|
||||
return {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"scope": scope,
|
||||
"exported_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).isoformat(),
|
||||
@@ -554,6 +572,7 @@ def memory_export_bootstrap():
|
||||
"pinned": True,
|
||||
"category": p.category,
|
||||
"source": p.source,
|
||||
"scope": p.scope,
|
||||
"tags": p.tags,
|
||||
}
|
||||
for p in pinned
|
||||
@@ -563,13 +582,18 @@ def memory_export_bootstrap():
|
||||
|
||||
class BootstrapBundle(BaseModel):
|
||||
version: int = 1
|
||||
scope: Optional[str] = None # system | personal | all (aus dem Export)
|
||||
memories: List[dict]
|
||||
|
||||
|
||||
@app.post("/memory/import-bootstrap")
|
||||
def memory_import_bootstrap(body: BootstrapBundle):
|
||||
"""Loescht alle pinned Memories und importiert die im Bundle.
|
||||
Cold Memory (unpinned) bleibt unangetastet.
|
||||
"""Importiert ein Bootstrap-Bundle scope-sicher.
|
||||
|
||||
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
|
||||
gehoert — ein System-Import laesst also die persoenlichen pinned Memories
|
||||
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
|
||||
(Vollbackup) werden alle pinned ersetzt.
|
||||
|
||||
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
|
||||
Caller soll erst exportieren und dann importieren.
|
||||
@@ -579,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
||||
|
||||
s = store()
|
||||
e = embedder()
|
||||
|
||||
# Alle aktuell pinned Punkte loeschen
|
||||
from qdrant_client.http import models as qm
|
||||
from memory.vector_store import COLLECTION
|
||||
|
||||
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
|
||||
bundle_scope = body.scope
|
||||
if bundle_scope not in ("system", "personal", "all"):
|
||||
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
|
||||
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
|
||||
|
||||
# Nur die pinned Punkte des betroffenen scope loeschen.
|
||||
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
|
||||
if bundle_scope in ("system", "personal"):
|
||||
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
|
||||
s.client.delete(
|
||||
collection_name=COLLECTION,
|
||||
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
|
||||
])),
|
||||
points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
|
||||
)
|
||||
|
||||
# Neue Punkte einspeisen
|
||||
# Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
|
||||
created = 0
|
||||
for m in body.memories:
|
||||
content = (m.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
|
||||
point = MemoryPoint(
|
||||
id="",
|
||||
type=m.get("type", "fact"),
|
||||
@@ -604,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
||||
pinned=True,
|
||||
category=m.get("category", ""),
|
||||
source=m.get("source", "bootstrap-import"),
|
||||
scope=mscope,
|
||||
tags=list(m.get("tags", [])),
|
||||
)
|
||||
vec = e.embed(content)
|
||||
s.upsert(point, vec)
|
||||
created += 1
|
||||
|
||||
return {"created": created, "deleted_previous_pinned": True}
|
||||
return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
|
||||
|
||||
|
||||
# ─── Conversation-Loop ──────────────────────────────────────────────
|
||||
@@ -636,6 +669,18 @@ class ChatOut(BaseModel):
|
||||
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
|
||||
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
|
||||
speak: bool = True
|
||||
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
|
||||
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
|
||||
converse: bool = True
|
||||
# Stellt ARIA eine blockierende Rueckfrage (braucht Stefans Antwort, bevor der
|
||||
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
|
||||
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
|
||||
awaiting_reply: bool = False
|
||||
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
|
||||
# Wake-Word-Listener komplett (Mikro frei).
|
||||
wake_off: bool = False
|
||||
# "Wake-Word an" per Befehl (Text/Aufnahme-Button) → App startet den Listener.
|
||||
wake_on: bool = False
|
||||
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||
# UI landet.
|
||||
@@ -719,7 +764,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||
loop = asyncio.get_running_loop()
|
||||
reply, answered_by, speak = await loop.run_in_executor(
|
||||
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: a.chat(
|
||||
body.message, source=body.source, project_id=pid,
|
||||
@@ -743,6 +788,10 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
project_id=pid,
|
||||
answered_by=answered_by,
|
||||
speak=speak,
|
||||
converse=converse,
|
||||
awaiting_reply=awaiting_reply,
|
||||
wake_off=(answered_by == "wake-off"),
|
||||
wake_on=(answered_by == "wake-on"),
|
||||
)
|
||||
finally:
|
||||
_project_pending[pid] = [
|
||||
@@ -760,15 +809,49 @@ def projects_queue_status():
|
||||
|
||||
# ── Projekte ────────────────────────────────────────────────────────
|
||||
|
||||
def _project_file_count(pid: str) -> int:
|
||||
"""Anzahl Dateien in /shared/projects/<pid>/ (rekursiv, gecappt). 0 = leer."""
|
||||
base = os.path.join("/shared/projects", pid or "")
|
||||
if not os.path.isdir(base):
|
||||
return 0
|
||||
cnt = 0
|
||||
try:
|
||||
for _dp, dns, fns in os.walk(base):
|
||||
dns[:] = [d for d in dns if d not in (".git", "node_modules", "__pycache__", ".venv", "venv")]
|
||||
cnt += len(fns)
|
||||
if cnt > 999:
|
||||
return 999
|
||||
except Exception:
|
||||
return 0
|
||||
return cnt
|
||||
|
||||
|
||||
def _enrich_projects(projects: list) -> list:
|
||||
"""Ergaenzt has_files + file_count pro Projekt (fuer das Datei-Symbol in der
|
||||
Liste). Das ersetzt das manuelle Code-Flag als primaeren Code-Indikator."""
|
||||
for p in projects or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
c = _project_file_count(p.get("id") or "")
|
||||
p["file_count"] = c
|
||||
p["has_files"] = c > 0
|
||||
return projects
|
||||
|
||||
|
||||
@app.get("/projects/status")
|
||||
def projects_status():
|
||||
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
|
||||
return projects_mod.status()
|
||||
st = projects_mod.status()
|
||||
_enrich_projects(st.get("projects", []))
|
||||
if st.get("active"):
|
||||
_enrich_projects([st["active"]])
|
||||
return st
|
||||
|
||||
|
||||
@app.get("/projects/list")
|
||||
def projects_list(include_archived: bool = False):
|
||||
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
|
||||
return {"projects": _enrich_projects(
|
||||
projects_mod.list_projects(include_archived=include_archived))}
|
||||
|
||||
|
||||
class ProjectCreateBody(BaseModel):
|
||||
@@ -818,17 +901,262 @@ class ProjectUpdateBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
hidden: Optional[bool] = None
|
||||
kind: Optional[str] = None # 'code' | 'chat' — manuell setzbar (App/Diagnostic)
|
||||
|
||||
|
||||
@app.patch("/projects/{project_id}")
|
||||
def projects_update(project_id: str, body: ProjectUpdateBody):
|
||||
patch = body.dict(exclude_unset=True)
|
||||
if "kind" in patch and patch["kind"] not in ("code", "chat", None):
|
||||
raise HTTPException(status_code=400, detail="kind muss 'code' oder 'chat' sein")
|
||||
p = projects_mod.update_project(project_id, patch)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||
return p
|
||||
|
||||
|
||||
# ── Code-Dateien eines Projekts (/shared/projects/<pid>/) ───────────
|
||||
# Der Live-Editor streamt ARIAs Writes; diese Endpoints liefern zusaetzlich die
|
||||
# BEREITS vorhandenen Dateien, damit der Editor beim Oeffnen nicht leer ist.
|
||||
_PROJECT_FILES_ROOT = "/shared/projects"
|
||||
_PROJECT_FILE_MAX = 512 * 1024
|
||||
|
||||
|
||||
def _project_dir(project_id: str) -> str:
|
||||
base = os.path.realpath(os.path.join(_PROJECT_FILES_ROOT, project_id or ""))
|
||||
root = os.path.realpath(_PROJECT_FILES_ROOT)
|
||||
if base != root and not base.startswith(root + os.sep):
|
||||
raise HTTPException(status_code=400, detail="ungueltige project_id")
|
||||
return base
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/files")
|
||||
def project_files(project_id: str):
|
||||
base = _project_dir(project_id)
|
||||
out = []
|
||||
if os.path.isdir(base):
|
||||
for dirpath, dirs, files in os.walk(base):
|
||||
dirs[:] = [d for d in dirs if d not in
|
||||
(".git", "node_modules", "__pycache__", ".venv", "venv")]
|
||||
for f in files:
|
||||
full = os.path.join(dirpath, f)
|
||||
rel = os.path.relpath(full, base).replace("\\", "/")
|
||||
try:
|
||||
sz = os.path.getsize(full)
|
||||
except OSError:
|
||||
sz = 0
|
||||
out.append({"path": rel, "size": sz})
|
||||
out.sort(key=lambda x: x["path"])
|
||||
return {"projectId": project_id, "files": out}
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/file")
|
||||
def project_file(project_id: str, path: str, binary: bool = False):
|
||||
base = _project_dir(project_id)
|
||||
target = os.path.realpath(os.path.join(base, path))
|
||||
if target != base and not target.startswith(base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
|
||||
if not os.path.isfile(target):
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
# Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor.
|
||||
if binary:
|
||||
import base64
|
||||
import mimetypes
|
||||
if os.path.getsize(target) > 8 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)")
|
||||
with open(target, "rb") as f:
|
||||
data = f.read()
|
||||
mime, _ = mimetypes.guess_type(target)
|
||||
return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream",
|
||||
"base64": base64.b64encode(data).decode("ascii")}
|
||||
if os.path.getsize(target) > _PROJECT_FILE_MAX:
|
||||
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return {"projectId": project_id, "path": path, "content": content}
|
||||
|
||||
|
||||
# ── QEMU-VMs pro Projekt ────────────────────────────────────────────
|
||||
# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH
|
||||
# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste.
|
||||
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
|
||||
|
||||
|
||||
def _docker_gateway() -> str:
|
||||
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
|
||||
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
|
||||
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
|
||||
try:
|
||||
import socket as _sock
|
||||
import struct as _struct
|
||||
with open("/proc/net/route") as f:
|
||||
for line in f.readlines()[1:]:
|
||||
fields = line.strip().split()
|
||||
if len(fields) >= 3 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _ssh_host(*cmd: str, timeout: int = 25):
|
||||
import subprocess
|
||||
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
||||
_ARIA_VM_HOST, *[str(c) for c in cmd]]
|
||||
try:
|
||||
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout or "", r.stderr or ""
|
||||
except Exception as exc:
|
||||
return 1, "", str(exc)
|
||||
|
||||
|
||||
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
||||
return _ssh_host("aria-vm", *args, timeout=timeout)
|
||||
|
||||
|
||||
def _vm_running_names() -> set:
|
||||
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
||||
names = set()
|
||||
if rc == 0:
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and "laeuft" in line:
|
||||
names.add(parts[0])
|
||||
return names
|
||||
|
||||
|
||||
class VmAddBody(BaseModel):
|
||||
name: str
|
||||
arch: str = "i386"
|
||||
iso: str = ""
|
||||
floppy: str = ""
|
||||
disk: str = ""
|
||||
vnc_display: int = 1
|
||||
mem: int = 1024
|
||||
create_disk: bool = False
|
||||
size: str = "10G"
|
||||
|
||||
|
||||
def _vm_boot_args(v: dict) -> list:
|
||||
args = ["boot", v.get("name", "?"),
|
||||
"--vnc-display", str(v.get("vnc_display", 1)),
|
||||
"--mem", str(v.get("mem", 1024))]
|
||||
if v.get("disk"):
|
||||
args += ["--disk", v["disk"]]
|
||||
if v.get("floppy"):
|
||||
args += ["--floppy", v["floppy"]]
|
||||
if v.get("iso"):
|
||||
args += ["--iso", v["iso"]]
|
||||
return args
|
||||
|
||||
|
||||
def _vm_boot_cmd(v: dict) -> str:
|
||||
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
|
||||
return "aria-vm " + " ".join(_vm_boot_args(v))
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/vms")
|
||||
def project_vms_list(project_id: str):
|
||||
vms = [dict(v) for v in project_vms_mod.list_vms(project_id)]
|
||||
running = _vm_running_names()
|
||||
for v in vms:
|
||||
v["running"] = v.get("name") in running
|
||||
v["vnc_port"] = 5900 + int(v.get("vnc_display", 1))
|
||||
v["boot_cmd"] = _vm_boot_cmd(v)
|
||||
return {"projectId": project_id, "vms": vms}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms")
|
||||
def project_vm_add(project_id: str, body: VmAddBody):
|
||||
try:
|
||||
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
|
||||
body.floppy, body.disk, body.vnc_display, body.mem)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if body.create_disk:
|
||||
rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size)
|
||||
vm["create_result"] = out.strip() or err.strip()
|
||||
vm["create_ok"] = (rc == 0)
|
||||
return vm
|
||||
|
||||
|
||||
@app.delete("/projects/{project_id}/vms/{name}")
|
||||
def project_vm_remove(project_id: str, name: str, purge: bool = False):
|
||||
ok = project_vms_mod.remove_vm(project_id, name)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}")
|
||||
if purge:
|
||||
_ssh_aria_vm("rm", name)
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms/{name}/boot")
|
||||
def project_vm_boot(project_id: str, name: str):
|
||||
vm = project_vms_mod.get_vm(project_id, name)
|
||||
if not vm:
|
||||
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
|
||||
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
|
||||
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
|
||||
boot_args = _vm_boot_args(vm)
|
||||
gw = _docker_gateway()
|
||||
if gw:
|
||||
boot_args += ["--vnc-bind", gw]
|
||||
rc, out, err = _ssh_aria_vm(*boot_args, timeout=40)
|
||||
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
|
||||
"vnc_bind": gw or "127.0.0.1", "output": (out.strip() or err.strip())[:500]}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms/{name}/stop")
|
||||
def project_vm_stop(project_id: str, name: str):
|
||||
rc, out, err = _ssh_aria_vm("stop", name, timeout=25)
|
||||
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
||||
def project_vm_screenshot(project_id: str, name: str):
|
||||
"""Macht einen Screenshot der laufenden VM und liefert ihn als Base64.
|
||||
|
||||
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend — nicht
|
||||
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
|
||||
die Datei danach per SSH (base64) — funktioniert unabhaengig von Volume-
|
||||
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
|
||||
import base64
|
||||
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
||||
if rc != 0:
|
||||
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
||||
path = ""
|
||||
for line in out.splitlines():
|
||||
if line.startswith("screenshot="):
|
||||
path = line.split("=", 1)[1].strip()
|
||||
if not path:
|
||||
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
||||
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
|
||||
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
|
||||
if rc2 != 0 or not b64.strip():
|
||||
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
|
||||
b64 = b64.strip()
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
|
||||
fname = os.path.basename(path)
|
||||
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
|
||||
proj_rel = ""
|
||||
try:
|
||||
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
||||
os.makedirs(shots_dir, exist_ok=True)
|
||||
with open(os.path.join(shots_dir, fname), "wb") as f:
|
||||
f.write(data)
|
||||
proj_rel = "screenshots/" + fname
|
||||
except Exception:
|
||||
proj_rel = ""
|
||||
return {"ok": True, "name": name, "filename": fname,
|
||||
"projectPath": proj_rel, "base64": b64}
|
||||
|
||||
|
||||
@app.get("/conversation/stats")
|
||||
def conversation_stats():
|
||||
return conversation().stats()
|
||||
|
||||
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
|
||||
content — eigentlicher Text (wird embedded)
|
||||
pinned — bool, True = Hot Memory (immer in Prompt)
|
||||
source — import | conversation | manual
|
||||
scope — system | personal. system = generische Regeln, die JEDER
|
||||
braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit,
|
||||
Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs-
|
||||
daten, Projekte). Steuert den getrennten Bootstrap-Export.
|
||||
tags — Liste von Strings
|
||||
created_at, updated_at — ISO-Strings
|
||||
conversation_id — optional, nur fuer type=conversation
|
||||
@@ -55,6 +59,7 @@ class MemoryPoint:
|
||||
pinned: bool = False
|
||||
category: str = ""
|
||||
source: str = "manual"
|
||||
scope: str = "personal" # system | personal — steuert Bootstrap-Export
|
||||
tags: List[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
@@ -74,6 +79,7 @@ class MemoryPoint:
|
||||
"pinned": self.pinned,
|
||||
"category": self.category,
|
||||
"source": self.source,
|
||||
"scope": self.scope,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
@@ -94,6 +100,7 @@ class MemoryPoint:
|
||||
pinned=payload.get("pinned", False),
|
||||
category=payload.get("category", ""),
|
||||
source=payload.get("source", "manual"),
|
||||
scope=payload.get("scope", "personal"),
|
||||
tags=payload.get("tags", []),
|
||||
created_at=payload.get("created_at", ""),
|
||||
updated_at=payload.get("updated_at", ""),
|
||||
@@ -120,14 +127,23 @@ class VectorStore:
|
||||
collection_name=COLLECTION,
|
||||
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
|
||||
)
|
||||
# Indexe fuer typische Filter-Felder
|
||||
for field_name in ("type", "pinned", "category", "source", "migration_key"):
|
||||
# Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
|
||||
# einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope).
|
||||
self._ensure_indexes()
|
||||
|
||||
def _ensure_indexes(self):
|
||||
for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"):
|
||||
schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned"
|
||||
else qm.PayloadSchemaType.KEYWORD)
|
||||
try:
|
||||
self.client.create_payload_index(
|
||||
collection_name=COLLECTION,
|
||||
field_name=field_name,
|
||||
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned"
|
||||
else qm.PayloadSchemaType.BOOL,
|
||||
field_schema=schema,
|
||||
)
|
||||
except Exception:
|
||||
# Index existiert bereits — kein Problem.
|
||||
pass
|
||||
|
||||
# ─── Schreib-Operationen ─────────────────────────────────────────
|
||||
|
||||
@@ -164,6 +180,38 @@ class VectorStore:
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
|
||||
]))
|
||||
|
||||
def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]:
|
||||
"""Alle pinned Punkte eines scope (system | personal). Fuer den
|
||||
getrennten Bootstrap-Export."""
|
||||
return self._scroll(filter=qm.Filter(must=[
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
|
||||
qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)),
|
||||
]))
|
||||
|
||||
def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]:
|
||||
"""Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den
|
||||
System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs-
|
||||
daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via
|
||||
memory_search — statt Stefan nach etwas zu fragen, das schon da ist.
|
||||
|
||||
Bewusst NUR die deliberat gespeicherten Punkte:
|
||||
- nicht pinned (die sind eh schon voll im Prompt),
|
||||
- kein type=conversation (Chat-Mitschnitte),
|
||||
- kein source=distilled (die 100e auto-destillierten Gespraechs-
|
||||
Fakten — die traegt das semantische Auto-Retrieval, sie hier
|
||||
als Titel zu listen wuerde nur Kontext fressen).
|
||||
So bleibt der Index klein (Dutzende statt Hunderte Zeilen)."""
|
||||
return self._scroll(
|
||||
filter=qm.Filter(
|
||||
must_not=[
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
|
||||
qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")),
|
||||
qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")),
|
||||
]
|
||||
),
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
|
||||
return self._scroll(
|
||||
filter=qm.Filter(must=[
|
||||
|
||||
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
|
||||
type_="preference", title=f"User: {btitle}",
|
||||
content=btext, category="allgemein",
|
||||
migration_key=f"{source_file}/general-{idx}",
|
||||
scope="personal",
|
||||
))
|
||||
else:
|
||||
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
|
||||
@@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
|
||||
type_="preference", title=title,
|
||||
content=content, category=cat_key,
|
||||
migration_key=f"{source_file}/{cat_key}",
|
||||
scope="personal",
|
||||
))
|
||||
return points
|
||||
|
||||
@@ -283,7 +285,11 @@ def _mk(
|
||||
migration_key: str,
|
||||
pinned: bool = True,
|
||||
category: str = "",
|
||||
scope: str = "system",
|
||||
) -> MemoryPoint:
|
||||
# scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst
|
||||
# (Identitaet, Sicherheit, Architektur) — das braucht jedes System.
|
||||
# USER.md-Praeferenzen sind personal und uebergeben scope='personal'.
|
||||
p = MemoryPoint(
|
||||
id="",
|
||||
type=type_,
|
||||
@@ -292,6 +298,7 @@ def _mk(
|
||||
pinned=pinned,
|
||||
category=category,
|
||||
source="import",
|
||||
scope=scope,
|
||||
tags=[],
|
||||
)
|
||||
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
project_vms — Registry der QEMU-VMs PRO PROJEKT.
|
||||
|
||||
Persistenz: /shared/config/project_vms.json → { project_id: [ {vm}, ... ] }.
|
||||
Eine VM = {name, arch, iso, vnc_display, mem, created_at, updated_at}. Der echte
|
||||
Start/Stop laeuft ueber `aria-vm` auf dem Host (SSH aria-wohnung, siehe main.py);
|
||||
diese Datei haelt nur die Zuordnung VM ↔ Projekt + die Startparameter, damit die
|
||||
Liste im Desktop-Panel der App pro Projekt erscheint (auch wenn leer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
VMS_FILE = Path(os.environ.get("PROJECT_VMS_FILE", "/shared/config/project_vms.json"))
|
||||
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,40}$")
|
||||
VALID_ARCH = {"x86_64", "amd64", "i386", "i686", "x86", "arm", "aarch64",
|
||||
"mips", "mipsel", "mips64", "ppc", "ppc64", "riscv64", "sparc"}
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if not VMS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(VMS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
VMS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = VMS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(VMS_FILE)
|
||||
|
||||
|
||||
def list_vms(project_id: str) -> list[dict]:
|
||||
return _load().get(project_id or "", [])
|
||||
|
||||
|
||||
def get_vm(project_id: str, name: str) -> Optional[dict]:
|
||||
for v in list_vms(project_id):
|
||||
if v.get("name") == name:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _used_displays(data: dict, exclude: object = None) -> set:
|
||||
"""Alle VNC-Displays, die ueber ALLE Projekte belegt sind (exclude = eine
|
||||
VM-Dict-Instanz, die ignoriert wird — fuer Updates)."""
|
||||
used = set()
|
||||
for lst in data.values():
|
||||
for v in lst:
|
||||
if v is exclude:
|
||||
continue
|
||||
try:
|
||||
used.add(int(v.get("vnc_display", 1)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return used
|
||||
|
||||
|
||||
def add_vm(project_id: str, name: str, arch: str, iso: str = "",
|
||||
floppy: str = "", disk: str = "",
|
||||
vnc_display: int = 0, mem: int = 1024) -> dict:
|
||||
"""Registriert/aktualisiert eine VM. Medien (disk/floppy/iso) optional —
|
||||
leer = aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso im VM-Ordner selbst.
|
||||
|
||||
Das VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte), damit
|
||||
mehrere laufende VMs nicht denselben Port doppelt binden. vnc_display<=0 oder
|
||||
ein bereits belegtes Display → automatisch das naechste freie."""
|
||||
if not NAME_RE.match(name or ""):
|
||||
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
|
||||
if arch not in VALID_ARCH:
|
||||
raise ValueError(f"Unbekannte Architektur: {arch!r}")
|
||||
data = _load()
|
||||
lst = data.setdefault(project_id or "", [])
|
||||
now = int(time.time())
|
||||
existing = next((v for v in lst if v.get("name") == name), None)
|
||||
|
||||
used = _used_displays(data, exclude=existing)
|
||||
req = int(vnc_display or 0)
|
||||
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
|
||||
req = int(existing.get("vnc_display", 0) or 0)
|
||||
if req <= 0 or req in used: # frei/eindeutig machen
|
||||
req = 1
|
||||
while req in used:
|
||||
req += 1
|
||||
|
||||
fields = {"arch": arch, "iso": iso, "floppy": floppy, "disk": disk,
|
||||
"vnc_display": req, "mem": int(mem), "updated_at": now}
|
||||
if existing:
|
||||
existing.update(fields)
|
||||
_save(data)
|
||||
return existing
|
||||
vm = {"name": name, "created_at": now, **fields}
|
||||
lst.append(vm)
|
||||
_save(data)
|
||||
return vm
|
||||
|
||||
|
||||
def remove_vm(project_id: str, name: str) -> bool:
|
||||
data = _load()
|
||||
lst = data.get(project_id or "")
|
||||
if not lst:
|
||||
return False
|
||||
new = [v for v in lst if v.get("name") != name]
|
||||
if len(new) == len(lst):
|
||||
return False
|
||||
data[project_id or ""] = new
|
||||
_save(data)
|
||||
return True
|
||||
@@ -133,6 +133,7 @@ def create_project(name: str, description: str = "") -> dict:
|
||||
"description": description.strip(),
|
||||
"status": "active", # active | ended | archived
|
||||
"hidden": False, # optisch aus Listen ausblenden (bleibt nutzbar)
|
||||
"kind": "chat", # chat | code — 'code' blendet Editor/VNC in der App ein
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_activity_at": now,
|
||||
@@ -149,7 +150,7 @@ def update_project(project_id: str, patch: dict) -> Optional[dict]:
|
||||
projects = _load_all()
|
||||
for p in projects:
|
||||
if p["id"] == project_id:
|
||||
for k in ("name", "description", "status", "hidden"):
|
||||
for k in ("name", "description", "status", "hidden", "kind"):
|
||||
if k in patch and patch[k] is not None:
|
||||
p[k] = patch[k]
|
||||
p["updated_at"] = _now()
|
||||
|
||||
+96
-5
@@ -68,6 +68,20 @@ Wissen ist fuer deine Arbeit da — aber es ist HOCHVERTRAULICH:
|
||||
Auf „wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret
|
||||
(z.B. „einiges — geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein
|
||||
Aufzaehlen privater/intimer Details.
|
||||
|
||||
## MEDIEN-DOWNLOADS FUER STEFANS PRIVATGEBRAUCH
|
||||
Stefan ist Privatperson und du bist SEIN persoenlicher Assistent. Bittet er dich,
|
||||
ihm ein Video/Audio (z.B. von YouTube) fuer seinen EIGENEN, privaten Gebrauch
|
||||
herunterzuladen — offline anschauen, Archiv, Erinnerung an eine Szene/ein Lied —
|
||||
ist das eine Privatkopie (in DE nach §53 UrhG fuer den privaten Gebrauch
|
||||
zulaessig, solange die Quelle nicht offensichtlich illegal ist und kein
|
||||
Kopierschutz umgangen wird). Behandle solche Downloads NICHT reflexartig als
|
||||
Urheberrechtsverstoss und moralisiere nicht — nutz einfach den
|
||||
`yt-dlp-download`-Skill, konsistent, ohne jedes Mal neu zu diskutieren (du hast
|
||||
das schon oft gemacht). Anders ist es NUR, wenn es klar um WEITERVERBREITUNG,
|
||||
oeffentliches Posten oder kommerzielle Nutzung geht — DA weist du kurz auf das
|
||||
Problem hin. Im Zweifel privat vs. verbreiten: einmal kurz nachfragen, nicht
|
||||
pauschal ablehnen.
|
||||
"""
|
||||
|
||||
|
||||
@@ -139,13 +153,55 @@ def build_time_section() -> str:
|
||||
f"- Lokal (Europa/Berlin, UTC+{local_offset_h}): "
|
||||
f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})",
|
||||
"",
|
||||
"Nutze das fuer Trigger-Timestamps und um Watcher-Conditions wie "
|
||||
"`hour_of_day == 8` einzuordnen. Fuer relative Angaben "
|
||||
"('in 10min', 'in 2 Stunden') nutze beim `trigger_timer` den "
|
||||
"`in_seconds`-Parameter — Server rechnet dann selbst.",
|
||||
"Nutze das um Watcher-Conditions wie `hour_of_day == 8` einzuordnen. "
|
||||
"Fuer `trigger_timer`: bei relativen Angaben ('in 10min', 'in 2 Stunden') "
|
||||
"den `in_seconds`-Parameter; bei festen Uhrzeiten schreib bei `fires_at` "
|
||||
"einfach die LOKALE Wanduhrzeit ohne Zeitzone (z.B. 'um 17 Uhr' → "
|
||||
"'...T17:00:00') — der Server rechnet sie selbst in UTC um. So feuert der "
|
||||
"Timer zur gemeinten Ortszeit und bleibt zeitzonen-portabel.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_voice_flow_section() -> str:
|
||||
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
|
||||
Frage, Kette vs. Ende) und deklariert sie per Marker — wie [[AWAIT]]. Die
|
||||
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
|
||||
return "\n".join([
|
||||
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
|
||||
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
|
||||
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
|
||||
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
|
||||
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
|
||||
"",
|
||||
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
|
||||
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
|
||||
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
|
||||
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
|
||||
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
|
||||
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
|
||||
"Wake-Word.",
|
||||
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
|
||||
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
|
||||
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
|
||||
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
|
||||
"oder das Gespraech klar weitergeht.",
|
||||
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
|
||||
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
|
||||
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
|
||||
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
|
||||
"`[[ENDE]]` an.",
|
||||
"",
|
||||
"Regeln:",
|
||||
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
|
||||
"ohne Marker (wird vorgelesen).",
|
||||
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
|
||||
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
|
||||
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
||||
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
||||
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
||||
])
|
||||
|
||||
|
||||
TYPE_HEADINGS = {
|
||||
"identity": "## Wer du bist",
|
||||
"rule": "## Sicherheitsregeln & Prinzipien",
|
||||
@@ -244,6 +300,36 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
|
||||
"""Titel-Index des kalten Gedaechtnisses: ARIA sieht WELCHES Nachschlage-
|
||||
Wissen sie hat (nur Titel, kein Inhalt = billig), damit sie den Inhalt via
|
||||
memory_search holt statt Stefan nach etwas zu fragen, das schon da ist.
|
||||
Nach Kategorie gruppiert; Conversation-Logs + auto-destillierte Fakten sind
|
||||
bereits ausgefiltert (siehe list_index_titles)."""
|
||||
if not index_titles:
|
||||
return ""
|
||||
grouped: dict[str, List[MemoryPoint]] = {}
|
||||
for p in index_titles:
|
||||
key = (p.category or p.type or "sonstiges").strip() or "sonstiges"
|
||||
grouped.setdefault(key, []).append(p)
|
||||
|
||||
lines = [
|
||||
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
|
||||
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
|
||||
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
|
||||
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
|
||||
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
|
||||
"",
|
||||
]
|
||||
for cat in sorted(grouped.keys()):
|
||||
items = grouped[cat]
|
||||
lines.append(f"### {cat}")
|
||||
for p in items:
|
||||
lines.append(f"- {p.title}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def build_skills_section(skills: List[dict]) -> str:
|
||||
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
|
||||
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
|
||||
@@ -434,6 +520,7 @@ def build_flux_section(flux_config: dict) -> str:
|
||||
def build_system_prompt(
|
||||
pinned: List[MemoryPoint],
|
||||
cold: List[MemoryPoint] | None = None,
|
||||
memory_index: List[MemoryPoint] | None = None,
|
||||
skills: List[dict] | None = None,
|
||||
triggers: List[dict] | None = None,
|
||||
condition_vars: List[dict] | None = None,
|
||||
@@ -447,7 +534,8 @@ def build_system_prompt(
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
||||
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()]
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
|
||||
"", build_voice_flow_section()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
parts.append(build_skills_section(skills))
|
||||
@@ -466,6 +554,9 @@ def build_system_prompt(
|
||||
callback_host=oauth_callback_host,
|
||||
callback_port=oauth_callback_port,
|
||||
callback_tls=oauth_callback_tls))
|
||||
if memory_index:
|
||||
parts.append("")
|
||||
parts.append(build_memory_index_section(memory_index))
|
||||
if cold:
|
||||
parts.append("")
|
||||
parts.append(build_cold_memory_section(cold))
|
||||
|
||||
+52
-4
@@ -75,6 +75,16 @@ _TOOL_HINTS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
|
||||
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
|
||||
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
|
||||
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
|
||||
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
|
||||
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
|
||||
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
|
||||
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
|
||||
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
|
||||
|
||||
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
|
||||
_HARD_HINTS = re.compile(
|
||||
r"```|" # Codeblock
|
||||
@@ -86,6 +96,18 @@ _HARD_HINTS = re.compile(
|
||||
|
||||
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
||||
|
||||
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
|
||||
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
|
||||
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
|
||||
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
|
||||
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
|
||||
_FORCE_CLAUDE = re.compile(
|
||||
r"\b(clodi|clody)\b"
|
||||
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
|
||||
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _strip_leading_hint_blocks(text: str) -> str:
|
||||
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
|
||||
@@ -108,6 +130,10 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
||||
msg = _strip_leading_hint_blocks(user_message)
|
||||
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
||||
return False
|
||||
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
|
||||
if _FORCE_CLAUDE.search(msg):
|
||||
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
|
||||
return False
|
||||
if _TOOL_HINTS.search(msg):
|
||||
return False
|
||||
if _HARD_HINTS.search(msg):
|
||||
@@ -122,10 +148,16 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
||||
# kein volles Memory (B1a).
|
||||
|
||||
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
|
||||
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
|
||||
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
|
||||
# Aktion braucht, gehoert ans grosse Modell.
|
||||
_AWARENESS = (
|
||||
"Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
|
||||
"Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
|
||||
"sowie tiefe/technische Analysen und langen Code."
|
||||
"Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
|
||||
"nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
|
||||
"News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
|
||||
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
|
||||
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
|
||||
"und langen Code. Fuer ALL das eskalierst du."
|
||||
)
|
||||
|
||||
|
||||
@@ -153,10 +185,26 @@ def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
|
||||
"GPS-Hinweis in der Nachricht.",
|
||||
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
|
||||
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
|
||||
"- `run_spotify`: Musik steuern (naechster Titel, Pause, weiter).",
|
||||
"- `run_*`-Skills: konkrete Faehigkeiten (z.B. Musik/Spotify u.a.). "
|
||||
"Was ein Skill kann + welche Parameter er nimmt, steht in SEINER "
|
||||
"Tool-Beschreibung — LIES sie und nutze den Skill fuer ALLES was "
|
||||
"dazu passt (nicht nur die offensichtlichen Faelle). Nie selbst eine "
|
||||
"Aktion 'spielen'/'nachschauen', wenn ein Skill das kann.",
|
||||
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
|
||||
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
|
||||
"Bedarf; erfinde keine.",
|
||||
"ANTI-HALLUZINATION (kritisch): Behaupte NIEMALS eine Aktion als "
|
||||
"erledigt, ohne das Werkzeug WIRKLICH aufgerufen zu haben. Keine "
|
||||
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
|
||||
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
|
||||
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
|
||||
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
|
||||
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
|
||||
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
|
||||
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
|
||||
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
|
||||
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
|
||||
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
|
||||
]
|
||||
parts += [
|
||||
"",
|
||||
|
||||
@@ -400,6 +400,101 @@ SEED_RULES: List[dict] = [
|
||||
"Brain-Resources: erst denken, sonst Brain-Tool nehmen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/qemu-vm-code-projects",
|
||||
"type": "rule",
|
||||
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
||||
"category": "architektur",
|
||||
"content": (
|
||||
"GRUNDWISSEN Code-/Bau-Projekte + VMs — so haengt das System zusammen:\n"
|
||||
"\n"
|
||||
"DATEIEN eines Code-Projekts gehoeren nach `/shared/projects/<projekt-id>/` "
|
||||
"(Volume in proxy+bridge+brain gemountet). Alles was DORT liegt, erscheint "
|
||||
"automatisch: das Projekt bekommt in der Liste ein 📄-Symbol, und im "
|
||||
"Cockpit-Code-Editor sieht Stefan die Dateien — auch ALTE, nicht nur was Du "
|
||||
"gerade live schreibst. Was Stefan im Editor tippt, kommt als Datei dorthin "
|
||||
"zurueck. (Ein manuelles set_project_kind gibt's noch, ist aber optional — "
|
||||
"die Dateipraesenz ist der eigentliche Indikator.)\n"
|
||||
"\n"
|
||||
"VMs (QEMU, JEDE Architektur: x86/i386, ARM/aarch64, MIPS, PPC, RISC-V, "
|
||||
"SPARC) laufen auf dem HOST (die qemu-Tools liegen in aria-wohnung, die "
|
||||
"Projektdateien in /shared). Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
||||
" - `aria-vm create <name> <arch> [groesse]` — legt eine VM an. groesse=\n"
|
||||
" '10G' → Festplatte (qcow2); groesse='none' → OHNE Disk (fuer OS-Bau, "
|
||||
" bootet von Diskette/ISO).\n"
|
||||
" - `aria-vm boot <name> [optionen]` — startet sie (daemonized). Optionen:\n"
|
||||
" --iso <pfad> von CD/ISO booten\n"
|
||||
" --floppy <pfad> von Diskette booten (-fda, klassisch OS-Dev)\n"
|
||||
" --disk <pfad> explizite qcow2\n"
|
||||
" --vnc-display <N> VNC-Display (Default 1 → Port 5901; mehrere VMs = "
|
||||
"verschiedene N)\n"
|
||||
" --mem <MB> RAM (Default 1024)\n"
|
||||
" Medien im VM-Ordner (disk.qcow2/floppy.img/cdrom.iso) werden auto-"
|
||||
"erkannt. Es MUSS mindestens ein Boot-Medium da sein.\n"
|
||||
" - `aria-vm screenshot <name>` → PNG (an Stefan per [FILE:] schickbar).\n"
|
||||
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
|
||||
"\n"
|
||||
"PFLICHT nach dem Bau/Boot: `vm_register(name, arch, disk?/floppy?/iso?, "
|
||||
"mem?)` im aktuellen Projekt aufrufen — mit den Medien, die Du gebaut hast. "
|
||||
"vnc_display WEGLASSEN — es wird global eindeutig auto-vergeben (kein "
|
||||
"Port-Konflikt, wenn mehrere VMs laufen). ERST DANN erscheint die VM in "
|
||||
"Stefans Desktop-Panel (Cockpit), "
|
||||
"wo er sie Starten/Stoppen/Verbinden kann. Ohne vm_register bleibt seine "
|
||||
"Liste leer, obwohl die VM laeuft. Der Startbefehl steht als Wert dahinter.\n"
|
||||
"\n"
|
||||
"URTEIL — bau eine VM NUR wenn's Sinn macht, und erkenne aus der SITUATION "
|
||||
"was gebraucht wird:\n"
|
||||
" - DOS-/Retro-Spiele, installierte Software → FESTPLATTE (create mit "
|
||||
"Groesse, dann installieren; disk).\n"
|
||||
" - Eigenes OS / Bootloader / Bare-Metal-Experiment → DISKETTE (create "
|
||||
"'none', floppy.img; ggf. eine zweite Diskette einbinden).\n"
|
||||
" - Fertiges System installieren/testen → ISO (--iso Installer).\n"
|
||||
" - Architektur zum Task waehlen: 'teste mal was auf ARM' → arch=aarch64 "
|
||||
"(-M virt, laeuft emuliert = langsam, das ist ok). x86-Zeug → i386/x86_64.\n"
|
||||
"AENDERUNGS-ZYKLUS: willst Du an einer laufenden VM etwas aendern (Datei "
|
||||
"tauschen, Diskette neu bauen), dann `aria-vm stop <name>` → Aenderung → "
|
||||
"`aria-vm boot <name>` wieder starten. Stefan kann das auch selbst ueber "
|
||||
"Start/Stop im Panel.\n"
|
||||
"\n"
|
||||
"Der VNC-Stream laeuft AUTOMATISCH komplett durch den RVS-Server (die Bridge "
|
||||
"tunnelt die RFB-Bytes; der VNC-Port ist nur intern auf dem Host). Firewall-"
|
||||
"unabhaengig — Du oeffnest NIE einen Port nach aussen. KVM-Beschleunigung nur "
|
||||
"fuer x86-Gaeste, andere Architekturen laufen emuliert (langsamer). Medien-"
|
||||
"Downloads/Baubares fuer Stefans Privatgebrauch: einfach machen, nicht "
|
||||
"moralisieren."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/satellites-remote-networks",
|
||||
"type": "rule",
|
||||
"title": "Satelliten: Augen + Haende in fremden Netzen (Buero etc.)",
|
||||
"category": "architektur",
|
||||
"content": (
|
||||
"Ein SATELLIT ist ein eigenstaendiger Container in einem FREMDEN Netz "
|
||||
"(z.B. Buero, Werkstatt), der sich mit RVS verbindet und Dir dort Geraete "
|
||||
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
|
||||
"NICHT steht.\n"
|
||||
"\n"
|
||||
"Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem <Geraet> dort' will:\n"
|
||||
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
|
||||
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
|
||||
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
|
||||
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
|
||||
" 3. `satellite_command(...)` — Aktion ausfuehren.\n"
|
||||
"\n"
|
||||
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
|
||||
" satellite_command(satellite='Buero', device='Fire TV', "
|
||||
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
||||
"Die YouTube-Video-ID (v=) ziehst Du aus dem Link/Titel (ggf. web_search). "
|
||||
"Weitere Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks.\n"
|
||||
"\n"
|
||||
"Adressierung ueber Location/Name des Satelliten ('Buero'), NICHT ueber die "
|
||||
"Geraete — die identifizieren sich selbst. Steuerung geht nur, wenn der "
|
||||
"Satellit sie erlaubt (steht in satellite_list als caps). Ist keiner online: "
|
||||
"sag das ehrlich, statt zu raten."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/brain-tools-xml-tag",
|
||||
"type": "rule",
|
||||
@@ -820,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
|
||||
"pinned": True,
|
||||
"category": rule.get("category", ""),
|
||||
"source": "seed",
|
||||
"scope": "system",
|
||||
"tags": [],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
|
||||
+36
-1
@@ -139,6 +139,26 @@ def read_manifest(name: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def read_skill_source(name: str) -> Optional[dict]:
|
||||
"""Manifest + kompletter entry_code + README eines Skills. Damit ARIA einen
|
||||
Skill LESEN kann bevor sie ihn per skill_update aendert (sonst Blind-Rewrite,
|
||||
der bestehende Funktionen killt)."""
|
||||
m = read_manifest(name)
|
||||
if m is None:
|
||||
return None
|
||||
d = _skill_dir(name)
|
||||
entry = m.get("entry", "run.sh")
|
||||
try:
|
||||
code = (d / entry).read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
code = f"(entry-Datei '{entry}' nicht lesbar: {exc})"
|
||||
try:
|
||||
readme = (d / "README.md").read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
readme = ""
|
||||
return {"manifest": m, "entry": entry, "entry_code": code, "readme": readme}
|
||||
|
||||
|
||||
def write_manifest(name: str, manifest: dict) -> None:
|
||||
d = _skill_dir(name)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -165,6 +185,8 @@ def create_skill(
|
||||
author: str = "aria",
|
||||
config_schema: Optional[list] = None,
|
||||
fast_patterns: Optional[list] = None,
|
||||
speak: bool = False,
|
||||
converse: bool = False,
|
||||
) -> dict:
|
||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||
|
||||
@@ -215,6 +237,14 @@ def create_skill(
|
||||
"author": author,
|
||||
"config_schema": _normalize_config_schema(config_schema),
|
||||
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
||||
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
|
||||
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
|
||||
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen.
|
||||
# converse: nach der Antwort 30s weiterlauschen (Dialog)? Default False
|
||||
# (Einzelaktion). Beide sind STATISCHE Defaults — der Skill kann sie im
|
||||
# JSON-Output pro Aufruf ueberschreiben (gemischte Skills).
|
||||
"speak": bool(speak),
|
||||
"converse": bool(converse),
|
||||
"version_history": [],
|
||||
}
|
||||
write_manifest(name, manifest)
|
||||
@@ -335,10 +365,15 @@ def update_skill(name: str, patch: dict) -> dict:
|
||||
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||
manifest = read_manifest(name) or manifest
|
||||
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry",
|
||||
"speak", "converse"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
manifest[k] = v
|
||||
if "speak" in patch:
|
||||
manifest["speak"] = bool(patch["speak"])
|
||||
if "converse" in patch:
|
||||
manifest["converse"] = bool(patch["converse"])
|
||||
if "config_schema" in patch:
|
||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||
if "fast_patterns" in patch:
|
||||
|
||||
+27
-3
@@ -24,7 +24,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -40,6 +40,29 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _local_offset_hours(dt: datetime) -> int:
|
||||
"""Grobe Europe/Berlin-Naeherung (CEST=+2 Maerz-Okt, sonst CET=+1) — dieselbe
|
||||
Logik wie build_time_section im Prompt, ohne zoneinfo/tzdata im Brain-Image."""
|
||||
return 2 if 3 <= dt.month <= 10 else 1
|
||||
|
||||
|
||||
def normalize_fires_at_utc(iso: str) -> str:
|
||||
"""Bringt einen fires_at-ISO IMMER auf UTC (+00:00).
|
||||
|
||||
- Aware (endet auf Z oder hat einen Offset) → in UTC umgerechnet.
|
||||
- Naiv (keine Zone) → als LOKALE Wanduhrzeit (Europe/Berlin) interpretiert
|
||||
und nach UTC umgerechnet.
|
||||
|
||||
So speichern wir stets den absoluten Instant. Die Ausfuehrung (background.py,
|
||||
UTC) trifft damit exakt die vom Nutzer gemeinte Ortszeit — und bleibt
|
||||
zeitzonen-portabel (feuert am selben Moment, egal wo Stefan gerade ist)."""
|
||||
dt = datetime.fromisoformat((iso or "").strip().replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
# Naiv = lokale Wanduhrzeit → UTC = lokal - Offset.
|
||||
dt = (dt - timedelta(hours=_local_offset_hours(dt))).replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
if not isinstance(name, str) or not NAME_RE.match(name):
|
||||
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
|
||||
@@ -127,9 +150,10 @@ def create_timer(
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
raise ValueError(f"Trigger '{name}' existiert schon")
|
||||
# ISO validieren
|
||||
# ISO validieren UND auf UTC normalisieren (naiv = lokale Wanduhrzeit →
|
||||
# UTC). So passt das Anlegen zur UTC-Ausfuehrung in background.py.
|
||||
try:
|
||||
datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00"))
|
||||
fires_at_iso = normalize_fires_at_utc(fires_at_iso)
|
||||
except Exception:
|
||||
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
|
||||
data = {
|
||||
|
||||
+703
-33
@@ -16,6 +16,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
@@ -32,6 +33,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
@@ -41,6 +43,25 @@ from openwakeword.model import Model as WakeWordModel
|
||||
|
||||
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
||||
|
||||
|
||||
def _docker_gateway() -> str:
|
||||
"""Docker-Gateway-IP (= Host-IP auf DIESEM Container-Netz, aria-net) aus
|
||||
/proc/net/route. Genau die IP, an die der Brain QEMUs VNC bindet — im
|
||||
Gegensatz zu host.docker.internal, das auf die Default-Bridge (docker0)
|
||||
zeigt und daher die VM nicht trifft."""
|
||||
try:
|
||||
import socket as _sock
|
||||
import struct as _struct
|
||||
with open("/proc/net/route") as f:
|
||||
for line in f.readlines()[1:]:
|
||||
fields = line.strip().split()
|
||||
if len(fields) >= 4 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -293,10 +314,17 @@ def clean_text_for_tts(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# <voice>...</voice> wenn vorhanden → nur das nehmen
|
||||
# <voice>...</voice> wenn vorhanden → nur diesen Inhalt lesen. ABER: ein
|
||||
# LEERES <voice></voice> darf die Sprachausgabe nicht verstummen lassen.
|
||||
# Claude haengt reflexartig manchmal ein leeres Tag an (v.a. bei Spotify-
|
||||
# Antworten) — frueher wurde daraus text="" → gar keine TTS (Playlist-Wechsel
|
||||
# 'Prodigy' stumm, 'Fliegen' gesprochen, rein je nachdem ob Claude Inhalt ins
|
||||
# Tag schrieb). Leeres/whitespace-Tag → ignorieren und den normalen Text lesen.
|
||||
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
|
||||
if voice_match:
|
||||
if voice_match and voice_match.group(1).strip():
|
||||
text = voice_match.group(1)
|
||||
else:
|
||||
text = _re_tts.sub(r'<voice>[\s\S]*?</voice>', ' ', text, flags=_re_tts.IGNORECASE)
|
||||
|
||||
t = text
|
||||
|
||||
@@ -370,6 +398,37 @@ def clean_text_for_tts(text: str) -> str:
|
||||
return t.strip()
|
||||
|
||||
|
||||
# ── Geo: Bewegungsrichtung aus zwei GPS-Punkten ──────────────
|
||||
|
||||
# Fahrtrichtung als Himmelsrichtungs-Adverb (8-Wind, "…waerts"). Index =
|
||||
# gerundeter Bearing / 45 (mod 8).
|
||||
_COMPASS_ADV = ["nordwaerts", "nordostwaerts", "ostwaerts", "suedostwaerts",
|
||||
"suedwaerts", "suedwestwaerts", "westwaerts", "nordwestwaerts"]
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Distanz in Metern zwischen zwei GPS-Punkten."""
|
||||
r = 6371000.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Kompass-Peilung 0..360 (0=Nord) von Punkt1 nach Punkt2."""
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
y = math.sin(dl) * math.cos(p2)
|
||||
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
||||
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
||||
|
||||
|
||||
def _heading_word(bearing: float) -> str:
|
||||
return _COMPASS_ADV[round(bearing / 45.0) % 8]
|
||||
|
||||
|
||||
# ── STT Engine ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -694,6 +753,19 @@ class ARIABridge:
|
||||
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
|
||||
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
|
||||
self._remote_stt_ready: bool = False
|
||||
# VNC-Tunnel (Workspace-Desktop): session → {"writer": StreamWriter,
|
||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||
self._vnc_sessions: dict[str, dict] = {}
|
||||
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
|
||||
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
|
||||
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
|
||||
or _docker_gateway() or "host.docker.internal")
|
||||
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
||||
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
||||
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||
self._satellites: dict[str, dict] = {}
|
||||
self._pending_sat: dict[str, asyncio.Future] = {}
|
||||
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
||||
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||
@@ -718,7 +790,8 @@ class ARIABridge:
|
||||
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
|
||||
# Anweisung" beim file, einmal auf den Chat-Text).
|
||||
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
|
||||
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
|
||||
# (file_path, name, type, kb, width, height, clientMsgId)
|
||||
self._pending_files: list[tuple[str, str, str, int, int, int, str]] = []
|
||||
self._pending_files_flush_task: Optional[asyncio.Task] = None
|
||||
# Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload).
|
||||
# Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen
|
||||
@@ -1175,8 +1248,28 @@ class ARIABridge:
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
})
|
||||
lat, lon = float(lat), float(lon)
|
||||
# Fuer den Standort-Praefix merken (auch fuer Turns ohne frisches GPS).
|
||||
self._last_location = {"lat": lat, "lon": lon}
|
||||
# Fahrtrichtung aus dem Bewegungsvektor. Anker bleibt stehen bis wir
|
||||
# uns >MIN_MOVE_M wirklich wegbewegt haben — so zaehlt echtes Fahren,
|
||||
# nicht das GPS-Jitter im Stand. Stehen wir → letzte Richtung bleibt.
|
||||
# Umdrehen liefert automatisch neue Punkte → neue Richtung.
|
||||
MIN_MOVE_M = 25.0
|
||||
anchor = getattr(self, "_heading_anchor", None)
|
||||
if anchor:
|
||||
moved = _haversine_m(anchor["lat"], anchor["lon"], lat, lon)
|
||||
if moved >= MIN_MOVE_M:
|
||||
_bg = _bearing_deg(anchor["lat"], anchor["lon"], lat, lon)
|
||||
self._last_heading = _heading_word(_bg)
|
||||
self._last_bearing = int(round(_bg)) % 360 # exakte Peilung
|
||||
self._heading_anchor = {"lat": lat, "lon": lon}
|
||||
else:
|
||||
self._heading_anchor = {"lat": lat, "lon": lon}
|
||||
except Exception:
|
||||
return
|
||||
# Ortsname im Hintergrund aufloesen (Nominatim, gecacht) — kein Modell.
|
||||
self._maybe_reverse_geocode(float(lat), float(lon))
|
||||
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
|
||||
# oder langsam ist, blockt das nicht den GPS-Pfad.
|
||||
try:
|
||||
@@ -1184,6 +1277,150 @@ class ARIABridge:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _maybe_reverse_geocode(self, lat: float, lon: float) -> None:
|
||||
"""Loest lat/lon → Ortsname auf, aber nur bei nennenswerter Bewegung
|
||||
(~1 km Raster) und im Hintergrund. Ergebnis landet in
|
||||
self._last_place_name; der Standort-Praefix liest es dann nur ab."""
|
||||
try:
|
||||
key = (round(lat, 3), round(lon, 3)) # ~110 m — fuer Strasse/Hausnr
|
||||
except Exception:
|
||||
return
|
||||
if key == getattr(self, "_geocoded_key", None):
|
||||
return # diese Gegend schon aufgeloest
|
||||
# Neue Gegend: alten Namen verwerfen, sonst zeigt der Praefix die falsche
|
||||
# Stadt bis der neue Geocode da ist.
|
||||
self._geocoded_key = key
|
||||
self._last_place_name = ""
|
||||
try:
|
||||
self._geocode_task = asyncio.create_task(self._do_reverse_geocode(lat, lon, key))
|
||||
except Exception:
|
||||
self._geocode_task = None
|
||||
|
||||
async def _ensure_place_name(self, lat, lon) -> None:
|
||||
"""Stellt (blockierend, mit Timeout) sicher, dass der Ortsname fuer die
|
||||
aktuelle Position da ist — fuer die ERSTE Nachricht an einem neuen Ort,
|
||||
wo der Hintergrund-Geocode noch laeuft. Gecacht → danach instant."""
|
||||
try:
|
||||
lat, lon = float(lat), float(lon)
|
||||
key = (round(lat, 3), round(lon, 3))
|
||||
except Exception:
|
||||
return
|
||||
if key == getattr(self, "_geocoded_key", None) and getattr(self, "_last_place_name", ""):
|
||||
return # schon aufgeloest
|
||||
if key != getattr(self, "_geocoded_key", None):
|
||||
self._maybe_reverse_geocode(lat, lon) # startet Task
|
||||
task = getattr(self, "_geocode_task", None)
|
||||
if task is not None and not task.done():
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=6)
|
||||
except Exception:
|
||||
pass # Timeout/Fehler → Praefix faellt auf reine Koordinaten zurueck
|
||||
|
||||
async def _do_reverse_geocode(self, lat: float, lon: float, key) -> None:
|
||||
"""Nominatim-Reverse-Geocode (keyless, gratis). Baut einen moeglichst
|
||||
genauen Ort: Strasse+Hausnummer, PLZ+Stadt, Bundesland; bei Autobahn/
|
||||
Bundesstrasse zusaetzlich die Ref (A 28 / B 75) + best-effort Kilometer
|
||||
(Overpass-Milestone). Setzt self._last_place_name. Fehler → still
|
||||
(Praefix faellt auf reine Koordinaten zurueck)."""
|
||||
base = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
|
||||
url = (f"{base}/reverse?lat={lat:.5f}&lon={lon:.5f}&format=jsonv2"
|
||||
f"&zoom=18&addressdetails=1&extratags=1&accept-language=de")
|
||||
|
||||
def _fetch():
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={
|
||||
# Nominatim verlangt einen aussagekraeftigen User-Agent.
|
||||
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=8) as r:
|
||||
return json.loads(r.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
logger.debug("[geo] reverse-geocode fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
data = await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
addr = data.get("address") or {}
|
||||
extra = data.get("extratags") or {}
|
||||
road = addr.get("road") or ""
|
||||
house = addr.get("house_number") or ""
|
||||
city = (addr.get("city") or addr.get("town") or addr.get("village")
|
||||
or addr.get("municipality") or addr.get("county") or "")
|
||||
plz = addr.get("postcode") or ""
|
||||
state = addr.get("state") or ""
|
||||
ref = (extra.get("ref") or "").strip() # z.B. "A 28", "B 75"
|
||||
|
||||
# Strasse + Hausnummer
|
||||
street = (f"{road} {house}".strip()) if road else ""
|
||||
# PLZ + Ort
|
||||
citypart = (f"{plz} {city}".strip()) if plz else city
|
||||
|
||||
segs: list[str] = []
|
||||
# Autobahn/Bundes-/Kreisstrasse? Ref + (best-effort) Kilometer voranstellen.
|
||||
is_road = bool(ref) or any(w in road.lower()
|
||||
for w in ("autobahn", "bundesstrasse", "bundesstraße",
|
||||
"kreisstrasse", "kreisstraße"))
|
||||
if is_road:
|
||||
label = ref or road
|
||||
km = await self._overpass_km(lat, lon)
|
||||
if km:
|
||||
label = f"{label} bei km {km}"
|
||||
if label:
|
||||
segs.append(label)
|
||||
if street:
|
||||
segs.append(street)
|
||||
if citypart:
|
||||
segs.append(citypart)
|
||||
if state and (state not in citypart):
|
||||
segs.append(state)
|
||||
|
||||
name = ", ".join(s for s in segs if s)
|
||||
if not name:
|
||||
name = data.get("name") or ""
|
||||
# Nur uebernehmen wenn Stefan nicht schon weitergezogen ist (key aktuell).
|
||||
if name and getattr(self, "_geocoded_key", None) == key:
|
||||
self._last_place_name = name
|
||||
logger.info("[geo] %.5f,%.5f → %s", lat, lon, name)
|
||||
|
||||
async def _overpass_km(self, lat: float, lon: float) -> str:
|
||||
"""Best-effort Autobahn/Strassen-Kilometer: naechsten OSM-Milestone
|
||||
(highway=milestone mit distance-Tag) im Umkreis suchen. Leer wenn keiner
|
||||
gefunden / Overpass nicht erreichbar. Milestones stehen i.d.R. alle 500 m,
|
||||
also grob (fuer Pannenhilfe 'ca. km X' voellig ausreichend)."""
|
||||
ov = os.environ.get("OVERPASS_URL", "https://overpass-api.de/api/interpreter")
|
||||
query = (f"[out:json][timeout:8];"
|
||||
f"node(around:900,{lat:.5f},{lon:.5f})[highway=milestone];out;")
|
||||
|
||||
def _fetch():
|
||||
try:
|
||||
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
|
||||
req = urllib.request.Request(ov, data=body, headers={
|
||||
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=12) as r:
|
||||
return json.loads(r.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
logger.debug("[geo] overpass-km fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
d = await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||
if not isinstance(d, dict):
|
||||
return ""
|
||||
best_dist = ""
|
||||
best_sq = 1e18
|
||||
for e in (d.get("elements") or []):
|
||||
tags = e.get("tags") or {}
|
||||
dist = tags.get("distance") or tags.get("milestone:distance") or ""
|
||||
elat, elon = e.get("lat"), e.get("lon")
|
||||
if not dist or elat is None or elon is None:
|
||||
continue
|
||||
sq = (float(elat) - lat) ** 2 + (float(elon) - lon) ** 2
|
||||
if sq < best_sq:
|
||||
best_sq = sq
|
||||
best_dist = str(dist)
|
||||
return best_dist.replace(".", ",") if best_dist else ""
|
||||
|
||||
async def _trigger_brain_check_now(self) -> None:
|
||||
"""Brain-Endpoint POST /triggers/check-now anstossen."""
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
@@ -1356,6 +1593,13 @@ class ARIABridge:
|
||||
"text": display_text,
|
||||
"sender": "aria",
|
||||
"messageId": message_id,
|
||||
# ARIA-Dateien DIREKT an der Chat-Bubble mitschicken — sonst kommt
|
||||
# der Anhang live nur als separates file_from_aria-Event (eigene
|
||||
# Bubble) und taucht an der Nachricht erst nach einem Seiten-
|
||||
# wechsel auf (Reload aus chat_backup, das die files kennt). Selbe
|
||||
# Struktur wie im Backup, damit App live == Reload rendert.
|
||||
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
||||
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
|
||||
"backupTs": assistant_backup_ts,
|
||||
@@ -1371,6 +1615,18 @@ class ARIABridge:
|
||||
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
|
||||
# nach einem Fast-Path-Befehl grau haengen.
|
||||
"speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True,
|
||||
# Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs-
|
||||
# Fenster in der App (Skill/Einzelaktion=False, Konversation=True).
|
||||
"converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True,
|
||||
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App
|
||||
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
|
||||
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
|
||||
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
|
||||
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
|
||||
# (Mikro frei).
|
||||
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
|
||||
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener.
|
||||
"wake_on": bool(payload.get("wake_on", False)) if isinstance(payload, dict) else False,
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
@@ -1557,29 +1813,48 @@ class ARIABridge:
|
||||
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
||||
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
|
||||
)
|
||||
if location and isinstance(location, dict):
|
||||
lat = location.get("lat")
|
||||
lon = location.get("lon") or location.get("lng")
|
||||
# Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
|
||||
# (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
|
||||
loc = location if (isinstance(location, dict) and location.get("lat") is not None) \
|
||||
else getattr(self, "_last_location", None)
|
||||
if isinstance(loc, dict):
|
||||
lat = loc.get("lat")
|
||||
lon = loc.get("lon") or loc.get("lng")
|
||||
if lat is not None and lon is not None:
|
||||
# Ortsname kommt vom Reverse-Geocode (Nominatim, gecacht) — so
|
||||
# muss KEIN Modell Koordinaten raten (das lokale 8B tippt sonst
|
||||
# daneben, z.B. "Berlin" fuer Oldenburg). Noch nicht aufgeloest
|
||||
# → nur Koordinaten (Claude kann die zur Not selbst deuten).
|
||||
place = getattr(self, "_last_place_name", "")
|
||||
where = f"{place} " if place else ""
|
||||
heading = getattr(self, "_last_heading", "")
|
||||
bearing = getattr(self, "_last_bearing", None)
|
||||
if heading and bearing is not None:
|
||||
moving = f", unterwegs {heading} ({bearing} Grad)"
|
||||
elif heading:
|
||||
moving = f", unterwegs {heading}"
|
||||
else:
|
||||
moving = ""
|
||||
parts.append(
|
||||
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
|
||||
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
|
||||
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
|
||||
f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
|
||||
f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
|
||||
f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
|
||||
f"nicht von dir aus, ausser er fragt explizit danach.]"
|
||||
)
|
||||
if parts:
|
||||
return " ".join(parts) + " " + text
|
||||
return text
|
||||
|
||||
def _build_pending_files_message(self, user_text: str) -> str:
|
||||
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
|
||||
def _build_pending_files_message(self, user_text: str, files: list) -> str:
|
||||
"""Baut eine Anweisung an aria-core aus den uebergebenen Files + optionalem
|
||||
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
|
||||
parts: list[str] = []
|
||||
for fp, name, ftype, kb, w, h in self._pending_files:
|
||||
for fp, name, ftype, kb, w, h, _cmid in files:
|
||||
dim = f" {w}x{h}px" if (w and h) else ""
|
||||
kind = "Bild" if ftype.startswith("image/") else "Datei"
|
||||
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
|
||||
files_summary = "\n".join(parts)
|
||||
n = len(self._pending_files)
|
||||
n = len(files)
|
||||
anhang = "Anhang" if n == 1 else "Anhaenge"
|
||||
if user_text:
|
||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||
@@ -1588,15 +1863,16 @@ class ARIABridge:
|
||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||
|
||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
|
||||
aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self._pending_files:
|
||||
return
|
||||
text = self._build_pending_files_message("")
|
||||
files = self._pending_files
|
||||
text = self._build_pending_files_message("", files)
|
||||
self._pending_files = []
|
||||
self._pending_files_flush_task = None
|
||||
pid = self._pending_files_project_id
|
||||
@@ -1604,23 +1880,48 @@ class ARIABridge:
|
||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||
|
||||
async def _flush_pending_files_with_text(self, user_text: str,
|
||||
project_id: str = "") -> bool:
|
||||
project_id: str = "",
|
||||
client_msg_id: str = "") -> bool:
|
||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||
|
||||
KORRELATION (Fix Queue-Bug): Files tragen dieselbe clientMsgId wie ihr
|
||||
Text. Bei einer Queue gehen Files (fire-and-forget) und Text (ACK-
|
||||
getrackt, ggf. verzoegert) auseinander — ohne Korrelation landeten die
|
||||
Bilder beim falschen Text. Wir mergen darum NUR die Files mit passender
|
||||
cmid; der Rest bleibt gepuffert fuer seine eigene Nachricht. Fallback
|
||||
(Legacy-App ohne cmid an Files, oder cmid ohne Treffer): altes Verhalten
|
||||
(alle Files mit diesem Text), damit nie ein Bild verloren geht.
|
||||
|
||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext
|
||||
zurueck, damit Anhaenge im richtigen Projekt landen statt im Hauptchat."""
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||
"""
|
||||
if not self._pending_files:
|
||||
return False
|
||||
cmid = (client_msg_id or "").strip()
|
||||
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||
if not matching:
|
||||
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||
matching = list(self._pending_files)
|
||||
remaining: list = []
|
||||
else:
|
||||
remaining = [f for f in self._pending_files if f not in matching]
|
||||
|
||||
text = self._build_pending_files_message(user_text, matching)
|
||||
self._pending_files = remaining
|
||||
pid = (project_id or "").strip() or self._pending_files_project_id
|
||||
# Flush-Timer neu setzen wenn noch Files anderer Nachrichten warten,
|
||||
# sonst zuruecksetzen.
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = None
|
||||
text = self._build_pending_files_message(user_text)
|
||||
self._pending_files = []
|
||||
pid = (project_id or "").strip() or self._pending_files_project_id
|
||||
self._pending_files_project_id = ""
|
||||
if remaining:
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
)
|
||||
else:
|
||||
self._pending_files_project_id = ""
|
||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||
# fertig ist (siehe chat-handler oben).
|
||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||
@@ -1667,10 +1968,13 @@ class ARIABridge:
|
||||
url, data=payload, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
|
||||
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
|
||||
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
|
||||
with urllib.request.urlopen(req, timeout=1200) as resp:
|
||||
# Timeout MUSS zum Proxy passen (der laesst ARIA bis 24h
|
||||
# rechnen). 1200s (20 Min) war zu knapp: lange Software-Dev-
|
||||
# Turns dauern laenger → die Bridge gab auf, die Antwort ging
|
||||
# verloren (kein Bubble), der Kontext blieb auf 'running'
|
||||
# haengen. Jetzt 24h (env BRAIN_CHAT_TIMEOUT_SEC).
|
||||
_chat_timeout = float(os.environ.get("BRAIN_CHAT_TIMEOUT_SEC", "86400"))
|
||||
with urllib.request.urlopen(req, timeout=_chat_timeout) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||
except Exception as exc:
|
||||
return None, str(exc)
|
||||
@@ -1712,6 +2016,16 @@ class ARIABridge:
|
||||
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
|
||||
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
|
||||
speak = data.get("speak", True)
|
||||
# Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs
|
||||
# Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus.
|
||||
converse = data.get("converse", True)
|
||||
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
|
||||
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
|
||||
awaiting_reply = bool(data.get("awaiting_reply", False))
|
||||
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
|
||||
wake_off = bool(data.get("wake_off", False))
|
||||
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener wieder.
|
||||
wake_on = bool(data.get("wake_on", False))
|
||||
|
||||
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||
# damit sie in der UI vor der Reply auftauchen
|
||||
@@ -1759,10 +2073,15 @@ class ARIABridge:
|
||||
elif etype == "project_changed":
|
||||
# ARIA hat ein Projekt erstellt / betreten / verlassen / beendet.
|
||||
# App + Diagnostic refreshen ihren Projekt-Banner anhand des Events.
|
||||
# clientMsgId der ausloesenden Anfrage mitgeben (Text-cmid oder
|
||||
# Voice-audioRequestId) → das ausloesende Geraet kann einen
|
||||
# geraete-lokalen Projektwechsel machen ("geh in Projekt X"),
|
||||
# ohne andere App-/Diagnostic-Instanzen mitzuziehen.
|
||||
await self._send_to_rvs({
|
||||
"type": "project_changed",
|
||||
"payload": {
|
||||
"action": event.get("action") or "",
|
||||
"clientMsgId": client_msg_id or "",
|
||||
**(event.get("project") or {}),
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
@@ -1770,6 +2089,22 @@ class ARIABridge:
|
||||
proj = event.get("project") or {}
|
||||
logger.info("[brain] Projekt %s: %s (id=%s)",
|
||||
event.get("action") or "?", proj.get("name"), proj.get("id"))
|
||||
elif etype == "aria_view":
|
||||
# M1: ARIA hat via present_view eine raeumliche Ansicht komponiert.
|
||||
# View-Spec (Orb + Karten) + Projekt-Kontext an App/Web/Diagnostic;
|
||||
# deren Renderer materialisieren die Karten auf der Flaeche.
|
||||
view = event.get("view") or {}
|
||||
await self._send_to_rvs({
|
||||
"type": "aria_view",
|
||||
"payload": {
|
||||
"view": view,
|
||||
"projectId": event.get("project_id") or "",
|
||||
"clientMsgId": client_msg_id or "",
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[brain] ARIA hat eine Ansicht geschickt: %d Karte(n), orb=%s",
|
||||
len(view.get("cards") or []), view.get("orb"))
|
||||
|
||||
# _process_core_response uebernimmt alles weitere:
|
||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||
@@ -1780,7 +2115,11 @@ class ARIABridge:
|
||||
try:
|
||||
await self._process_core_response(reply, {"projectId": turn_project_id,
|
||||
"answeredBy": answered_by,
|
||||
"speak": speak})
|
||||
"speak": speak,
|
||||
"converse": converse,
|
||||
"awaiting_reply": awaiting_reply,
|
||||
"wake_off": wake_off,
|
||||
"wake_on": wake_on})
|
||||
except Exception:
|
||||
logger.exception("[brain] _process_core_response Fehler")
|
||||
await self._emit_activity("idle", "", project_id=project_id)
|
||||
@@ -2082,11 +2421,15 @@ class ARIABridge:
|
||||
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
||||
# zwei separater send_to_core-Calls.
|
||||
merged = await self._flush_pending_files_with_text(
|
||||
text, project_id=str(payload.get("projectId") or ""))
|
||||
text, project_id=str(payload.get("projectId") or ""),
|
||||
client_msg_id=client_msg_id or "")
|
||||
if merged:
|
||||
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
|
||||
str(payload.get("projectId") or "") or "(main)", text[:80])
|
||||
else:
|
||||
_lg = location or getattr(self, "_last_location", None)
|
||||
if isinstance(_lg, dict) and _lg.get("lat") is not None:
|
||||
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
logger.info("[rvs] App-Chat%s%s: '%s'",
|
||||
" [BARGE-IN]" if interrupted else "",
|
||||
@@ -2121,6 +2464,20 @@ class ARIABridge:
|
||||
await self._emit_activity("idle", "", project_id=cancel_pid)
|
||||
return
|
||||
|
||||
if msg_type == "interject":
|
||||
# Zwischenruf: waehrend eines laufenden Turns eine Korrektur
|
||||
# reinschieben — KEIN Abbruch, keine Queue. Geht an den Proxy-
|
||||
# internen /interject, der die Message in den laufenden Subprozess
|
||||
# des Kontexts schreibt (claude greift sie an der naechsten Tool-
|
||||
# Grenze auf).
|
||||
interject_pid = str(payload.get("projectId") or "")
|
||||
interject_text = str(payload.get("text") or "")
|
||||
logger.info("[rvs] Zwischenruf project=%s: '%s'",
|
||||
interject_pid or "(main)", interject_text[:80])
|
||||
if interject_text.strip():
|
||||
await self._interject_proxy_for_project(interject_pid, interject_text)
|
||||
return
|
||||
|
||||
elif msg_type == "audio_pcm":
|
||||
# Audio-PCM geht direkt von XTTS-Bridge an die App.
|
||||
# Die aria-bridge darf es NICHT rebroadcasten — sonst bekommt die App
|
||||
@@ -2373,8 +2730,11 @@ class ARIABridge:
|
||||
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
|
||||
file_name, e)
|
||||
|
||||
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
|
||||
# In Pending-Queue + Flush-Timer (anti-spam Buffering).
|
||||
# clientMsgId mitpuffern → spaeterer Text-Flush ordnet die Datei
|
||||
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||
file_cmid = str(payload.get("clientMsgId") or "")
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
@@ -3103,6 +3463,82 @@ class ARIABridge:
|
||||
logger.info("[rvs] flux-bridge -> %s", state)
|
||||
return
|
||||
|
||||
elif msg_type == "code_file_edit":
|
||||
# Stefan hat im Live-Editor getippt → Datei im Projekt-Arbeits-
|
||||
# verzeichnis (/shared/projects/<pid>/) mit dem Volltext ueberschreiben.
|
||||
# ARIA sieht die Aenderung beim naechsten Read.
|
||||
pid = str(payload.get("projectId") or "")
|
||||
rel = (payload.get("path") or "").strip()
|
||||
full_text = payload.get("fullText")
|
||||
if rel and isinstance(full_text, str):
|
||||
ok = self._write_project_file(pid, rel, full_text)
|
||||
logger.info("[rvs] code_file_edit %s/%s (%d chars) → %s",
|
||||
pid or "main", rel, len(full_text), "ok" if ok else "abgelehnt")
|
||||
return
|
||||
|
||||
elif msg_type == "check_desktop":
|
||||
session = str(payload.get("session") or "main")
|
||||
try:
|
||||
port = int(payload.get("port") or 5901)
|
||||
except (TypeError, ValueError):
|
||||
port = 5901
|
||||
asyncio.create_task(self._check_desktop(session, port))
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_open":
|
||||
session = str(payload.get("session") or "main")
|
||||
try:
|
||||
port = int(payload.get("port") or 5901)
|
||||
except (TypeError, ValueError):
|
||||
port = 5901
|
||||
asyncio.create_task(self._vnc_open(session, port))
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_close":
|
||||
session = str(payload.get("session") or "main")
|
||||
asyncio.create_task(self._vnc_close(session))
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_input":
|
||||
session = str(payload.get("session") or "main")
|
||||
b64 = payload.get("b64") or ""
|
||||
sess = self._vnc_sessions.get(session)
|
||||
if sess and b64:
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
sess["writer"].write(data)
|
||||
# drain nicht awaiten (wir sind synchron im Dispatcher) —
|
||||
# write puffert, der Kernel-Socket schluckt RFB-Input locker.
|
||||
except Exception as exc:
|
||||
logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc)
|
||||
return
|
||||
|
||||
elif msg_type == "sat_hello":
|
||||
sid = (payload.get("id") or "").strip()
|
||||
if sid:
|
||||
self._satellites[sid] = {
|
||||
"id": sid,
|
||||
"location": payload.get("location") or sid,
|
||||
"caps": payload.get("caps") or [],
|
||||
"control": bool(payload.get("control")),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
||||
sid, self._satellites[sid]["location"],
|
||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||
return
|
||||
|
||||
elif msg_type in ("sat_devices", "sat_result"):
|
||||
req_id = payload.get("requestId", "")
|
||||
future = self._pending_sat.get(req_id)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(payload)
|
||||
# last_seen aktualisieren
|
||||
sid = (payload.get("satellite") or "").strip()
|
||||
if sid and sid in self._satellites:
|
||||
self._satellites[sid]["last_seen"] = time.time()
|
||||
return
|
||||
|
||||
elif msg_type == "config_request":
|
||||
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
|
||||
# Config — passiert wenn sie sich connected, weil sie sonst die
|
||||
@@ -3184,6 +3620,9 @@ class ARIABridge:
|
||||
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
|
||||
|
||||
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
||||
_lg = location or getattr(self, "_last_location", None)
|
||||
if isinstance(_lg, dict) and _lg.get("lat") is not None:
|
||||
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
await self.send_to_core(core_text,
|
||||
source="app-voice" + (" [barge-in]" if interrupted else ""),
|
||||
@@ -3312,8 +3751,11 @@ class ARIABridge:
|
||||
)
|
||||
|
||||
if meta_action in ("back_to_main", "project_prefix"):
|
||||
# UI-Focus-Update broadcasten
|
||||
payload = {"action": "entered" if meta_action == "project_prefix" else "exited"}
|
||||
# UI-Focus-Update broadcasten — clientMsgId (= audio_request_id der
|
||||
# Aufnahme) mitgeben, damit NUR das Geraet folgt, das den Sprach-
|
||||
# befehl gab (geraete-lokaler Fokus, keine anderen Instanzen ziehen).
|
||||
payload = {"action": "entered" if meta_action == "project_prefix" else "exited",
|
||||
"clientMsgId": audio_request_id or ""}
|
||||
if meta_action == "project_prefix" and project_id:
|
||||
# Namen aus dem Cache holen — best effort
|
||||
try:
|
||||
@@ -3734,6 +4176,28 @@ class ARIABridge:
|
||||
logger.info("[cancel] proxy /cancel project=%s: %s %s",
|
||||
project_id or "(main)", status, body)
|
||||
|
||||
async def _interject_proxy_for_project(self, project_id: str, text: str) -> None:
|
||||
"""Zwischenruf: schiebt eine User-Message in den laufenden Turn dieses
|
||||
Kontexts (proxy-internes /interject) — ohne Abbruch. claude greift sie
|
||||
an der naechsten Tool-Grenze auf."""
|
||||
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/interject"
|
||||
data = json.dumps({"projectId": project_id or "", "text": text}).encode("utf-8")
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url, method="POST", data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[interject] proxy /interject project=%s: %s %s",
|
||||
project_id or "(main)", status, body)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False,
|
||||
project_id: str = "") -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||
@@ -3941,6 +4405,54 @@ class ARIABridge:
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/code-file":
|
||||
# Vom Proxy-Hook gefeuert wenn ARIA in einem Code-Projekt eine
|
||||
# Datei schreibt/aendert. Wir spiegeln das als RVS code_file an
|
||||
# die App (Live-Code-Editor). payload: {projectId, path,
|
||||
# language?, content?, patch?, version?}.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
fpath = (data.get("path") or "").strip()
|
||||
if not fpath:
|
||||
await _send_response(writer, 400, {"error": "path erforderlich"})
|
||||
return
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "code_file",
|
||||
"payload": {
|
||||
"projectId": str(data.get("projectId") or ""),
|
||||
"path": fpath,
|
||||
"language": data.get("language") or "",
|
||||
"content": data.get("content"),
|
||||
"patch": data.get("patch"),
|
||||
"version": data.get("version") or 0,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/satellite-list":
|
||||
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
|
||||
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
|
||||
elif method == "POST" and path == "/internal/satellite":
|
||||
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
op = (data.get("op") or "discover").strip()
|
||||
result = await self._satellite_request(
|
||||
op=op,
|
||||
satellite=str(data.get("satellite") or ""),
|
||||
device=str(data.get("device") or ""),
|
||||
action=str(data.get("action") or ""),
|
||||
params=data.get("params") if isinstance(data.get("params"), dict) else {},
|
||||
timeout=float(data.get("timeout") or 20.0),
|
||||
)
|
||||
await _send_response(writer, 200, result)
|
||||
elif method == "POST" and path == "/internal/flux-generate":
|
||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
||||
@@ -4050,6 +4562,164 @@ class ARIABridge:
|
||||
except Exception:
|
||||
logger.exception("[bridge] Internal HTTP-Listener konnte nicht starten")
|
||||
|
||||
def _write_project_file(self, project_id: str, rel_path: str, content: str) -> bool:
|
||||
"""Schreibt content nach /shared/projects/<project_id>/<rel_path>.
|
||||
|
||||
Pfad-sicher: rel_path darf nicht aus dem Projekt-Basisverzeichnis
|
||||
ausbrechen (kein .., kein absoluter Pfad). Legt Verzeichnisse an.
|
||||
"""
|
||||
try:
|
||||
base = Path("/shared/projects") / (project_id or "main")
|
||||
base = base.resolve()
|
||||
target = (base / rel_path).resolve()
|
||||
if base != target and base not in target.parents:
|
||||
logger.warning("[code_file] Pfad ausserhalb Projekt abgelehnt: %s", rel_path)
|
||||
return False
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
tmp.replace(target)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("[code_file] Schreiben fehlgeschlagen %s/%s: %s", project_id, rel_path, exc)
|
||||
return False
|
||||
|
||||
async def _check_desktop(self, session: str, port: int) -> None:
|
||||
"""Probt ob auf dem Host eine QEMU-VNC laeuft und meldet desktop_status."""
|
||||
available = False
|
||||
try:
|
||||
fut = asyncio.open_connection(self._vnc_host, port)
|
||||
reader, writer = await asyncio.wait_for(fut, timeout=2.0)
|
||||
available = True
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
available = False
|
||||
await self._send_to_rvs({
|
||||
"type": "desktop_status",
|
||||
"payload": {
|
||||
"available": available,
|
||||
"session": session,
|
||||
"message": (f"Desktop laeuft (VNC {self._vnc_host}:{port})" if available
|
||||
else "Kein Desktop verbunden"),
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _vnc_open(self, session: str, port: int) -> None:
|
||||
"""Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als
|
||||
vnc_data ueber RVS. Bestehende Session wird vorher geschlossen."""
|
||||
await self._vnc_close(session)
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(self._vnc_host, port)
|
||||
except Exception as exc:
|
||||
logger.warning("[vnc] open %s:%d fehlgeschlagen: %s", self._vnc_host, port, exc)
|
||||
await self._send_to_rvs({
|
||||
"type": "desktop_status",
|
||||
"payload": {"available": False, "session": session,
|
||||
"message": f"VNC-Verbindung fehlgeschlagen: {exc}"},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
return
|
||||
task = asyncio.create_task(self._vnc_reader_loop(session, reader))
|
||||
self._vnc_sessions[session] = {"writer": writer, "task": task}
|
||||
logger.info("[vnc] Tunnel offen: session=%s → %s:%d", session, self._vnc_host, port)
|
||||
|
||||
async def _vnc_reader_loop(self, session: str, reader: asyncio.StreamReader) -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(16384)
|
||||
if not data:
|
||||
break
|
||||
await self._send_to_rvs({
|
||||
"type": "vnc_data",
|
||||
"payload": {"session": session, "b64": base64.b64encode(data).decode("ascii")},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.info("[vnc] reader-loop (%s) beendet: %s", session, exc)
|
||||
finally:
|
||||
# TCP-Ende → App informieren (Desktop weg).
|
||||
if session in self._vnc_sessions:
|
||||
await self._send_to_rvs({
|
||||
"type": "desktop_status",
|
||||
"payload": {"available": False, "session": session, "message": "VNC-Verbindung beendet"},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _vnc_close(self, session: str) -> None:
|
||||
sess = self._vnc_sessions.pop(session, None)
|
||||
if not sess:
|
||||
return
|
||||
task = sess.get("task")
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
writer = sess.get("writer")
|
||||
if writer:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("[vnc] Tunnel geschlossen: session=%s", session)
|
||||
|
||||
def _satellite_list(self) -> list[dict]:
|
||||
"""Bekannte Satelliten (frisch = in den letzten 5 Min gesehen)."""
|
||||
now = time.time()
|
||||
out = []
|
||||
for s in self._satellites.values():
|
||||
out.append({
|
||||
"id": s["id"], "location": s.get("location") or s["id"],
|
||||
"caps": s.get("caps") or [], "control": bool(s.get("control")),
|
||||
"online": (now - s.get("last_seen", 0)) < 300,
|
||||
})
|
||||
return out
|
||||
|
||||
async def _satellite_request(self, op: str, satellite: str = "",
|
||||
device: str = "", action: str = "",
|
||||
params: Optional[dict] = None,
|
||||
timeout: float = 20.0) -> dict:
|
||||
"""Schickt sat_discover / sat_command an einen Satelliten (via RVS) und
|
||||
wartet auf sat_devices / sat_result. op = 'discover' | 'command'.
|
||||
Muster identisch zu _flux_generate (requestId → Future)."""
|
||||
if self.ws_rvs is None:
|
||||
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending_sat[request_id] = future
|
||||
try:
|
||||
if op == "discover":
|
||||
msg = {"type": "sat_discover",
|
||||
"payload": {"requestId": request_id, "satellite": satellite,
|
||||
"force": bool((params or {}).get("force"))}}
|
||||
else:
|
||||
msg = {"type": "sat_command",
|
||||
"payload": {"requestId": request_id, "satellite": satellite,
|
||||
"device": device, "action": action,
|
||||
"params": params or {}}}
|
||||
msg["timestamp"] = int(time.time() * 1000)
|
||||
ok = await self._send_to_rvs(msg)
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Satellit-Request konnte nicht gesendet werden"}
|
||||
result = await asyncio.wait_for(future, timeout=timeout)
|
||||
return result if isinstance(result, dict) else {"ok": False, "error": "ungueltige Antwort"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": f"Satellit '{satellite or 'all'}' antwortet nicht (Timeout)."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
self._pending_sat.pop(request_id, None)
|
||||
|
||||
async def _delete_chat_message(self, ts: int) -> dict:
|
||||
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
|
||||
broadcastet chat_message_deleted via RVS.
|
||||
|
||||
+336
-21
@@ -198,7 +198,7 @@
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<span id="disk-banner-icon" style="font-size:18px;">⚠️</span>
|
||||
<span id="disk-banner-text" style="flex:1;min-width:200px;font-weight:600;"></span>
|
||||
<button onclick="copyDiskCmd('safe')" class="btn secondary" style="padding:4px 10px;font-size:11px;" title="docker builder prune -a -f && docker image prune -a -f">
|
||||
<button id="disk-clean-btn" onclick="runDiskCleanup('safe')" class="btn secondary" style="padding:4px 10px;font-size:11px;" title="Fuehrt aus: docker builder prune -a && docker image prune -a (ohne Volumes)">
|
||||
Sicher aufraeumen
|
||||
</button>
|
||||
<button onclick="document.getElementById('disk-banner-aggressive').style.display=(document.getElementById('disk-banner-aggressive').style.display==='none'?'flex':'none')"
|
||||
@@ -216,6 +216,7 @@
|
||||
<div style="color:#FFAA55;">
|
||||
<b>Aggressiv</b> — zusaetzlich ungenutzte Volumes. <b>Nur wenn alle ARIA-Container laufen</b>, sonst riskierst du Daten-Verlust (Sessions, SSH-Keys, Shared):<br>
|
||||
<code style="font-family:monospace;">docker system prune -a --volumes -f</code>
|
||||
<button onclick="runDiskCleanup('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;border-color:#FF3B30;color:#FF3B30;">Jetzt ausfuehren</button>
|
||||
<button onclick="copyDiskCmd('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,6 +230,7 @@
|
||||
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('satellites')">Satelliten</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
|
||||
</div>
|
||||
|
||||
@@ -320,12 +322,14 @@
|
||||
</div>
|
||||
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
</div>
|
||||
<div id="diag-queue-banner" style="display:none;padding:6px 10px;background:#2A2410;border:1px solid #4A3F14;border-radius:6px;margin-bottom:6px;"></div>
|
||||
<div class="input-row">
|
||||
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
|
||||
📎
|
||||
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
||||
</label>
|
||||
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
||||
<button class="btn secondary" onclick="interjectDiag('chat-input')" title="Zwischenruf — Korrektur in den laufenden Turn (kein Abbruch, keine Queue)" style="border-color:#FF9500;color:#FF9500;">📣</button>
|
||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,6 +347,7 @@
|
||||
</div>
|
||||
<div class="input-row" style="margin-top:8px;">
|
||||
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
||||
<button class="btn secondary" onclick="interjectDiag('chat-input-fs')" title="Zwischenruf — Korrektur in den laufenden Turn (kein Abbruch, keine Queue)" style="border-color:#FF9500;color:#FF9500;">📣</button>
|
||||
<button class="btn" onclick="testRVSFS()">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -783,6 +788,18 @@
|
||||
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
||||
Status wird geladen...
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
|
||||
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
|
||||
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
|
||||
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
|
||||
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
|
||||
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
|
||||
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
|
||||
@@ -1063,11 +1080,13 @@
|
||||
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
|
||||
<div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div>
|
||||
<div style="color:#8888AA;font-size:11px;margin-bottom:8px;">
|
||||
Klein und schnell: <strong>nur</strong> die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON.
|
||||
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer.
|
||||
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet.
|
||||
Getrennt nach <strong>scope</strong>: <span style="color:#3FFF3F;">System</span> = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System.
|
||||
<span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
|
||||
Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
|
||||
</div>
|
||||
<button class="btn secondary" onclick="exportBootstrap()" style="color:#FFD60A;border-color:#FFD60A;">⬇ Bootstrap exportieren (JSON)</button>
|
||||
<button class="btn secondary" onclick="exportBootstrap('system')" style="color:#3FFF3F;border-color:#3FFF3F;">⬇ System-Regeln exportieren</button>
|
||||
<button class="btn secondary" onclick="exportBootstrap('personal')" style="color:#FF9F0A;border-color:#FF9F0A;">⬇ Persönliches exportieren</button>
|
||||
<button class="btn secondary" onclick="exportBootstrap('all')" style="color:#FFD60A;border-color:#FFD60A;">⬇ Alles (Vollbackup)</button>
|
||||
<input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)">
|
||||
<button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button>
|
||||
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
|
||||
@@ -1270,6 +1289,27 @@
|
||||
</div>
|
||||
</div><!-- /tab-triggers -->
|
||||
|
||||
<!-- ══════ TAB: Satelliten ══════ -->
|
||||
<div id="tab-satellites" class="main-tab">
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">Satelliten 🛰️</h2>
|
||||
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:8px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0;">
|
||||
Aussenposten-Container in fremden Netzen (Buero, Werkstatt …), die sich mit
|
||||
RVS verbinden. Hier siehst Du, welche Satelliten online sind und welche
|
||||
Geraete sie im jeweiligen Netz erkannt haben. „Geraete scannen" fragt den
|
||||
Satelliten live ab.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div id="sat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-satellites -->
|
||||
|
||||
<!-- Trigger-Create/Edit Modal -->
|
||||
<div class="modal-overlay" id="trigger-modal">
|
||||
<div class="modal-box" style="max-width:600px;">
|
||||
@@ -1370,6 +1410,11 @@
|
||||
<input type="checkbox" id="memory-pinned">
|
||||
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
||||
</label>
|
||||
<label style="display:block;color:#8888AA;font-size:11px;margin-top:10px;margin-bottom:3px;">Scope (steuert Bootstrap-Export):</label>
|
||||
<select id="memory-scope" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
<option value="personal">🟠 Persönlich — Stefan-spezifisch, bleibt privat</option>
|
||||
<option value="system">🟢 System — generische Regel, teilbar für frisches System</option>
|
||||
</select>
|
||||
|
||||
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
|
||||
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
|
||||
@@ -1425,6 +1470,12 @@
|
||||
let diagQueueStatus = {};
|
||||
let diagProjectsCache = [];
|
||||
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
|
||||
// ── Pro-Kontext-Queue + Zustandsautomat (spiegelt die App) ──
|
||||
const diagCtxStates = {}; // pid → 'idle' | 'running' | 'awaiting_reply'
|
||||
const diagCtxQueues = {}; // pid → [{id,text}]
|
||||
const diagDrafts = JSON.parse(localStorage.getItem('diag_ctx_drafts') || '{}');
|
||||
let diagQid = 0;
|
||||
const diagState = (pid) => diagCtxStates[pid] || 'idle';
|
||||
|
||||
function updateChatVisibilityByFocus() {
|
||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||
@@ -1438,10 +1489,22 @@
|
||||
}
|
||||
|
||||
function switchDiagFocus(id) {
|
||||
focusedContextId = id || '';
|
||||
const nextId = id || '';
|
||||
// Pro-Kontext-Textfeld-Entwuerfe: Feldinhalt dem verlassenen Kontext
|
||||
// zuordnen, Entwurf des neuen laden.
|
||||
if (nextId !== focusedContextId) {
|
||||
const input = document.getElementById('chat-input');
|
||||
if (input) {
|
||||
diagDrafts[focusedContextId] = input.value;
|
||||
input.value = diagDrafts[nextId] || '';
|
||||
}
|
||||
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||
}
|
||||
focusedContextId = nextId;
|
||||
localStorage.setItem('diag_focused_context_id', focusedContextId);
|
||||
updateChatVisibilityByFocus();
|
||||
renderContextStrip();
|
||||
renderDiagQueue();
|
||||
}
|
||||
|
||||
function renderContextStrip() {
|
||||
@@ -1544,9 +1607,34 @@
|
||||
const d = await r.json();
|
||||
diagQueueStatus = d?.contexts || {};
|
||||
renderContextStrip();
|
||||
reconcileDiagStates();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Selbstheilung: haengt ein Kontext lokal auf 'running', obwohl der Brain
|
||||
// ihn NICHT als busy meldet, ist der Turn (z.B. durch einen Bridge-Timeout)
|
||||
// ohne Antwort-Bubble abgebrochen — der Automat blieb sonst ewig 'running'
|
||||
// (Folge-Nachrichten wandern in die Queue, obwohl ARIA idle ist; frueher
|
||||
// half nur Ctrl+R, was die Queue-Nachricht verlor). Nach 2 aufeinander-
|
||||
// folgenden not-busy-Polls (~4s Karenz gegen das Sende-Fenster) schalten
|
||||
// wir die Queue weiter: angestellte Nachricht geht automatisch raus, sonst
|
||||
// idle.
|
||||
const diagStuckCounts = {};
|
||||
function reconcileDiagStates() {
|
||||
const ctxs = diagQueueStatus || {};
|
||||
for (const pid of Object.keys(diagCtxStates)) {
|
||||
if (diagCtxStates[pid] !== 'running') { diagStuckCounts[pid] = 0; continue; }
|
||||
const busy = !!(ctxs[pid || '__main__'] && ctxs[pid || '__main__'].busy);
|
||||
if (busy) { diagStuckCounts[pid] = 0; continue; }
|
||||
diagStuckCounts[pid] = (diagStuckCounts[pid] || 0) + 1;
|
||||
if (diagStuckCounts[pid] >= 2) {
|
||||
diagStuckCounts[pid] = 0;
|
||||
console.warn('[diag] Kontext', pid || '(main)', 'haengt auf running, Brain idle → Queue weiterschalten');
|
||||
advanceDiagQueue(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDiagProjectsCache() {
|
||||
try {
|
||||
const r = await fetch('/api/brain/projects/list?include_archived=false');
|
||||
@@ -1696,6 +1784,27 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'disk_cleanup') {
|
||||
const btn = document.getElementById('disk-clean-btn');
|
||||
const dtext = document.getElementById('disk-banner-text');
|
||||
if (msg.status === 'running') {
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
|
||||
} else if (msg.status === 'done') {
|
||||
// Feedback NICHT nur per alert() (koennte unterdrueckt sein) —
|
||||
// direkt am Button + im Banner sichtbar machen.
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '✓ ' + (msg.freed || '0 MB') + ' frei';
|
||||
setTimeout(() => { btn.textContent = 'Sicher aufraeumen'; }, 6000);
|
||||
}
|
||||
if (dtext) dtext.textContent = 'Aufgeraeumt: ' + (msg.freed || '0 MB') + ' frei (' + (msg.steps || []).join(', ') + '). Disk-Status aktualisiert sich gleich.';
|
||||
} else if (msg.status === 'error') {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Fehler — nochmal'; setTimeout(() => { btn.textContent = 'Sicher aufraeumen'; }, 6000); }
|
||||
if (dtext) dtext.textContent = 'Aufraeumen fehlgeschlagen: ' + (msg.error || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'mode' && msg.payload) {
|
||||
// Bridge hat den Modus geaendert (evtl. von anderer App/Diagnostic) — UI syncen
|
||||
const mode = (msg.payload.mode || '').toLowerCase();
|
||||
@@ -1809,6 +1918,11 @@
|
||||
if (slider) slider.value = msg.voiceIdThreshold;
|
||||
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
||||
}
|
||||
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
|
||||
{
|
||||
const cb = document.getElementById('diag-voice-id-enabled');
|
||||
if (cb) cb.checked = !!msg.voiceIdEnabled;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1843,6 +1957,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'sat_update') { satellites = msg.satellites || []; renderSatellites(); return; }
|
||||
if (msg.type === 'sat_devices') {
|
||||
if (msg.satellite) { satDevices[msg.satellite] = { devices: msg.devices || [], location: msg.location, ts: Date.now() }; }
|
||||
satScanning = null;
|
||||
renderSatellites();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'voice_id_delete_response') {
|
||||
const p = msg.payload || msg;
|
||||
if (p.removed) {
|
||||
@@ -1983,6 +2105,15 @@
|
||||
projectId: p.projectId || '',
|
||||
answeredBy: p.answeredBy || '',
|
||||
});
|
||||
// ── Pro-Kontext-Queue-Automat: auf ARIAs Antwort reagieren ──
|
||||
if (sender === 'aria') {
|
||||
const apid = p.projectId || '';
|
||||
const st = diagState(apid);
|
||||
if (st === 'running' || st === 'awaiting_reply') {
|
||||
if (p.awaiting_reply) { diagCtxStates[apid] = 'awaiting_reply'; renderDiagQueue(); }
|
||||
else advanceDiagQueue(apid);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'chat_message_deleted') {
|
||||
@@ -2179,16 +2310,76 @@
|
||||
renderDiagPending();
|
||||
}
|
||||
|
||||
// ── Pro-Kontext-Queue: Helfer (spiegelt die App) ──
|
||||
function actuallySendDiag(pid, text) {
|
||||
addChat('sent', text, 'via RVS', { projectId: pid });
|
||||
send({ action: 'test_rvs', text, projectId: pid });
|
||||
diagCtxStates[pid] = 'running';
|
||||
renderDiagQueue();
|
||||
}
|
||||
// Zwischenruf: waehrend ARIA arbeitet eine Korrektur in den laufenden Turn
|
||||
// schieben — NICHT anstellen, NICHT abbrechen.
|
||||
function interjectDiag(inputId) {
|
||||
const input = document.getElementById(inputId || 'chat-input');
|
||||
const text = (input.value || '').trim();
|
||||
if (!text) return;
|
||||
send({ action: 'interject', text, projectId: focusedContextId });
|
||||
addChat('sent', '📣 Zwischenruf: ' + text, 'interject', { projectId: focusedContextId });
|
||||
diagDrafts[focusedContextId] = '';
|
||||
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||
input.value = '';
|
||||
}
|
||||
function advanceDiagQueue(pid) {
|
||||
const q = diagCtxQueues[pid] || [];
|
||||
if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; }
|
||||
const next = q.shift();
|
||||
actuallySendDiag(pid, next.text);
|
||||
}
|
||||
function enqueueOrSendDiag(pid, text) {
|
||||
if (diagState(pid) === 'running') {
|
||||
(diagCtxQueues[pid] = diagCtxQueues[pid] || []).push({ id: 'q' + (++diagQid), text });
|
||||
renderDiagQueue();
|
||||
} else {
|
||||
// idle ODER awaiting_reply → sofort (bei awaiting = Antwort auf Rueckfrage).
|
||||
actuallySendDiag(pid, text);
|
||||
}
|
||||
}
|
||||
function removeDiagQueuedFocused(id) {
|
||||
const pid = focusedContextId;
|
||||
diagCtxQueues[pid] = (diagCtxQueues[pid] || []).filter(it => it.id !== id);
|
||||
renderDiagQueue();
|
||||
}
|
||||
function renderDiagQueue() {
|
||||
const el = document.getElementById('diag-queue-banner');
|
||||
if (!el) return;
|
||||
const st = diagState(focusedContextId);
|
||||
const q = diagCtxQueues[focusedContextId] || [];
|
||||
if (st !== 'awaiting_reply' && q.length === 0) { el.style.display = 'none'; el.innerHTML = ''; return; }
|
||||
el.style.display = 'block';
|
||||
let html = '';
|
||||
if (st === 'awaiting_reply') {
|
||||
html += '<div style="color:#FFD60A;font-weight:600;margin-bottom:4px;">❓ ARIA fragt nach — deine Eingabe beantwortet das</div>';
|
||||
}
|
||||
if (q.length) {
|
||||
html += q.map(it =>
|
||||
'<span style="display:inline-block;background:#1E1E2E;border:1px solid #4A3F14;border-radius:10px;padding:2px 8px;margin:2px;font-size:12px;color:#FFD60A;">⏸ ' +
|
||||
escapeHtml(it.text.slice(0, 40)) +
|
||||
' <a href="#" style="color:#FF6B6B;text-decoration:none;" onclick="removeDiagQueuedFocused(\'' + it.id + '\');return false;">✕</a></span>'
|
||||
).join('');
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function testRVS() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const text = input.value.trim();
|
||||
if (!text && diagPendingFiles.length === 0) return;
|
||||
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||
if (text) {
|
||||
// Multi-Threading: mit fokussierter Kontext-ID senden.
|
||||
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
|
||||
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
|
||||
send({ action: 'test_rvs', text, projectId: focusedContextId });
|
||||
// Draft dieses Kontexts leeren + Queue-Automat entscheidet senden/anstellen.
|
||||
diagDrafts[focusedContextId] = '';
|
||||
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||
enqueueOrSendDiag(focusedContextId, text);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
@@ -2675,8 +2866,8 @@
|
||||
const input = document.getElementById('chat-input-fs');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
addChat('sent', text, 'via RVS');
|
||||
send({ action: 'test_rvs', text });
|
||||
// Ueber denselben Queue-Automaten wie der Haupt-Chat (fokussierter Kontext).
|
||||
enqueueOrSendDiag(focusedContextId, text);
|
||||
input.value = '';
|
||||
}
|
||||
// Escape schliesst Vollbild-Chat
|
||||
@@ -3086,14 +3277,17 @@
|
||||
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
|
||||
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
|
||||
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
|
||||
${p.has_files ? `<span title="${p.file_count || 0} Dateien in /shared/projects/${escapeHtmlAttr(p.id)}/" style="font-size:11px;margin-left:4px;">📄${p.file_count ? ' ' + p.file_count : ''}</span>` : ''}
|
||||
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
|
||||
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
|
||||
${p.kind === 'code' ? '<span style="color:#0096FF;font-size:10px;font-weight:700;margin-left:6px;background:rgba(0,150,255,0.15);padding:2px 6px;border-radius:3px;"></> Code</span>' : ''}
|
||||
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
|
||||
</div>
|
||||
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
|
||||
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn secondary" onclick="setProjectKind('${p.id}', '${p.kind === 'code' ? 'chat' : 'code'}')" style="padding:3px 8px;font-size:10px;color:${p.kind === 'code' ? '#0096FF' : '#8888AA'};" title="${p.kind === 'code' ? 'Ist Code-Projekt — klick fuer normalen Chat' : 'Als Code-Projekt markieren (Editor + Desktop im Cockpit)'}"></></button>
|
||||
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
|
||||
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden">⏹</button>` : ''}
|
||||
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
|
||||
@@ -3140,6 +3334,18 @@
|
||||
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
|
||||
}
|
||||
|
||||
async function setProjectKind(id, kind) {
|
||||
try {
|
||||
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind }),
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
loadProjects();
|
||||
} catch (e) { alert('Code-Markierung fehlgeschlagen: ' + e.message); }
|
||||
}
|
||||
|
||||
async function switchProject(projectId) {
|
||||
try {
|
||||
await fetch('/api/brain/projects/switch', {
|
||||
@@ -3418,13 +3624,14 @@
|
||||
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
||||
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
||||
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
||||
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
|
||||
send({
|
||||
action: 'send_voice_config',
|
||||
ttsEnabled, xttsVoice, whisperModel,
|
||||
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||
f5ttsCfgStrength, f5ttsNfeStep,
|
||||
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
||||
voiceIdThreshold,
|
||||
voiceIdThreshold, voiceIdEnabled,
|
||||
});
|
||||
const statusEl = document.getElementById('voice-status');
|
||||
if (statusEl && xttsVoice) {
|
||||
@@ -3650,6 +3857,12 @@
|
||||
}
|
||||
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
|
||||
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
|
||||
// Entwurf des zuletzt fokussierten Kontexts ins Feld laden + Queue-Banner init.
|
||||
(function initDiagDraft() {
|
||||
const input = document.getElementById('chat-input');
|
||||
if (input && diagDrafts[focusedContextId]) input.value = diagDrafts[focusedContextId];
|
||||
renderDiagQueue();
|
||||
})();
|
||||
|
||||
// Escape schliesst Lightbox
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -3963,9 +4176,79 @@
|
||||
loadSkills();
|
||||
} else if (tab === 'triggers') {
|
||||
loadTriggers();
|
||||
} else if (tab === 'satellites') {
|
||||
requestSatellites();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Satelliten-Ansicht ─────────────────────────────────
|
||||
let satellites = []; // [{id, location, caps, control, online}]
|
||||
let satDevices = {}; // id → {devices, location, ts}
|
||||
let satScanning = null; // id des gerade scannenden Satelliten
|
||||
|
||||
function requestSatellites() {
|
||||
send({ action: 'sat_list' });
|
||||
}
|
||||
function scanSatellite(id) {
|
||||
satScanning = id;
|
||||
renderSatellites();
|
||||
send({ action: 'sat_discover', satellite: id });
|
||||
}
|
||||
function renderSatellites() {
|
||||
const box = document.getElementById('sat-list');
|
||||
if (!box) return;
|
||||
if (!satellites.length) {
|
||||
box.innerHTML = '<span style="color:#8888AA;">Kein Satellit verbunden. ' +
|
||||
'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = satellites.map(s => {
|
||||
const online = s.online !== false;
|
||||
const dot = online ? '#34C759' : '#FF3B30';
|
||||
const caps = (s.caps || []).join(', ') || 'nur beobachten';
|
||||
const ctrl = s.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#8888AA;">read-only</span>';
|
||||
const dev = satDevices[s.id];
|
||||
const scanning = satScanning === s.id;
|
||||
let devHtml;
|
||||
if (scanning) {
|
||||
devHtml = '<div style="color:#FFD60A;margin-top:6px;">Scanne Netz …</div>';
|
||||
} else if (dev) {
|
||||
if (!dev.devices.length) {
|
||||
devHtml = '<div style="color:#8888AA;margin-top:6px;">Keine Geraete gefunden.</div>';
|
||||
} else {
|
||||
devHtml = '<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px;">' +
|
||||
dev.devices.map(d => {
|
||||
const tags = [];
|
||||
if (d.model) tags.push(escapeHtml(d.model));
|
||||
if (d.dialAppUrl) tags.push('DIAL');
|
||||
if (d.mac) tags.push(escapeHtml(d.mac));
|
||||
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
||||
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;">' +
|
||||
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
||||
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
||||
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
} else {
|
||||
devHtml = '<div style="color:#8888AA;margin-top:6px;">Noch nicht gescannt.</div>';
|
||||
}
|
||||
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
|
||||
'<strong style="color:#E0E0F0;">' + escapeHtml(s.location || s.id) + '</strong> ' +
|
||||
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' +
|
||||
'<button class="btn secondary" onclick="scanSatellite(\'' + escapeHtml(s.id) + '\')" style="padding:3px 8px;font-size:11px;"' +
|
||||
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
||||
'</div>' +
|
||||
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
|
||||
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + '</div>' +
|
||||
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
|
||||
devHtml +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Triggers-Verwaltung ────────────────────────────────
|
||||
let triggersCache = [];
|
||||
|
||||
@@ -5528,9 +5811,15 @@
|
||||
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
|
||||
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
|
||||
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
|
||||
// scope-Badge nur bei pinned (nur die werden exportiert — da zaehlt die Trennung).
|
||||
const scopeBadge = m.pinned
|
||||
? (m.scope === 'system'
|
||||
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
|
||||
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
|
||||
: '';
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
|
||||
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
||||
</div>
|
||||
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
||||
@@ -5740,6 +6029,7 @@
|
||||
document.getElementById('memory-category').value = m.category || '';
|
||||
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
||||
document.getElementById('memory-pinned').checked = !!m.pinned;
|
||||
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
|
||||
// Anhang-Block sichtbar — Liste rendern
|
||||
if (attBlock) attBlock.style.display = 'block';
|
||||
if (attHint) attHint.style.display = 'none';
|
||||
@@ -5753,6 +6043,7 @@
|
||||
document.getElementById('memory-category').value = '';
|
||||
document.getElementById('memory-tags').value = '';
|
||||
document.getElementById('memory-pinned').checked = false;
|
||||
document.getElementById('memory-scope').value = 'personal';
|
||||
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
|
||||
if (attBlock) attBlock.style.display = 'none';
|
||||
if (attHint) attHint.style.display = 'block';
|
||||
@@ -5857,6 +6148,7 @@
|
||||
const category = document.getElementById('memory-category').value.trim();
|
||||
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const pinned = document.getElementById('memory-pinned').checked;
|
||||
const scope = document.getElementById('memory-scope').value || 'personal';
|
||||
|
||||
if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
|
||||
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
|
||||
@@ -5867,13 +6159,13 @@
|
||||
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content, pinned, category, tags }),
|
||||
body: JSON.stringify({ title, content, pinned, category, scope, tags }),
|
||||
});
|
||||
} else {
|
||||
r = await fetch('/api/brain/memory/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, title, content, pinned, category, tags, source: 'manual' }),
|
||||
body: JSON.stringify({ type, title, content, pinned, category, scope, tags, source: 'manual' }),
|
||||
});
|
||||
}
|
||||
if (!r.ok) {
|
||||
@@ -6250,11 +6542,12 @@
|
||||
}
|
||||
|
||||
// ── Bootstrap Export / Import ──────────────────────────
|
||||
async function exportBootstrap() {
|
||||
async function exportBootstrap(scope) {
|
||||
scope = scope || 'system';
|
||||
const status = document.getElementById('bootstrap-status');
|
||||
if (status) status.innerHTML = '⏳ Lade...';
|
||||
try {
|
||||
const r = await fetch('/api/brain/memory/export-bootstrap');
|
||||
const r = await fetch('/api/brain/memory/export-bootstrap?scope=' + encodeURIComponent(scope));
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const data = await r.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
@@ -6262,10 +6555,11 @@
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `aria-bootstrap-${ts}.json`;
|
||||
a.download = `aria-bootstrap-${scope}-${ts}.json`;
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} pinned Memories exportiert</span>`;
|
||||
const label = scope === 'system' ? 'System-Regeln' : (scope === 'personal' ? 'persönliche Memories' : 'pinned Memories');
|
||||
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} ${label} exportiert</span>`;
|
||||
} catch (e) {
|
||||
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
|
||||
}
|
||||
@@ -6279,7 +6573,11 @@
|
||||
const text = await file.text();
|
||||
const bundle = JSON.parse(text);
|
||||
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
|
||||
if (!confirm(`Bootstrap importieren?\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\nALLE aktuell pinned Memories werden überschrieben. Cold Memory bleibt unverändert.`)) {
|
||||
const bScope = bundle.scope || 'all';
|
||||
const scopeInfo = bScope === 'system' ? 'Nur die aktuell pinned SYSTEM-Regeln werden ersetzt — Persönliches bleibt.'
|
||||
: bScope === 'personal' ? 'Nur die aktuell pinned PERSÖNLICHEN Memories werden ersetzt — System-Regeln bleiben.'
|
||||
: 'ALLE aktuell pinned Memories werden überschrieben.';
|
||||
if (!confirm(`Bootstrap importieren? (scope: ${bScope})\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\n${scopeInfo} Cold Memory bleibt unverändert.`)) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -6523,6 +6821,23 @@
|
||||
banner.style.display = 'block';
|
||||
}
|
||||
|
||||
// Fuehrt das Aufraeumen WIRKLICH aus (Server-seitig via Docker-API), statt
|
||||
// nur den Befehl zu kopieren.
|
||||
// WICHTIG: "safe" laeuft OHNE confirm() — Build-Cache + ungenutzte Images
|
||||
// sind ungefaehrlich (keine Volumes/Daten). Frueher blockte ein confirm()
|
||||
// den Klick, wenn der Browser Dialoge unterdrueckt hatte ("Verhindern,
|
||||
// dass diese Seite weitere Dialoge erstellt") → confirm()===false → es ging
|
||||
// NICHTS raus. Nur "aggressive" (Volumes!) fragt noch nach.
|
||||
function runDiskCleanup(variant) {
|
||||
const aggressive = variant === 'aggressive';
|
||||
if (aggressive) {
|
||||
const ok = confirm('AGGRESSIV aufraeumen? Loescht zusaetzlich ungenutzte Volumes — nur wenn ALLE ARIA-Container laufen, sonst Datenverlust!');
|
||||
if (!ok) return;
|
||||
}
|
||||
const btn = document.getElementById('disk-clean-btn');
|
||||
if (btn && !aggressive) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
|
||||
send({ action: 'disk_cleanup', variant });
|
||||
}
|
||||
function copyDiskCmd(variant) {
|
||||
const cmd = variant === 'aggressive'
|
||||
? 'docker system prune -a --volumes -f'
|
||||
|
||||
+129
-3
@@ -475,6 +475,21 @@ function broadcastState() {
|
||||
broadcast({ type: "state", state });
|
||||
}
|
||||
|
||||
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
|
||||
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
|
||||
|
||||
function satelliteList() {
|
||||
const now = Date.now();
|
||||
return Array.from(satellites.values()).map(s => ({
|
||||
id: s.id, location: s.location, caps: s.caps, control: s.control, net: s.net || null,
|
||||
online: (now - (s.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
|
||||
function broadcastSatellites() {
|
||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||
}
|
||||
|
||||
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
||||
|
||||
async function connectGateway() {
|
||||
@@ -903,6 +918,23 @@ function connectRVS(forcePlain) {
|
||||
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
|
||||
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
|
||||
broadcast({ type: "project_changed", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_hello") {
|
||||
// Ein Satellit (Aussenposten in einem fremden Netz) meldet sich.
|
||||
const p = msg.payload || {};
|
||||
if (p.id) {
|
||||
satellites.set(p.id, {
|
||||
id: p.id, location: p.location || p.id,
|
||||
caps: p.caps || [], control: !!p.control, net: p.net || null,
|
||||
last_seen: Date.now(),
|
||||
});
|
||||
broadcastSatellites();
|
||||
}
|
||||
} else if (msg.type === "sat_devices") {
|
||||
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
||||
const p = msg.payload || {};
|
||||
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
||||
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
||||
location: p.location || "", devices: p.devices || [] });
|
||||
} else if (msg.type === "agent_activity") {
|
||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||
@@ -1527,6 +1559,70 @@ function dockerExec(containerName, cmd) {
|
||||
});
|
||||
}
|
||||
|
||||
// POST gegen die Docker-Daemon-API (via gemountetem Socket). Fuer prune-
|
||||
// Endpoints — die geben SpaceReclaimed (Bytes) zurueck.
|
||||
function dockerApiPost(apiPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
socketPath: "/var/run/docker.sock",
|
||||
path: apiPath,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Content-Length": 0 },
|
||||
}, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => data += c);
|
||||
res.on("end", () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try { resolve(JSON.parse(data || "{}")); } catch { resolve({}); }
|
||||
} else {
|
||||
reject(new Error(`Docker API ${apiPath}: HTTP ${res.statusCode} — ${String(data).slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// "Sicher aufraeumen": Build-Cache + ungenutzte Images prunen — OHNE Volumes
|
||||
// (keine Daten weg). "aggressive": zusaetzlich gestoppte Container + ungenutzte
|
||||
// Volumes (kann Daten kosten → nur auf ausdrueckliche Wahl). Fuehrt es WIRKLICH
|
||||
// aus (frueher kopierte der Button nur den Befehl in die Zwischenablage).
|
||||
async function handleDiskCleanup(clientWs, variant) {
|
||||
const aggressive = variant === "aggressive";
|
||||
const send = (o) => { try { clientWs.send(JSON.stringify(o)); } catch (_) {} };
|
||||
send({ type: "disk_cleanup", status: "running", variant });
|
||||
log("warn", "server", `Disk-Cleanup gestartet (${aggressive ? "aggressive" : "safe"})`);
|
||||
try {
|
||||
let reclaimed = 0;
|
||||
const steps = [];
|
||||
const bp = await dockerApiPost("/build/prune?all=true");
|
||||
reclaimed += (bp.SpaceReclaimed || 0);
|
||||
steps.push("Build-Cache");
|
||||
// dangling=false → ALLE ungenutzten Images (nicht nur dangling).
|
||||
// Docker-API-Filterformat: map[string][]string.
|
||||
const imgFilter = encodeURIComponent(JSON.stringify({ dangling: ["false"] }));
|
||||
const ip = await dockerApiPost("/images/prune?filters=" + imgFilter);
|
||||
reclaimed += (ip.SpaceReclaimed || 0);
|
||||
steps.push("ungenutzte Images");
|
||||
if (aggressive) {
|
||||
const cp = await dockerApiPost("/containers/prune");
|
||||
reclaimed += (cp.SpaceReclaimed || 0);
|
||||
steps.push("gestoppte Container");
|
||||
const vp = await dockerApiPost("/volumes/prune");
|
||||
reclaimed += (vp.SpaceReclaimed || 0);
|
||||
steps.push("ungenutzte Volumes");
|
||||
}
|
||||
const mb = (reclaimed / (1024 * 1024));
|
||||
const freed = mb >= 1024 ? (mb / 1024).toFixed(2) + " GB" : mb.toFixed(0) + " MB";
|
||||
log("info", "server", `Disk-Cleanup fertig: ${freed} frei (${steps.join(", ")})`);
|
||||
send({ type: "disk_cleanup", status: "done", variant, reclaimedBytes: reclaimed, freed, steps });
|
||||
} catch (err) {
|
||||
log("error", "server", `Disk-Cleanup fehlgeschlagen: ${err.message}`);
|
||||
send({ type: "disk_cleanup", status: "error", variant, error: String(err && err.message || err) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ─────────────────────────────────────
|
||||
|
||||
function waitForMessage(ws, timeoutMs) {
|
||||
@@ -2407,6 +2503,8 @@ wss.on("connection", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
|
||||
// Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
|
||||
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
||||
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
|
||||
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
@@ -2421,10 +2519,28 @@ wss.on("connection", (ws) => {
|
||||
} else if (msg.action === "test_rvs") {
|
||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
|
||||
} else if (msg.action === "interject") {
|
||||
// Zwischenruf: in den laufenden Turn schieben (kein Abbruch, keine
|
||||
// Queue) → RVS interject → Bridge → Proxy /interject.
|
||||
const t = String(msg.text || "");
|
||||
if (t.trim()) {
|
||||
sendToRVS_raw({ type: "interject", payload: { projectId: msg.projectId || "", text: t }, timestamp: Date.now() });
|
||||
log("info", "server", "Zwischenruf an RVS (project=" + (msg.projectId || "(main)") + "): " + t.slice(0, 60));
|
||||
}
|
||||
} else if (msg.action === "disk_cleanup") {
|
||||
handleDiskCleanup(ws, msg.variant === "aggressive" ? "aggressive" : "safe");
|
||||
} else if (msg.action === "reconnect_gateway") {
|
||||
connectGateway();
|
||||
} else if (msg.action === "reconnect_rvs") {
|
||||
connectRVS();
|
||||
} else if (msg.action === "sat_list") {
|
||||
// Browser will die aktuelle Satelliten-Liste.
|
||||
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||
} else if (msg.action === "sat_discover") {
|
||||
// Browser triggert einen Geraete-Scan auf einem Satelliten.
|
||||
sendToRVS_raw({ type: "sat_discover",
|
||||
payload: { satellite: msg.satellite || "", force: true },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "test_proxy") {
|
||||
testProxy(msg.text);
|
||||
} else if (msg.action === "check_proxy_auth") {
|
||||
@@ -2457,14 +2573,18 @@ wss.on("connection", (ws) => {
|
||||
});
|
||||
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||
} else if (msg.action === "cancel_request") {
|
||||
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
|
||||
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
|
||||
// Laufende Anfrage abbrechen — ECHTER Cancel: RVS cancel_request (hard)
|
||||
// an die Bridge, die den Proxy-/cancel-all Side-Channel anruft und den
|
||||
// laufenden claude-Subprozess killt. Das alte `openclaw doctor --fix`
|
||||
// zielte auf den Container aria-core, den es nicht mehr gibt — es
|
||||
// beendete den Run nie (ARIA lief munter weiter).
|
||||
log("warn", "server", "Anfrage abgebrochen — cancel_request (hard) an Bridge/Proxy");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-cancel" }, timestamp: Date.now() });
|
||||
} else if (msg.action === "aria_panic_stop") {
|
||||
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
||||
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
||||
@@ -2561,6 +2681,12 @@ wss.on("connection", (ws) => {
|
||||
const t = parseFloat(msg.voiceIdThreshold);
|
||||
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
||||
}
|
||||
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
|
||||
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
|
||||
// config-Broadcast; aus = gar keine Pruefung.
|
||||
if (msg.voiceIdEnabled !== undefined) {
|
||||
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||
|
||||
+3
-5
@@ -11,11 +11,7 @@ services:
|
||||
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
|
||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
|
||||
cp /proxy-patches/manager.js $$DIST/subprocess/manager.js &&
|
||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||
@@ -120,6 +116,8 @@ services:
|
||||
- brain
|
||||
networks:
|
||||
- aria-net
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # fuer den VNC-Tunnel zum Host (QEMU)
|
||||
ports:
|
||||
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
||||
volumes:
|
||||
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# aria-vm — ARIAs QEMU-VM-Verwaltung fuer alle Architekturen (laeuft auf dem
|
||||
# Host). ARIA ruft das per SSH (aria-wohnung) ueber den qemu-vm-Skill auf.
|
||||
#
|
||||
# Unterkommandos:
|
||||
# aria-vm create <name> <arch> [size] Disk anlegen (qcow2)
|
||||
# aria-vm boot <name> [optionen] VM starten (VNC 127.0.0.1:<display>)
|
||||
# aria-vm screenshot <name> PNG-Screenshot → Shared-Uploads
|
||||
# aria-vm list laufende/vorhandene VMs
|
||||
# aria-vm stop <name> VM beenden
|
||||
# aria-vm rm <name> VM + Disk loeschen
|
||||
#
|
||||
# boot-Optionen:
|
||||
# --iso <pfad> Boot-ISO (setzt Boot-Reihenfolge auf CD)
|
||||
# --disk-boot von der Festplatte booten (Default nach Installation)
|
||||
# --vnc-display <N> VNC-Display (Port = 5900+N, Default 1)
|
||||
# --mem <MB> RAM (Default 1024)
|
||||
# --machine <typ> QEMU-Maschine ueberschreiben
|
||||
#
|
||||
# VNC bindet immer nur an 127.0.0.1 — von aussen erreichbar ausschliesslich
|
||||
# ueber den RVS-Tunnel der Bridge (host.docker.internal:<port>).
|
||||
set -euo pipefail
|
||||
|
||||
VM_ROOT="${ARIA_VM_ROOT:-/var/lib/aria-vms}"
|
||||
# Wohin Screenshots geschrieben werden — Host-Pfad des /shared-Volumes, damit
|
||||
# Bridge/App sie sehen. Ueberschreibbar via ARIA_VM_SHOT_DIR.
|
||||
SHOT_DIR="${ARIA_VM_SHOT_DIR:-/root/ARIA-AGENT/aria-shared/uploads}"
|
||||
|
||||
die() { echo "aria-vm: $*" >&2; exit 1; }
|
||||
|
||||
qemu_bin_for() {
|
||||
case "$1" in
|
||||
x86_64|amd64) echo qemu-system-x86_64 ;;
|
||||
i386|i686|x86) echo qemu-system-i386 ;;
|
||||
arm|armv7) echo qemu-system-arm ;;
|
||||
aarch64|arm64) echo qemu-system-aarch64 ;;
|
||||
mips) echo qemu-system-mips ;;
|
||||
mipsel) echo qemu-system-mipsel ;;
|
||||
mips64) echo qemu-system-mips64 ;;
|
||||
ppc) echo qemu-system-ppc ;;
|
||||
ppc64) echo qemu-system-ppc64 ;;
|
||||
riscv64) echo qemu-system-riscv64 ;;
|
||||
sparc) echo qemu-system-sparc ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
vm_dir() { echo "${VM_ROOT}/$1"; }
|
||||
vm_pid() { local d; d="$(vm_dir "$1")"; [[ -f "${d}/pid" ]] && cat "${d}/pid" || echo ""; }
|
||||
vm_running() {
|
||||
local p; p="$(vm_pid "$1")"
|
||||
[[ -n "${p}" ]] && kill -0 "${p}" 2>/dev/null
|
||||
}
|
||||
|
||||
cmd_create() {
|
||||
local name="${1:?name}" arch="${2:?arch}" size="${3:-10G}"
|
||||
local bin; bin="$(qemu_bin_for "${arch}")"
|
||||
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
|
||||
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
[[ -f "${d}/arch" ]] && die "VM '${name}' existiert schon"
|
||||
mkdir -p "${d}"
|
||||
echo "${arch}" > "${d}/arch"
|
||||
# size='none' oder '0' → keine Festplatte (VM bootet von --iso/--floppy,
|
||||
# z.B. OS-Entwicklung von Diskette). Sonst eine qcow2-Disk anlegen.
|
||||
if [[ "${size}" == "none" || "${size}" == "0" ]]; then
|
||||
echo "VM '${name}' angelegt (${arch}, ohne Disk — bootet von ISO/Diskette)."
|
||||
else
|
||||
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
|
||||
echo "VM '${name}' angelegt (${arch}, ${size})."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_boot() {
|
||||
local name="${1:?name}"; shift || true
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
[[ -f "${d}/arch" || -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
|
||||
vm_running "${name}" && die "VM '${name}' laeuft bereits"
|
||||
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
|
||||
local bin; bin="$(qemu_bin_for "${arch}")"
|
||||
|
||||
local iso="" floppy="" disk="" bootdev="" display=1 mem=1024 machine=""
|
||||
# VNC bindet an 127.0.0.1 (Loopback) — von aussen nur ueber den RVS-Tunnel der
|
||||
# Bridge erreichbar. Die Bridge (Container) kann Loopback aber NICHT erreichen;
|
||||
# der Brain gibt deshalb per --vnc-bind die Docker-Gateway-IP mit (container-
|
||||
# intern, NICHT im LAN/Internet). Default bleibt Loopback.
|
||||
local vncbind="${ARIA_VM_VNC_BIND:-127.0.0.1}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--iso) iso="${2:?}"; shift 2 ;;
|
||||
--floppy) floppy="${2:?}"; shift 2 ;; # -fda (Disketten-Boot, OS-Dev)
|
||||
--disk) disk="${2:?}"; shift 2 ;; # explizite qcow2 statt Auto
|
||||
--boot) bootdev="${2:?}"; shift 2 ;; # Boot-Reihenfolge (a/c/d)
|
||||
--disk-boot) bootdev="c"; shift ;;
|
||||
--vnc-display) display="${2:?}"; shift 2 ;;
|
||||
--vnc-bind) vncbind="${2:?}"; shift 2 ;; # Bind-Adresse fuer -vnc
|
||||
--mem) mem="${2:?}"; shift 2 ;;
|
||||
--machine) machine="${2:?}"; shift 2 ;;
|
||||
*) die "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-Erkennung der Medien im VM-Ordner, falls nicht explizit angegeben.
|
||||
[[ -z "${disk}" && -f "${d}/disk.qcow2" ]] && disk="${d}/disk.qcow2"
|
||||
[[ -z "${floppy}" && -f "${d}/floppy.img" ]] && floppy="${d}/floppy.img"
|
||||
[[ -z "${iso}" && -f "${d}/cdrom.iso" ]] && iso="${d}/cdrom.iso"
|
||||
[[ -n "${disk}${floppy}${iso}" ]] || \
|
||||
die "Keine Boot-Medien fuer '${name}' (disk.qcow2 / --iso / --floppy). Erst 'create <name> <arch> <groesse>' oder ein Medium angeben."
|
||||
|
||||
local args=(-name "${name}" -m "${mem}"
|
||||
-vnc "${vncbind}:${display}"
|
||||
-monitor "unix:${d}/monitor.sock,server,nowait"
|
||||
-pidfile "${d}/pid" -daemonize)
|
||||
[[ -n "${disk}" ]] && args+=(-drive "file=${disk},format=qcow2")
|
||||
[[ -n "${floppy}" ]] && args+=(-fda "${floppy}")
|
||||
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
|
||||
|
||||
# KVM nur fuer x86 auf x86-Host.
|
||||
case "${arch}" in
|
||||
x86_64|amd64|i386|i686|x86)
|
||||
[[ -e /dev/kvm ]] && args+=(-enable-kvm) ;;
|
||||
esac
|
||||
# ARM/AArch64 brauchen eine Maschine (kein Default).
|
||||
if [[ -z "${machine}" ]]; then
|
||||
case "${arch}" in
|
||||
arm|armv7|aarch64|arm64) machine="virt" ;;
|
||||
esac
|
||||
fi
|
||||
[[ -n "${machine}" ]] && args+=(-M "${machine}")
|
||||
|
||||
# Boot-Reihenfolge: explizit, sonst automatisch (ISO→d, nur Diskette→a, sonst c).
|
||||
if [[ -z "${bootdev}" ]]; then
|
||||
if [[ -n "${iso}" ]]; then bootdev="d"
|
||||
elif [[ -n "${floppy}" && -z "${disk}" ]]; then bootdev="a"
|
||||
else bootdev="c"; fi
|
||||
fi
|
||||
args+=(-boot "${bootdev}")
|
||||
|
||||
"${bin}" "${args[@]}"
|
||||
echo "VM '${name}' gestartet (${arch}) — VNC ${vncbind}:${display} (Port $((5900+display)))."
|
||||
echo "vnc_display=${display} vnc_port=$((5900+display))"
|
||||
}
|
||||
|
||||
cmd_screenshot() {
|
||||
local name="${1:?name}"
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
||||
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
||||
# Standard: ins VM-Verzeichnis schreiben (dem aria-User gehoerend) — NICHT
|
||||
# nach /root/... (da kommt der aria-User nicht hin). Der Brain holt das PNG
|
||||
# danach per SSH (base64). Ueberschreibbar via ARIA_VM_SHOT_DIR.
|
||||
local out_dir="${ARIA_VM_SHOT_DIR:-${d}}"
|
||||
mkdir -p "${out_dir}"
|
||||
local ts; ts="$(date +%s)"
|
||||
local ppm="${d}/shot-${ts}.ppm"
|
||||
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
||||
sleep 0.3
|
||||
local out="${out_dir}/${name}-${ts}.png"
|
||||
if command -v convert >/dev/null; then
|
||||
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
||||
else
|
||||
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
||||
fi
|
||||
echo "screenshot=${out}"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
[[ -d "${VM_ROOT}" ]] || { echo "(keine VMs)"; return; }
|
||||
local any=0
|
||||
for d in "${VM_ROOT}"/*/; do
|
||||
[[ -d "${d}" ]] || continue
|
||||
any=1
|
||||
local name arch state
|
||||
name="$(basename "${d}")"
|
||||
arch="$(cat "${d}/arch" 2>/dev/null || echo '?')"
|
||||
if vm_running "${name}"; then state="laeuft (pid $(vm_pid "${name}"))"; else state="gestoppt"; fi
|
||||
echo "${name} [${arch}] ${state}"
|
||||
done
|
||||
[[ "${any}" -eq 1 ]] || echo "(keine VMs)"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local name="${1:?name}"
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
if vm_running "${name}"; then
|
||||
printf 'quit\n' | socat - "unix-connect:${d}/monitor.sock" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
vm_running "${name}" && kill "$(vm_pid "${name}")" 2>/dev/null || true
|
||||
echo "VM '${name}' gestoppt."
|
||||
else
|
||||
echo "VM '${name}' lief nicht."
|
||||
fi
|
||||
rm -f "${d}/pid" "${d}/monitor.sock"
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
local name="${1:?name}"
|
||||
vm_running "${name}" && cmd_stop "${name}"
|
||||
rm -rf "$(vm_dir "${name}")"
|
||||
echo "VM '${name}' geloescht."
|
||||
}
|
||||
|
||||
main() {
|
||||
local sub="${1:-}"; shift || true
|
||||
case "${sub}" in
|
||||
create) cmd_create "$@" ;;
|
||||
boot) cmd_boot "$@" ;;
|
||||
screenshot) cmd_screenshot "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
stop) cmd_stop "$@" ;;
|
||||
rm) cmd_rm "$@" ;;
|
||||
""|-h|--help)
|
||||
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "unbekanntes Kommando: ${sub} (siehe --help)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# qemu-setup.sh — installiert QEMU fuer ALLE Architekturen auf dem ARIA-Host
|
||||
# (172.0.2.33) plus den aria-vm-Helper. Einmalig als root ausfuehren.
|
||||
#
|
||||
# sudo bash host-provisioning/qemu-setup.sh
|
||||
#
|
||||
# KVM-Beschleunigung gibt es nur fuer x86-Gaeste auf einem x86-Host; ARM/MIPS/
|
||||
# PPC/RISC-V laufen unter TCG (voll emuliert, langsamer, aber alle Architekturen
|
||||
# baubar). websockify/noVNC werden NICHT installiert — der VNC-Stream wird als
|
||||
# RFB-Bytes durch die Bridge/RVS getunnelt (siehe aria_bridge.py VNC-Bruecke).
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Bitte als root ausfuehren (sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[qemu-setup] apt update ..."
|
||||
apt-get update -qq
|
||||
|
||||
echo "[qemu-setup] Installiere QEMU (alle Architekturen) + Werkzeuge ..."
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
qemu-system \
|
||||
qemu-system-x86 \
|
||||
qemu-system-arm \
|
||||
qemu-system-mips \
|
||||
qemu-system-ppc \
|
||||
qemu-system-sparc \
|
||||
qemu-system-misc \
|
||||
qemu-utils \
|
||||
seabios \
|
||||
ovmf \
|
||||
ipxe-qemu \
|
||||
socat \
|
||||
imagemagick
|
||||
|
||||
echo "[qemu-setup] KVM-Status:"
|
||||
if [[ -e /dev/kvm ]]; then
|
||||
echo " /dev/kvm vorhanden → x86-Gaeste mit KVM-Beschleunigung."
|
||||
else
|
||||
echo " /dev/kvm FEHLT → alle Gaeste laufen unter TCG (emuliert, langsamer)."
|
||||
fi
|
||||
|
||||
# aria-vm-Helper installieren (liegt neben diesem Skript).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
install -m 0755 "${SCRIPT_DIR}/aria-vm" /usr/local/bin/aria-vm
|
||||
echo "[qemu-setup] /usr/local/bin/aria-vm installiert."
|
||||
|
||||
mkdir -p /var/lib/aria-vms
|
||||
echo "[qemu-setup] VM-Verzeichnis: /var/lib/aria-vms"
|
||||
|
||||
echo "[qemu-setup] Fertig. Test: aria-vm list"
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Claude Code CLI Subprocess Manager — ARIA-Patch
|
||||
*
|
||||
* Basis: claude-max-api-proxy dist/subprocess/manager.js, plus die bisher per
|
||||
* sed in docker-compose.yml eingespielten Anpassungen (dangerously-skip-
|
||||
* permissions, system-prompt, 24h-Timeout, Prompt via stdin) — hier fest im
|
||||
* File, damit der groessere Zwischenruf-Umbau nicht per sed gefrickelt werden
|
||||
* muss. Wird per `cp` ueber die npm-Version gelegt (siehe docker-compose.yml).
|
||||
*
|
||||
* ZWISCHENRUF (interject): Statt den Prompt als Text zu schreiben und stdin
|
||||
* sofort zu schliessen (--print/text), laeuft claude jetzt im
|
||||
* `--input-format stream-json`-Modus. Der initiale Prompt geht als
|
||||
* stream-json User-Message rein, stdin bleibt OFFEN — so kann waehrend des
|
||||
* laufenden Turns per sendMessage() eine weitere User-Message reingeschoben
|
||||
* werden, die claude an der naechsten Tool-Grenze aufgreift (kein Abbruch).
|
||||
* Bei 'result' (Turn fertig) wird stdin geschlossen, damit claude sauber
|
||||
* beendet und die HTTP-Response (in routes.js an 'close' gebunden) rausgeht.
|
||||
*/
|
||||
import { spawn } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||
export class ClaudeSubprocess extends EventEmitter {
|
||||
process = null;
|
||||
buffer = "";
|
||||
timeoutId = null;
|
||||
isKilled = false;
|
||||
_stdinClosed = false;
|
||||
/**
|
||||
* Start the Claude CLI subprocess with the given prompt
|
||||
*/
|
||||
async start(prompt, options) {
|
||||
const args = this.buildArgs(prompt, options);
|
||||
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Use spawn() for security - no shell interpretation
|
||||
this.process = spawn("claude", args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
// Set timeout
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (!this.isKilled) {
|
||||
this.isKilled = true;
|
||||
this.process?.kill("SIGTERM");
|
||||
this.emit("error", new Error(`Request timed out after ${timeout}ms`));
|
||||
}
|
||||
}, timeout);
|
||||
// Handle spawn errors (e.g., claude not found)
|
||||
this.process.on("error", (err) => {
|
||||
this.clearTimeout();
|
||||
if (err.message.includes("ENOENT")) {
|
||||
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
||||
}
|
||||
else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
// stdin BLEIBT OFFEN: initialen Prompt als stream-json User-
|
||||
// Message schreiben; spaetere Zwischenrufe kommen via
|
||||
// sendMessage(). Geschlossen wird bei 'result' (s. processBuffer).
|
||||
this._writeUserMessage(prompt);
|
||||
// Falls stdin (z.B. EPIPE) frueh stirbt: nicht crashen.
|
||||
this.process.stdin?.on("error", () => {});
|
||||
console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`);
|
||||
// Parse JSON stream from stdout
|
||||
this.process.stdout?.on("data", (chunk) => {
|
||||
const data = chunk.toString();
|
||||
console.error(`[Subprocess] Received ${data.length} bytes of stdout`);
|
||||
this.buffer += data;
|
||||
this.processBuffer();
|
||||
});
|
||||
// Capture stderr for debugging
|
||||
this.process.stderr?.on("data", (chunk) => {
|
||||
const errorText = chunk.toString().trim();
|
||||
if (errorText) {
|
||||
// Don't emit as error unless it's actually an error
|
||||
// Claude CLI may write debug info to stderr
|
||||
console.error("[Subprocess stderr]:", errorText.slice(0, 200));
|
||||
}
|
||||
});
|
||||
// Handle process close
|
||||
this.process.on("close", (code) => {
|
||||
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||
this.clearTimeout();
|
||||
// Process any remaining buffer
|
||||
if (this.buffer.trim()) {
|
||||
this.processBuffer();
|
||||
}
|
||||
this.emit("close", code);
|
||||
});
|
||||
// Resolve immediately since we're streaming
|
||||
resolve();
|
||||
}
|
||||
catch (err) {
|
||||
this.clearTimeout();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Build CLI arguments array
|
||||
*/
|
||||
buildArgs(prompt, options) {
|
||||
const args = [
|
||||
"--print", // Non-interactive mode
|
||||
"--output-format",
|
||||
"stream-json", // JSON streaming output
|
||||
"--verbose", // Required for stream-json
|
||||
"--include-partial-messages", // Enable streaming chunks
|
||||
"--input-format",
|
||||
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||
"--model",
|
||||
options.model, // Model alias (opus/sonnet/haiku)
|
||||
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
|
||||
];
|
||||
if (options.sessionId) {
|
||||
args.push("--session-id", options.sessionId);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
/**
|
||||
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||
*/
|
||||
_writeUserMessage(text) {
|
||||
const p = this.process;
|
||||
if (!p || !p.stdin || p.stdin.destroyed || this._stdinClosed)
|
||||
return false;
|
||||
try {
|
||||
p.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: String(text) } }) + "\n");
|
||||
return true;
|
||||
}
|
||||
catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Zwischenruf: waehrend eines laufenden Turns eine weitere User-Message
|
||||
* reinschieben. claude greift sie an der naechsten Tool-Grenze auf, ohne
|
||||
* den Turn abzubrechen. Kein Effekt, wenn stdin schon geschlossen ist
|
||||
* (Turn praktisch fertig) — dann ist der Zwischenruf schlicht zu spaet.
|
||||
*/
|
||||
sendMessage(text) {
|
||||
return this._writeUserMessage(text);
|
||||
}
|
||||
/**
|
||||
* stdin schliessen → claude beendet den stream-json-Input und exit't.
|
||||
*/
|
||||
_closeStdin() {
|
||||
if (this._stdinClosed)
|
||||
return;
|
||||
this._stdinClosed = true;
|
||||
try {
|
||||
this.process?.stdin?.end();
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
/**
|
||||
* Process the buffer and emit parsed messages
|
||||
*/
|
||||
processBuffer() {
|
||||
const lines = this.buffer.split("\n");
|
||||
this.buffer = lines.pop() || ""; // Keep incomplete line
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed)
|
||||
continue;
|
||||
try {
|
||||
const message = JSON.parse(trimmed);
|
||||
this.emit("message", message);
|
||||
if (isContentDelta(message)) {
|
||||
// Emit content delta for streaming
|
||||
this.emit("content_delta", message);
|
||||
}
|
||||
else if (isAssistantMessage(message)) {
|
||||
this.emit("assistant", message);
|
||||
}
|
||||
else if (isResultMessage(message)) {
|
||||
this.emit("result", message);
|
||||
// Turn fertig → stdin schliessen, sonst wartet claude im
|
||||
// stream-json-Input auf weitere Messages und der Prozess
|
||||
// (und damit die HTTP-Response) haengt fuer immer.
|
||||
this._closeStdin();
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Non-JSON output, emit as raw
|
||||
this.emit("raw", trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear the timeout timer
|
||||
*/
|
||||
clearTimeout() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Kill the subprocess
|
||||
*/
|
||||
kill(signal = "SIGTERM") {
|
||||
if (!this.isKilled && this.process) {
|
||||
this.isKilled = true;
|
||||
this.clearTimeout();
|
||||
this.process.kill(signal);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Check if the process is still running
|
||||
*/
|
||||
isRunning() {
|
||||
return this.process !== null && !this.isKilled && this.process.exitCode === null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Verify that Claude CLI is installed and accessible
|
||||
*/
|
||||
export async function verifyClaude() {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("claude", ["--version"], { stdio: "pipe" });
|
||||
let output = "";
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
proc.on("error", () => {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code",
|
||||
});
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, version: output.trim() });
|
||||
}
|
||||
else {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: "Claude CLI returned non-zero exit code",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Check if Claude CLI is authenticated
|
||||
*/
|
||||
export async function verifyAuth() {
|
||||
return { ok: true };
|
||||
}
|
||||
//# sourceMappingURL=manager.js.map
|
||||
@@ -25,6 +25,13 @@ const MODEL_MAP = {
|
||||
"opus": "opus",
|
||||
"sonnet": "sonnet",
|
||||
"haiku": "haiku",
|
||||
"fable": "fable",
|
||||
"claude-fable-5": "fable",
|
||||
"claude-code-cli/fable": "fable",
|
||||
// Volle aktuelle IDs (falls die App/Diagnostic sie mal direkt setzt)
|
||||
"claude-opus-5": "opus",
|
||||
"claude-sonnet-5": "sonnet",
|
||||
"claude-haiku-4-5": "haiku",
|
||||
};
|
||||
|
||||
export function extractModel(model) {
|
||||
|
||||
+95
-4
@@ -28,6 +28,43 @@ const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/agent-activity";
|
||||
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/agent-stream";
|
||||
const CODE_FILE_HOOK_URL = process.env.ARIA_CODE_FILE_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/code-file";
|
||||
|
||||
// Code-Projekte leben unter /shared/projects/<projectId>/ (Volume in proxy +
|
||||
// bridge + brain gemountet). Schreibt/aendert ARIA hier eine Datei, spiegeln
|
||||
// wir den Volltext live in den Code-Editor der App. Nur Dateien unter diesem
|
||||
// Praefix — ARIAs sonstige Datei-Ops (Skills, Configs) bleiben unberuehrt.
|
||||
const PROJECTS_ROOT = "/shared/projects/";
|
||||
const CODE_FILE_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/** Zerlegt einen absoluten Pfad unter /shared/projects/<pid>/<rel> → {pid, rel}
|
||||
* oder null wenn er nicht darunter liegt. */
|
||||
function _parseProjectPath(filePath) {
|
||||
if (typeof filePath !== "string" || !filePath.startsWith(PROJECTS_ROOT)) return null;
|
||||
const rest = filePath.slice(PROJECTS_ROOT.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return { pid: rest.slice(0, slash), rel: rest.slice(slash + 1) };
|
||||
}
|
||||
|
||||
/** Liest die (frisch geschriebene) Datei und pusht sie als code_file an die
|
||||
* Bridge. Fire-and-forget, fail-open. */
|
||||
function _emitCodeFile(filePath) {
|
||||
try {
|
||||
const parsed = _parseProjectPath(filePath);
|
||||
if (!parsed || !parsed.rel) return;
|
||||
const st = fs.statSync(filePath);
|
||||
if (!st.isFile() || st.size > CODE_FILE_MAX_BYTES) return;
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
_postJson(CODE_FILE_HOOK_URL, {
|
||||
projectId: parsed.pid,
|
||||
path: parsed.rel,
|
||||
content,
|
||||
version: Date.now(),
|
||||
});
|
||||
} catch (_) { /* fail-open */ }
|
||||
}
|
||||
|
||||
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
|
||||
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
|
||||
@@ -153,6 +190,9 @@ function _attachIdleWatchdog(subprocess, requestId) {
|
||||
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
|
||||
*/
|
||||
function _attachToolHook(subprocess, requestId, projectId) {
|
||||
// tool_use_id → file_path fuer Write/Edit, damit wir beim (erfolgreichen)
|
||||
// tool_result die frisch geschriebene Datei aus /shared lesen koennen.
|
||||
const _pendingFileWrites = new Map();
|
||||
subprocess.on("assistant", (message) => {
|
||||
try {
|
||||
const blocks = message?.message?.content || [];
|
||||
@@ -160,6 +200,10 @@ function _attachToolHook(subprocess, requestId, projectId) {
|
||||
if (!b) continue;
|
||||
if (b.type === "tool_use") {
|
||||
if (b.name) _emitToolEvent(b.name, projectId);
|
||||
if ((b.name === "Write" || b.name === "Edit" || b.name === "MultiEdit")
|
||||
&& b.id && b.input && typeof b.input.file_path === "string") {
|
||||
_pendingFileWrites.set(b.id, b.input.file_path);
|
||||
}
|
||||
const inputStr = b.input ? JSON.stringify(b.input) : "";
|
||||
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
|
||||
_emitStreamEvent(requestId, "tool_use", {
|
||||
@@ -203,6 +247,11 @@ function _attachToolHook(subprocess, requestId, projectId) {
|
||||
truncatedBytes: out.truncatedBytes,
|
||||
isError: b.is_error === true,
|
||||
});
|
||||
// Write/Edit erfolgreich → Datei live in den Code-Editor spiegeln.
|
||||
if (b.tool_use_id && b.is_error !== true && _pendingFileWrites.has(b.tool_use_id)) {
|
||||
_emitCodeFile(_pendingFileWrites.get(b.tool_use_id));
|
||||
_pendingFileWrites.delete(b.tool_use_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fail-open */ }
|
||||
@@ -465,12 +514,18 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
|
||||
// Datei, greifen die eingebauten Defaults; die Datei wird dann einmalig mit
|
||||
// diesen Defaults angelegt, damit es was zu editieren gibt.
|
||||
const MODELS_FILE = process.env.ARIA_MODELS_FILE || "/shared/config/models.json";
|
||||
// Tier-Aliase als id (opus/sonnet/haiku/fable) — die CLI loest sie automatisch
|
||||
// auf die AKTUELLE Version des Tiers auf (Stand 2026-07: fable→Fable 5,
|
||||
// opus→Opus 5, sonnet→Sonnet 5, haiku→Haiku 4.5). So bleibt die Liste
|
||||
// versions-robust; die display_name-Texte nur bei Tier-Wechsel anpassen.
|
||||
const DEFAULT_MODELS = [
|
||||
{ id: "claude-sonnet-4", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
|
||||
{ id: "fable", tier: "fable", display_name: "Fable (aktuell: Fable 5)",
|
||||
description: "Staerkstes Modell — fuer die haertesten Aufgaben (Software-Entwicklung, lange Agent-Laeufe)." },
|
||||
{ id: "opus", tier: "opus", display_name: "Opus (aktuell: Opus 5)",
|
||||
description: "Sehr schlau, schneller als Fable — fuer schwere/lange Aufgaben." },
|
||||
{ id: "sonnet", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
|
||||
description: "Schnell & gut — Standard fuer den Alltag." },
|
||||
{ id: "claude-opus-4", tier: "opus", display_name: "Opus (aktuell: Opus 4.8)",
|
||||
description: "Langsamer, aber am schlausten — fuer schwere/lange Aufgaben." },
|
||||
{ id: "claude-haiku-4", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
|
||||
{ id: "haiku", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
|
||||
description: "Sehr schnell & guenstig, kleinerer Kontext — fuer einfache Tasks." },
|
||||
];
|
||||
|
||||
@@ -572,6 +627,28 @@ function _cancelByProject(projectId) {
|
||||
return { killed, requestIds: ids, projectId: pid };
|
||||
}
|
||||
|
||||
// Zwischenruf: schiebt eine User-Message in den/die laufenden Subprozess(e)
|
||||
// eines Kontexts, OHNE sie zu killen. claude greift sie an der naechsten Tool-
|
||||
// Grenze auf (stream-json-Input, s. manager.js). Kein Treffer / stdin schon
|
||||
// zu (Turn quasi fertig) → delivered=0.
|
||||
function _interjectByProject(projectId, text) {
|
||||
const pid = String(projectId || "");
|
||||
const ids = [];
|
||||
let delivered = 0;
|
||||
for (const [id, entry] of Array.from(_activeSubprocesses)) {
|
||||
if (entry.projectId !== pid) continue;
|
||||
try {
|
||||
if (typeof entry.subprocess.sendMessage === "function" && entry.subprocess.sendMessage(text)) {
|
||||
delivered++;
|
||||
ids.push(id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[aria-interject] sendMessage failed for", id, e?.message);
|
||||
}
|
||||
}
|
||||
return { delivered, requestIds: ids, projectId: pid };
|
||||
}
|
||||
|
||||
try {
|
||||
const internalServer = http.createServer((req, res) => {
|
||||
if (req.method === "POST" && req.url === "/cancel-all") {
|
||||
@@ -596,6 +673,20 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/interject") {
|
||||
// Body: {projectId, text}. Zwischenruf in den laufenden Turn.
|
||||
let raw = "";
|
||||
req.on("data", (c) => { raw += c; if (raw.length > 65536) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
let projectId = "", text = "";
|
||||
try { const b = JSON.parse(raw || "{}"); projectId = String(b.projectId || ""); text = String(b.text || ""); } catch (_) {}
|
||||
const result = text ? _interjectByProject(projectId, text) : { delivered: 0, requestIds: [], projectId };
|
||||
console.warn("[aria-interject] /interject project=%s — delivered %d", projectId || "(main)", result.delivered);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, ...result }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
||||
|
||||
+15
-1
@@ -17,7 +17,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||
"update_check", "update_available", "update_download", "update_data",
|
||||
"agent_activity", "cancel_request",
|
||||
"agent_activity", "cancel_request", "interject",
|
||||
"audio_pcm",
|
||||
"file_from_aria",
|
||||
"container_restart",
|
||||
@@ -66,6 +66,20 @@ const ALLOWED_TYPES = new Set([
|
||||
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp).
|
||||
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
|
||||
"llm_request", "llm_response", "llm_partial",
|
||||
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
|
||||
// spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS
|
||||
// getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig).
|
||||
"code_file", "code_file_edit",
|
||||
// M1 Generatives Cockpit: ARIA komponiert via present_view eine View-Spec
|
||||
// (Orb + Karten), die App/Web/Diagnostic mit ihrem jeweiligen Renderer
|
||||
// materialisieren. Brain → Bridge → RVS → Clients.
|
||||
"aria_view",
|
||||
"check_desktop", "desktop_status",
|
||||
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
||||
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
|
||||
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
||||
// fuehren Aktionen aus (sat_command → sat_result).
|
||||
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# ─── ARIA Satellit — Konfiguration ──────────────────────────────────
|
||||
# Kopiere diese Datei nach .env und passe sie an.
|
||||
|
||||
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
|
||||
# diesen Satelliten erreicht). Werte aus der Haupt-.env / vom generate-token.sh.
|
||||
RVS_HOST=rvs.example.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TOKEN=
|
||||
|
||||
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
||||
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||
# SATELLITE_LOCATION = menschlicher Name, so spricht ARIA das Netz an ("Buero").
|
||||
# Mehrere Satelliten koennen im selben RVS-Raum haengen — die Location
|
||||
# unterscheidet sie ("Buero", "Zuhause", "Werkstatt").
|
||||
SATELLITE_ID=buero
|
||||
SATELLITE_LOCATION=Büro
|
||||
|
||||
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
|
||||
# CONTROL_ENABLED=false → reiner Info-/Beobachtungs-Satellit (entdeckt & meldet
|
||||
# nur, steuert nichts). Sicherste Basis.
|
||||
# CONTROL_ENABLED=true → darf Geraete steuern (nur Aktionen aus der Allowlist).
|
||||
CONTROL_ENABLED=true
|
||||
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
|
||||
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
|
||||
# wol Wake-on-LAN (Geraet per MAC aufwecken)
|
||||
# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks)
|
||||
# http.post generischer HTTP-POST
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get
|
||||
|
||||
# ─── Discovery-Tuning (optional) ───────────────────────────────────
|
||||
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
|
||||
DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP)
|
||||
DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt
|
||||
@@ -0,0 +1,13 @@
|
||||
# ARIA Satellit — schlanker Aussenposten-Container.
|
||||
# Laeuft mit network_mode: host (siehe docker-compose.yml), damit mDNS/SSDP-
|
||||
# Broadcasts + die Geraete-IPs im lokalen Netz erreichbar sind.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY satellite.py .
|
||||
|
||||
CMD ["python", "-u", "satellite.py"]
|
||||
@@ -0,0 +1,102 @@
|
||||
# ARIA Satellit 🛰️
|
||||
|
||||
Ein eigenständiger **Außenposten-Container** für ein fremdes Netz (Büro, Werkstatt,
|
||||
Ferienwohnung …). Er verbindet sich als RVS-Client in Stefans Raum und gibt ARIA
|
||||
**Augen und Hände in genau diesem Netz** — ohne dass der Haupt-Stack dort stehen muss.
|
||||
|
||||
- **Augen (Info):** entdeckt Geräte via **mDNS/Zeroconf** (Chromecast, AirPlay, Sonos,
|
||||
Drucker, NAS …), **SSDP/UPnP + DIAL** (Smart-TVs, Fire TV) und der **ARP-Tabelle**
|
||||
(rohe Hosts). Meldet ARIA ein Live-Inventar.
|
||||
- **Hände (Steuerung):** **DIAL-App-Launch** (z.B. YouTube-Video auf dem Fire TV),
|
||||
**Wake-on-LAN**, generisches **HTTP**. Nur wenn freigeschaltet (siehe Sicherheit).
|
||||
|
||||
## ⚠️ Wichtig: der Satellit MUSS im echten Ziel-LAN laufen
|
||||
|
||||
Discovery (mDNS/SSDP-Multicast + ARP) funktioniert **nur**, wenn der Prozess
|
||||
tatsächlich im selben LAN wie die Geräte hängt — z.B. `192.168.177.0/24`, wo der
|
||||
Fire TV steht.
|
||||
|
||||
**Docker Desktop (Mac/Windows) geht NICHT.** Dort ist `network_mode: host` das Netz
|
||||
der Docker-Linux-VM (NAT, `192.168.65.x` / `172.x`), **nicht** dein echtes LAN.
|
||||
Der Satellit sieht dann nur Docker-Container statt der echten Geräte. (Der Satellit
|
||||
erkennt das selbst und meldet eine ⚠-Warnung im Diagnostic + Log.)
|
||||
|
||||
Richtig deployen — zwei Wege:
|
||||
|
||||
**A) Linux-Box im Ziel-LAN mit Docker Engine** (empfohlen, z.B. Raspberry Pi / NUC im Büro):
|
||||
```bash
|
||||
cd satellite
|
||||
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION
|
||||
docker compose up -d --build
|
||||
docker compose logs -f # "Netz: primary_ip=192.168.177.x" + "[scan] N Geraete"
|
||||
```
|
||||
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
|
||||
|
||||
**B) Nativ als Python-Prozess** (für Mac/Windows-Test oder ohne Docker) — läuft direkt
|
||||
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
|
||||
`satellite/`-Ordner liegen):
|
||||
```bash
|
||||
cd satellite
|
||||
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||
pip install -r requirements.txt
|
||||
python satellite.py # liest .env automatisch
|
||||
```
|
||||
(Echte Umgebungsvariablen haben Vorrang — `export RVS_TOKEN=...` überschreibt die
|
||||
`.env`, falls du das lieber magst.)
|
||||
|
||||
`RVS_HOST/PORT/TLS/TOKEN` **identisch** zum Haupt-Stack (gleicher Raum, damit ARIA
|
||||
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
||||
anspricht („Büro").
|
||||
|
||||
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
|
||||
(`192.168.177.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
|
||||
falschen (Docker-)Netz.
|
||||
|
||||
## Als Dienst installieren (Autostart, ohne Docker)
|
||||
|
||||
Legt automatisch ein `.venv` an, installiert die Requirements und richtet einen
|
||||
Dienst ein, der bei Boot startet und bei Absturz neu hochkommt. `.env` vorher
|
||||
anlegen.
|
||||
|
||||
**Linux (systemd) / macOS (launchd):**
|
||||
```bash
|
||||
cd satellite
|
||||
bash install.sh # installieren + starten
|
||||
bash install.sh uninstall # entfernen
|
||||
```
|
||||
- Linux-Logs: `journalctl -u aria-satellite -f`
|
||||
- macOS-Logs: `tail -f satellite.log` (evtl. „Lokales Netzwerk"-Zugriff erlauben)
|
||||
|
||||
**Windows (Scheduled Task) — PowerShell als Administrator:**
|
||||
```powershell
|
||||
cd satellite
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 # installieren + starten
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # entfernen
|
||||
```
|
||||
- Log: `Get-Content -Wait satellite.log`
|
||||
|
||||
## So nutzt ARIA es
|
||||
|
||||
ARIA hat drei Brain-Tools:
|
||||
- `satellite_list` — welche Netze/Satelliten sind online + was können sie.
|
||||
- `satellite_devices(satellite)` — Inventar eines Netzes.
|
||||
- `satellite_command(satellite, device, action, params)` — Aktion ausführen.
|
||||
|
||||
Beispiel „YouTube-Video auf dem Büro-Stick":
|
||||
```
|
||||
satellite_command(satellite="Büro", device="Fire TV",
|
||||
action="dial.launch", params={"app":"YouTube","v":"<videoId>"})
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar — deshalb:
|
||||
|
||||
- Reagiert **nur** auf den eigenen RVS-Raum (Token).
|
||||
- **`CONTROL_ENABLED=false`** = reiner Info-Satellit (steuert nichts). Standard-sicher.
|
||||
- Bei `true`: nur Aktionen aus **`CONTROL_ALLOWLIST`**, alles andere wird abgelehnt.
|
||||
- Jede ausgeführte Aktion wird **geloggt**.
|
||||
- Keine offenen Ports — reiner Client.
|
||||
|
||||
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||
sonst `false` und nur beobachten.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ARIA Satellit — eigenstaendiger Stack fuer ein fremdes Netz (Buero, Werkstatt …).
|
||||
#
|
||||
# Deploy:
|
||||
# cd satellite
|
||||
# cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# WICHTIG: network_mode: host — der Satellit MUSS im Host-Netz laufen, sonst
|
||||
# sieht er die mDNS/SSDP-Broadcasts + Geraete-IPs des LAN nicht (Docker-Bridge
|
||||
# wuerde das isolieren). Damit ist er zugleich als RVS-Client raus ins Internet
|
||||
# verbunden. Keine Ports zu veroeffentlichen — er ist reiner Client.
|
||||
#
|
||||
# ⚠ NUR auf LINUX Docker Engine, und der Host muss physisch im Ziel-LAN haengen!
|
||||
# Docker Desktop (Mac/Windows) gibt hier NUR das Docker-VM-Netz (192.168.65.x /
|
||||
# 172.x), NICHT dein echtes LAN → Discovery findet dann nur Docker-Container.
|
||||
# Fuer Mac/Windows: satellite.py nativ starten (siehe README, Weg B).
|
||||
services:
|
||||
satellite:
|
||||
build: .
|
||||
container_name: aria-satellite
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,87 @@
|
||||
<#
|
||||
ARIA Satellit — Dienst-Installer fuer Windows (Scheduled Task, Autostart + Restart).
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 installiert + startet
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall entfernt den Task
|
||||
|
||||
Legt ein venv (.venv) an, installiert requirements.txt und richtet einen
|
||||
geplanten Task ein, der satellite.py aus DIESEM Ordner startet (findet die .env)
|
||||
und bei Absturz/Neustart wieder hochkommt. Laeuft als aktueller Nutzer (S4U),
|
||||
auch ohne Anmeldung. Als Administrator ausfuehren.
|
||||
|
||||
WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
|
||||
Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
|
||||
#>
|
||||
param([switch]$Uninstall)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Dir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Task = "ARIA-Satellite"
|
||||
|
||||
# ─── Uninstall ──────────────────────────────────────────────────────
|
||||
if ($Uninstall) {
|
||||
if (Get-ScheduledTask -TaskName $Task -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $Task -Confirm:$false
|
||||
Write-Host "Task '$Task' entfernt."
|
||||
} else {
|
||||
Write-Host "Kein Task '$Task' gefunden."
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ─── Admin-Check ────────────────────────────────────────────────────
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
Write-Warning "Bitte als Administrator ausfuehren (Rechtsklick PowerShell -> Als Administrator)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Python finden ──────────────────────────────────────────────────
|
||||
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
if (-not $py) { $py = (Get-Command python3 -ErrorAction SilentlyContinue).Source }
|
||||
if (-not $py) { Write-Error "Python 3 nicht gefunden. Bitte von python.org installieren (mit 'Add to PATH')."; exit 1 }
|
||||
|
||||
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
|
||||
if (-not (Test-Path "$Dir\.venv")) {
|
||||
Write-Host "[install] Lege venv an ..."
|
||||
& $py -m venv "$Dir\.venv"
|
||||
}
|
||||
$venvPy = "$Dir\.venv\Scripts\python.exe"
|
||||
Write-Host "[install] Installiere Abhaengigkeiten ..."
|
||||
& $venvPy -m pip install --upgrade pip | Out-Null
|
||||
& $venvPy -m pip install -r "$Dir\requirements.txt"
|
||||
|
||||
if (-not (Test-Path "$Dir\.env")) {
|
||||
Write-Warning "Keine .env gefunden - bitte anlegen: copy .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
|
||||
}
|
||||
|
||||
# ─── Scheduled Task ─────────────────────────────────────────────────
|
||||
Write-Host "[install] Richte geplanten Task '$Task' ein ..."
|
||||
# Ueber cmd starten, damit stdout/stderr in satellite.log landen (der Task laeuft
|
||||
# in Session 0 / S4U = kein sichtbares Fenster, python.exe ist hier ok).
|
||||
$logfile = Join-Path $Dir "satellite.log"
|
||||
$cmdArg = '/c ""{0}" satellite.py >> "{1}" 2>&1"' -f $venvPy, $logfile
|
||||
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument $cmdArg -WorkingDirectory $Dir
|
||||
$trigger = @(
|
||||
New-ScheduledTaskTrigger -AtStartup
|
||||
New-ScheduledTaskTrigger -AtLogOn
|
||||
)
|
||||
# Laeuft als aktueller Nutzer, auch ohne Anmeldung (S4U), mit hoechsten Rechten.
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
|
||||
-LogonType S4U -RunLevel Highest
|
||||
# Bei Absturz neu starten, unbegrenzte Laufzeit, startet nach falls Zeitpunkt verpasst, versteckt.
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -Hidden `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
Register-ScheduledTask -TaskName $Task -Action $action -Trigger $trigger `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $Task
|
||||
Write-Host "OK Dienst laeuft."
|
||||
Write-Host " Status: Get-ScheduledTask -TaskName $Task"
|
||||
Write-Host " Stoppen: Stop-ScheduledTask -TaskName $Task"
|
||||
Write-Host " Log: Get-Content -Wait '$logfile' (pruefen: primary_ip + '[scan] N Geraete')"
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ARIA Satellit — Dienst-Installer fuer Linux (systemd) und macOS (launchd).
|
||||
#
|
||||
# bash install.sh installiert + startet den Dienst
|
||||
# bash install.sh uninstall entfernt den Dienst
|
||||
#
|
||||
# Legt ein venv an (.venv), installiert requirements.txt und richtet einen
|
||||
# Autostart-Dienst ein, der satellite.py aus DIESEM Ordner startet (damit die
|
||||
# .env gefunden wird) und bei Absturz neu startet.
|
||||
#
|
||||
# WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
|
||||
# Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SVC="aria-satellite"
|
||||
PLIST_LABEL="de.aria.satellite"
|
||||
OS="$(uname -s)"
|
||||
ACTION="${1:-install}"
|
||||
|
||||
# ─── Uninstall ──────────────────────────────────────────────────────
|
||||
if [ "$ACTION" = "uninstall" ]; then
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO systemctl disable --now "$SVC" 2>/dev/null || true
|
||||
$SUDO rm -f "/etc/systemd/system/$SVC.service"
|
||||
$SUDO systemctl daemon-reload
|
||||
echo "systemd-Dienst '$SVC' entfernt."
|
||||
elif [ "$OS" = "Darwin" ]; then
|
||||
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
|
||||
launchctl unload "$PLIST" 2>/dev/null || true
|
||||
rm -f "$PLIST"
|
||||
echo "launchd-Agent '$PLIST_LABEL' entfernt."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
|
||||
PY="$(command -v python3 || command -v python || true)"
|
||||
[ -n "$PY" ] || { echo "Python 3 nicht gefunden. Bitte installieren."; exit 1; }
|
||||
|
||||
if [ ! -d "$DIR/.venv" ]; then
|
||||
echo "[install] Lege venv an …"
|
||||
if ! "$PY" -m venv "$DIR/.venv" 2>/dev/null; then
|
||||
echo "venv-Erstellung fehlgeschlagen. Auf Debian/Ubuntu: sudo apt install python3-venv"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
VENV_PY="$DIR/.venv/bin/python"
|
||||
echo "[install] Installiere Abhaengigkeiten …"
|
||||
"$VENV_PY" -m pip install --upgrade pip >/dev/null
|
||||
"$VENV_PY" -m pip install -r "$DIR/requirements.txt"
|
||||
|
||||
if [ ! -f "$DIR/.env" ]; then
|
||||
echo "⚠ Keine .env gefunden — bitte anlegen: cp .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
|
||||
fi
|
||||
|
||||
# ─── Dienst einrichten ──────────────────────────────────────────────
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
RUN_USER="${SUDO_USER:-$(id -un)}"
|
||||
UNIT="/etc/systemd/system/$SVC.service"
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
echo "[install] Schreibe systemd-Unit $UNIT (User=$RUN_USER) …"
|
||||
$SUDO tee "$UNIT" >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=ARIA Satellit (Netz-Aussenposten)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$RUN_USER
|
||||
WorkingDirectory=$DIR
|
||||
ExecStart=$VENV_PY $DIR/satellite.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable --now "$SVC"
|
||||
echo "✅ Dienst laeuft."
|
||||
echo " Status: $SUDO systemctl status $SVC"
|
||||
echo " Logs: $SUDO journalctl -u $SVC -f"
|
||||
|
||||
elif [ "$OS" = "Darwin" ]; then
|
||||
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
echo "[install] Schreibe launchd-Agent $PLIST …"
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>$PLIST_LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$VENV_PY</string>
|
||||
<string>$DIR/satellite.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>$DIR</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key><string>$DIR/satellite.log</string>
|
||||
<key>StandardErrorPath</key><string>$DIR/satellite.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl unload "$PLIST" 2>/dev/null || true
|
||||
launchctl load "$PLIST"
|
||||
echo "✅ Agent geladen."
|
||||
echo " Logs: tail -f $DIR/satellite.log"
|
||||
echo " Hinweis: macOS fragt evtl. nach 'Lokales Netzwerk'-Zugriff — erlauben, sonst findet der Satellit keine Geraete."
|
||||
else
|
||||
echo "Unbekanntes OS: $OS (dieses Skript kann Linux/macOS; fuer Windows: install.ps1)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,3 @@
|
||||
websockets>=12.0
|
||||
zeroconf>=0.131.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
ARIA Satellit — Info-/Gateway-Aussenposten in einem fremden Netz.
|
||||
|
||||
Laeuft eigenstaendig (z.B. im Buero) und verbindet sich als RVS-Client in
|
||||
Stefans Raum (gleicher Token). Gibt ARIA damit Augen + Haende in DIESEM Netz:
|
||||
|
||||
Augen: entdeckt Geraete (mDNS/Zeroconf, SSDP/UPnP + DIAL, ARP-Tabelle) und
|
||||
meldet ein Inventar → sat_devices.
|
||||
Haende: steuert Geraete (DIAL-App-Launch z.B. YouTube auf Fire TV, Wake-on-
|
||||
LAN, generisches HTTP) → sat_command / sat_result. Nur wenn
|
||||
CONTROL_ENABLED=true, Aktion in der Allowlist, alles geloggt.
|
||||
|
||||
Adressierung: mehrere Satelliten haengen im selben RVS-Raum. Jeder hat eine
|
||||
SATELLITE_ID (technisch, eindeutig) + SATELLITE_LOCATION (menschlich, "Buero").
|
||||
ARIA spricht einen Satelliten ueber seine ID/Location an.
|
||||
|
||||
Message-Typen (RVS, Base64/JSON-Relay wie der Rest):
|
||||
raus: sat_hello {id, location, caps, ts}
|
||||
sat_devices {requestId, satellite, devices:[...]}
|
||||
sat_result {requestId, satellite, ok, result|error}
|
||||
rein: sat_discover {satellite?, requestId}
|
||||
sat_command {satellite?, requestId, device, action, params}
|
||||
|
||||
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token). Commands brauchen
|
||||
CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import websockets
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [satellite] %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("satellite")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
"""Laedt eine .env neben dem Script (oder im CWD) in os.environ — fuer den
|
||||
NATIVEN Start (`python satellite.py`). In Docker sind die Variablen via
|
||||
env_file schon gesetzt; bereits gesetzte Werte gewinnen (werden NICHT
|
||||
ueberschrieben). Kein python-dotenv noetig."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
if key.startswith("export "):
|
||||
key = key[len("export "):].strip()
|
||||
val = val.strip()
|
||||
if val[:1] in ("'", '"'):
|
||||
# Gequotet: Inhalt bis zum schliessenden Quote, Rest (Kommentar) egal.
|
||||
q = val[0]
|
||||
end = val.find(q, 1)
|
||||
val = val[1:end] if end != -1 else val[1:]
|
||||
else:
|
||||
# Ungequotet: Inline-Kommentar (Whitespace + #) abschneiden.
|
||||
m = re.search(r"\s+#", val)
|
||||
if m:
|
||||
val = val[:m.start()]
|
||||
val = val.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
except Exception as exc:
|
||||
logging.getLogger("satellite").warning(".env laden fehlgeschlagen (%s): %s", path, exc)
|
||||
break # erste gefundene .env gewinnt
|
||||
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
|
||||
# ─── Konfiguration ──────────────────────────────────────────────────
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
v = os.environ.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||
|
||||
|
||||
def _default_id() -> str:
|
||||
host = socket.gethostname() or "satellite"
|
||||
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
|
||||
return slug or "satellite"
|
||||
|
||||
|
||||
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||
|
||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||
|
||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||
CONTROL_ALLOWLIST = [
|
||||
a.strip() for a in
|
||||
os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",")
|
||||
if a.strip()
|
||||
]
|
||||
|
||||
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
|
||||
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
||||
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||
MDNS_TYPES = [
|
||||
"_googlecast._tcp.local.", # Chromecast / Google TV / Nest
|
||||
"_airplay._tcp.local.", # Apple TV / AirPlay
|
||||
"_raop._tcp.local.", # AirPlay-Audio
|
||||
"_spotify-connect._tcp.local.", # Spotify-Geraete
|
||||
"_sonos._tcp.local.", # Sonos
|
||||
"_hap._tcp.local.", # HomeKit
|
||||
"_printer._tcp.local.", # Drucker
|
||||
"_ipp._tcp.local.", # Drucker (IPP)
|
||||
"_smb._tcp.local.", # NAS / Fileshares
|
||||
"_workstation._tcp.local.", # generische Hosts
|
||||
"_http._tcp.local.", # Web-UIs (Router, NAS, IoT)
|
||||
]
|
||||
|
||||
CAPABILITIES = ["discover"]
|
||||
if CONTROL_ENABLED:
|
||||
CAPABILITIES += CONTROL_ALLOWLIST
|
||||
|
||||
|
||||
# ─── Netz-Kontext / Selbstdiagnose ──────────────────────────────────
|
||||
|
||||
def _in_docker_bridge(ip: str) -> bool:
|
||||
# Docker-Default-Bridge-Range 172.16.0.0/12
|
||||
try:
|
||||
a, b = ip.split(".")[:2]
|
||||
return a == "172" and 16 <= int(b) <= 31
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _net_context() -> dict:
|
||||
"""Ermittelt in welchem Netz der Satellit LAeUFT — und warnt, wenn das ein
|
||||
Docker-/NAT-Netz ist (dann erreicht Discovery das echte LAN nicht)."""
|
||||
ips: list[str] = []
|
||||
primary = ""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(1)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
primary = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip and not ip.startswith("127.") and ip not in ips:
|
||||
ips.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
if primary and primary not in ips:
|
||||
ips.insert(0, primary)
|
||||
p = primary or (ips[0] if ips else "")
|
||||
warning = ""
|
||||
if p.startswith("192.168.65.") or _in_docker_bridge(p):
|
||||
warning = (f"Satellit laeuft in einem Docker-/NAT-Netz ({p}), NICHT im echten LAN. "
|
||||
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
|
||||
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
|
||||
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
|
||||
return {"primary_ip": p, "ips": ips, "warning": warning}
|
||||
|
||||
|
||||
NET = _net_context()
|
||||
|
||||
|
||||
# ─── Discovery ──────────────────────────────────────────────────────
|
||||
|
||||
def _discover_mdns(timeout: float) -> list[dict]:
|
||||
"""Blockierend (im Executor): mDNS/Zeroconf-Sweep ueber MDNS_TYPES."""
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
from zeroconf import Zeroconf, ServiceBrowser
|
||||
except Exception as exc:
|
||||
logger.warning("zeroconf nicht verfuegbar: %s", exc)
|
||||
return []
|
||||
|
||||
class _Listener:
|
||||
def add_service(self, zc, type_, name):
|
||||
try:
|
||||
info = zc.get_service_info(type_, name, timeout=2000)
|
||||
except Exception:
|
||||
info = None
|
||||
if not info:
|
||||
return
|
||||
ips = []
|
||||
try:
|
||||
for addr in info.parsed_addresses():
|
||||
ips.append(addr)
|
||||
except Exception:
|
||||
pass
|
||||
props = {}
|
||||
try:
|
||||
for k, v in (info.properties or {}).items():
|
||||
try:
|
||||
props[k.decode("utf-8", "ignore")] = (
|
||||
v.decode("utf-8", "ignore") if isinstance(v, (bytes, bytearray)) else v)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
friendly = name.split("." + type_.split(".", 1)[0])[0].strip(".")
|
||||
fn = props.get("fn") or props.get("friendlyName") or friendly
|
||||
dev_id = _slug(f"{fn}-{ips[0] if ips else name}")
|
||||
out[dev_id] = {
|
||||
"id": dev_id,
|
||||
"name": fn,
|
||||
"type": _mdns_kind(type_),
|
||||
"ip": ips[0] if ips else "",
|
||||
"port": info.port,
|
||||
"via": "mdns",
|
||||
"service": type_,
|
||||
"model": props.get("md") or props.get("model") or "",
|
||||
}
|
||||
|
||||
def update_service(self, *a):
|
||||
pass
|
||||
|
||||
def remove_service(self, *a):
|
||||
pass
|
||||
|
||||
zc = None
|
||||
try:
|
||||
zc = Zeroconf()
|
||||
listener = _Listener()
|
||||
for t in MDNS_TYPES:
|
||||
try:
|
||||
ServiceBrowser(zc, t, listener)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("mDNS-Sweep-Fehler: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
if zc:
|
||||
zc.close()
|
||||
except Exception:
|
||||
pass
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _mdns_kind(service_type: str) -> str:
|
||||
m = {
|
||||
"_googlecast": "cast", "_airplay": "airplay", "_raop": "airplay-audio",
|
||||
"_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit",
|
||||
"_printer": "printer", "_ipp": "printer", "_smb": "fileshare",
|
||||
"_workstation": "host", "_http": "web",
|
||||
}
|
||||
for k, v in m.items():
|
||||
if service_type.startswith(k):
|
||||
return v
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _discover_ssdp(timeout: float) -> list[dict]:
|
||||
"""Blockierend: SSDP M-SEARCH (UPnP + DIAL). Liefert v.a. Smart-TVs / Fire
|
||||
TV mit ihrer DIAL Application-URL (fuer App-Launch wie YouTube)."""
|
||||
out: dict[str, dict] = {}
|
||||
targets = [
|
||||
"urn:dial-multiscreen-org:service:dial:1",
|
||||
"ssdp:all",
|
||||
]
|
||||
for st in targets:
|
||||
msg = (
|
||||
"M-SEARCH * HTTP/1.1\r\n"
|
||||
"HOST: 239.255.255.250:1900\r\n"
|
||||
'MAN: "ssdp:discover"\r\n'
|
||||
"MX: 2\r\n"
|
||||
f"ST: {st}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendto(msg, ("239.255.255.250", 1900))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, addr = sock.recvfrom(65507)
|
||||
except socket.timeout:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
headers = _parse_http_headers(data.decode("utf-8", "ignore"))
|
||||
location = headers.get("location", "")
|
||||
dial_app = headers.get("application-url", "")
|
||||
ip = addr[0]
|
||||
dev = _fetch_upnp_description(location) if location else {}
|
||||
name = dev.get("name") or headers.get("server", "") or ip
|
||||
dev_id = _slug(f"{name}-{ip}")
|
||||
entry = out.get(dev_id, {
|
||||
"id": dev_id, "name": name, "type": "media-renderer",
|
||||
"ip": ip, "via": "ssdp",
|
||||
})
|
||||
if dev.get("name"):
|
||||
entry["name"] = dev["name"]
|
||||
if dev.get("model"):
|
||||
entry["model"] = dev["model"]
|
||||
if dev.get("manufacturer"):
|
||||
entry["manufacturer"] = dev["manufacturer"]
|
||||
if dial_app or dev.get("dialAppUrl"):
|
||||
entry["dialAppUrl"] = dial_app or dev.get("dialAppUrl")
|
||||
entry["type"] = "dial"
|
||||
out[dev_id] = entry
|
||||
except Exception as exc:
|
||||
logger.debug("SSDP (%s) Fehler: %s", st, exc)
|
||||
finally:
|
||||
try:
|
||||
if sock:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _fetch_upnp_description(location: str) -> dict:
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(location, timeout=3)
|
||||
dial_app = r.headers.get("Application-URL", "")
|
||||
xml = r.text
|
||||
name = _xml_tag(xml, "friendlyName")
|
||||
model = _xml_tag(xml, "modelName")
|
||||
manuf = _xml_tag(xml, "manufacturer")
|
||||
return {"name": name, "model": model, "manufacturer": manuf, "dialAppUrl": dial_app}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _discover_arp() -> list[dict]:
|
||||
"""Rohe Host-Liste aus der ARP-Tabelle (kein aktiver Scan)."""
|
||||
out = []
|
||||
try:
|
||||
with open("/proc/net/arp", "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()[1:]
|
||||
for ln in lines:
|
||||
parts = ln.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
ip, _hw, _flags, mac = parts[0], parts[1], parts[2], parts[3]
|
||||
if mac == "00:00:00:00:00:00":
|
||||
continue
|
||||
out.append({
|
||||
"id": _slug(f"host-{ip}"), "name": ip, "type": "host",
|
||||
"ip": ip, "mac": mac, "via": "arp",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _merge_devices(*lists) -> list[dict]:
|
||||
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
|
||||
by_ip: dict[str, dict] = {}
|
||||
loose: list[dict] = []
|
||||
order = {"mdns": 3, "ssdp": 2, "arp": 1}
|
||||
for lst in lists:
|
||||
for d in lst:
|
||||
ip = d.get("ip") or ""
|
||||
if not ip:
|
||||
loose.append(d)
|
||||
continue
|
||||
cur = by_ip.get(ip)
|
||||
if not cur:
|
||||
by_ip[ip] = d
|
||||
else:
|
||||
# bessere Quelle / mehr Felder → mergen
|
||||
merged = {**d, **{k: v for k, v in cur.items() if v}}
|
||||
if order.get(d.get("via"), 0) >= order.get(cur.get("via"), 0):
|
||||
merged.update({k: v for k, v in d.items() if v})
|
||||
# DIAL-URL / mac aus beiden behalten
|
||||
for key in ("dialAppUrl", "mac", "model", "manufacturer"):
|
||||
merged[key] = d.get(key) or cur.get(key) or merged.get(key)
|
||||
by_ip[ip] = {k: v for k, v in merged.items() if v not in (None, "")}
|
||||
return list(by_ip.values()) + loose
|
||||
|
||||
|
||||
# ─── Control ────────────────────────────────────────────────────────
|
||||
|
||||
async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
||||
"""Fuehrt eine Steuer-Aktion aus. Guards: CONTROL_ENABLED + Allowlist."""
|
||||
if not CONTROL_ENABLED:
|
||||
return {"ok": False, "error": "Steuerung ist an diesem Satelliten deaktiviert (CONTROL_ENABLED=false)."}
|
||||
if action not in CONTROL_ALLOWLIST:
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht erlaubt (Allowlist: {', '.join(CONTROL_ALLOWLIST)})."}
|
||||
logger.info("[control] %s params=%s", action, {k: str(v)[:60] for k, v in (params or {}).items()})
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
if action == "dial.launch":
|
||||
return await loop.run_in_executor(None, _do_dial_launch, params, devices)
|
||||
if action == "wol":
|
||||
return await loop.run_in_executor(None, _do_wol, params)
|
||||
if action in ("http.get", "http.post"):
|
||||
return await loop.run_in_executor(None, _do_http, action, params)
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||
|
||||
|
||||
def _find_device(devices: list[dict], ref: str) -> Optional[dict]:
|
||||
ref = (ref or "").strip().lower()
|
||||
if not ref:
|
||||
return None
|
||||
for d in devices:
|
||||
if d.get("id", "").lower() == ref or d.get("ip", "") == ref:
|
||||
return d
|
||||
for d in devices:
|
||||
if ref in (d.get("name", "").lower()):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def _do_dial_launch(params: dict, devices: list[dict]) -> dict:
|
||||
"""DIAL-App-Launch, z.B. YouTube-Video auf Fire TV / Smart-TV.
|
||||
params: {device, app='YouTube', v=<videoId> (oder beliebige app-params)}"""
|
||||
import requests
|
||||
ref = params.get("device") or ""
|
||||
dev = _find_device(devices, ref)
|
||||
app_url = (dev or {}).get("dialAppUrl") if dev else params.get("dialAppUrl")
|
||||
if not app_url:
|
||||
return {"ok": False, "error": f"Kein DIAL-Geraet fuer '{ref}' gefunden (oder keine Application-URL)."}
|
||||
app = params.get("app") or "YouTube"
|
||||
# app-Parameter (alles ausser device/app) als form-urlencoded Body.
|
||||
body = {k: v for k, v in (params or {}).items() if k not in ("device", "app", "dialAppUrl")}
|
||||
url = app_url.rstrip("/") + "/" + app
|
||||
r = requests.post(url, data=body, timeout=5)
|
||||
ok = r.status_code in (200, 201)
|
||||
return {"ok": ok, "result": f"DIAL {app} → {(dev or {}).get('name', ref)} (HTTP {r.status_code})"
|
||||
if ok else None,
|
||||
"error": None if ok else f"DIAL-Launch HTTP {r.status_code}: {r.text[:120]}"}
|
||||
|
||||
|
||||
def _do_wol(params: dict) -> dict:
|
||||
mac = (params.get("mac") or "").strip()
|
||||
if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$", mac):
|
||||
return {"ok": False, "error": f"Ungueltige MAC: {mac!r}"}
|
||||
clean = re.sub(r"[:-]", "", mac)
|
||||
packet = b"\xff" * 6 + bytes.fromhex(clean) * 16
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
s.sendto(packet, ("255.255.255.255", 9))
|
||||
s.close()
|
||||
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
||||
|
||||
|
||||
def _do_http(action: str, params: dict) -> dict:
|
||||
import requests
|
||||
url = params.get("url") or ""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||
method = "GET" if action == "http.get" else "POST"
|
||||
r = requests.request(method, url, data=params.get("body"),
|
||||
headers=params.get("headers"), timeout=6)
|
||||
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _slug(s: str) -> str:
|
||||
s = (s or "").strip().lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s or "dev"
|
||||
|
||||
|
||||
def _parse_http_headers(text: str) -> dict:
|
||||
headers = {}
|
||||
for line in text.split("\r\n")[1:]:
|
||||
if ":" in line:
|
||||
k, _, v = line.partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
return headers
|
||||
|
||||
|
||||
def _xml_tag(xml: str, tag: str) -> str:
|
||||
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.IGNORECASE | re.DOTALL)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
# ─── Satellit (RVS-Client) ──────────────────────────────────────────
|
||||
|
||||
class Satellite:
|
||||
def __init__(self) -> None:
|
||||
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self._devices: list[dict] = []
|
||||
self._devices_ts: float = 0.0
|
||||
self._scanning = False
|
||||
|
||||
async def _scan(self, force: bool = False) -> list[dict]:
|
||||
fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC
|
||||
if self._devices and fresh and not force:
|
||||
return self._devices
|
||||
if self._scanning:
|
||||
# Laufenden Scan abwarten (grob)
|
||||
for _ in range(30):
|
||||
await asyncio.sleep(0.2)
|
||||
if not self._scanning:
|
||||
break
|
||||
return self._devices
|
||||
self._scanning = True
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
self._devices = _merge_devices(mdns, ssdp, arp)
|
||||
self._devices_ts = time.time()
|
||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
|
||||
len(self._devices), len(mdns), len(ssdp), len(arp))
|
||||
finally:
|
||||
self._scanning = False
|
||||
return self._devices
|
||||
|
||||
async def _send(self, message: dict) -> None:
|
||||
if self.ws is None:
|
||||
return
|
||||
try:
|
||||
await self.ws.send(json.dumps(message))
|
||||
except Exception as exc:
|
||||
logger.warning("send fehlgeschlagen: %s", exc)
|
||||
|
||||
async def _hello(self, log: bool = False) -> None:
|
||||
await self._send({
|
||||
"type": "sat_hello",
|
||||
"payload": {
|
||||
"id": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION,
|
||||
"caps": CAPABILITIES,
|
||||
"control": CONTROL_ENABLED,
|
||||
"net": NET,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
if log:
|
||||
logger.info("sat_hello gesendet: id=%s location=%s caps=%s",
|
||||
SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES)
|
||||
|
||||
def _for_me(self, payload: dict) -> bool:
|
||||
target = (payload.get("satellite") or "").strip().lower()
|
||||
if not target or target in ("all", "*"):
|
||||
return True
|
||||
return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower())
|
||||
|
||||
async def _handle(self, raw: str) -> None:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
return
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
|
||||
if mtype == "sat_discover":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
devices = await self._scan(force=bool(payload.get("force")))
|
||||
await self._send({
|
||||
"type": "sat_devices",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION, "devices": devices,
|
||||
"net": NET},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
elif mtype == "sat_command":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
action = (payload.get("action") or "").strip()
|
||||
params = payload.get("params") or {}
|
||||
if payload.get("device") and "device" not in params:
|
||||
params["device"] = payload.get("device")
|
||||
devices = await self._scan()
|
||||
result = await _control(action, params, devices)
|
||||
await self._send({
|
||||
"type": "sat_result",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _periodic_scan(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._scan(force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("periodischer Scan-Fehler: %s", exc)
|
||||
await asyncio.sleep(SCAN_INTERVAL_SEC)
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
# Re-announce bei jedem Heartbeat: falls die Bridge NACH uns (neu)
|
||||
# verbindet, lernt sie uns so innerhalb von HEARTBEAT_SEC — RVS replayt
|
||||
# nichts. Haelt zugleich last_seen in der Bridge-Registry frisch.
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_SEC)
|
||||
await self._send({"type": "heartbeat", "timestamp": int(time.time() * 1000)})
|
||||
await self._hello()
|
||||
|
||||
async def run(self) -> None:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||
return
|
||||
asyncio.create_task(self._periodic_scan())
|
||||
backoff = 1
|
||||
while True:
|
||||
proto = "wss" if RVS_TLS else "ws"
|
||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
try:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
||||
async with websockets.connect(url, max_size=8 * 1024 * 1024,
|
||||
ping_interval=20, ping_timeout=20) as ws:
|
||||
self.ws = ws
|
||||
backoff = 1
|
||||
await self._hello(log=True)
|
||||
hb = asyncio.create_task(self._heartbeat())
|
||||
try:
|
||||
async for raw in ws:
|
||||
await self._handle(raw)
|
||||
finally:
|
||||
hb.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||
finally:
|
||||
self.ws = None
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
|
||||
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
|
||||
logger.info("Netz: primary_ip=%s alle=%s", NET.get("primary_ip"), NET.get("ips"))
|
||||
if NET.get("warning"):
|
||||
logger.warning("⚠ %s", NET["warning"])
|
||||
try:
|
||||
asyncio.run(Satellite().run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+35
-3
@@ -30,7 +30,7 @@ services:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./voices:/voices # WAV + TXT Referenz
|
||||
@@ -63,12 +63,13 @@ services:
|
||||
whisper-bridge:
|
||||
build: ./whisper
|
||||
container_name: aria-whisper-bridge
|
||||
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
@@ -108,7 +109,7 @@ services:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
device_ids: ["0"] # LLM → GPU 0 (8 GB, teilt sich mit TTS)
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./models:/models # HF-Download-Cache (persistent)
|
||||
@@ -137,3 +138,34 @@ services:
|
||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
|
||||
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
|
||||
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
|
||||
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
|
||||
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
|
||||
# immer nur EINEN STT laufen lassen.
|
||||
voxtral-bridge:
|
||||
build: ./voxtral
|
||||
container_name: aria-voxtral-bridge
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
||||
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
||||
environment:
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
- RVS_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN}
|
||||
- VOXTRAL_MODEL=mistralai/Voxtral-Mini-3B-2507
|
||||
- VOXTRAL_LANGUAGE=${WHISPER_LANGUAGE:-de}
|
||||
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
|
||||
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -9,12 +9,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch CUDA-Wheels zuerst (f5-tts zieht sonst CPU-only Torch rein)
|
||||
RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
|
||||
--index-url https://download.pytorch.org/whl/cu121
|
||||
# torch FEST auf cu124 (Treiber 550 = CUDA 12.4). 2.6.0 ist der NEUESTE cu124-
|
||||
# Build — torch 2.7+ gibt es nur noch fuer cu126+, was 550 nicht unterstuetzt
|
||||
# ("NVIDIA driver too old, found 12040"). Der Constraint haelt f5-tts davon ab,
|
||||
# torch beim Dependency-Aufloesen wieder auf eine zu neue CUDA-Version zu ziehen.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 torchaudio==2.6.0 \
|
||||
--index-url https://download.pytorch.org/whl/cu124
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||
|
||||
COPY bridge.py .
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Voxtral-STT-3B Bridge (Transformers). Laeuft auf Treiber 550/CUDA 12.4 via
|
||||
# torch cu124 — KEIN Treiber-Upgrade noetig (gleicher Trick wie f5tts).
|
||||
# Modell: Voxtral-Mini-3B-2507 (~9 GB in bf16) → passt auf die 12-GB-Karte (GPU 1).
|
||||
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ffmpeg git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# torch FEST auf cu124 (Treiber 550 = CUDA 12.4). 2.6.0 ist der neueste cu124-Build;
|
||||
# torch 2.7+ gibt es nur fuer cu126+ und braeuchte einen neueren Treiber.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 torchaudio==2.6.0 \
|
||||
--index-url https://download.pytorch.org/whl/cu124
|
||||
|
||||
COPY requirements.txt .
|
||||
# Constraint haelt transformers/mistral-common davon ab, torch wieder hochzuziehen.
|
||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||
|
||||
COPY bridge.py speaker_id.py ./
|
||||
|
||||
CMD ["python3", "bridge.py"]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Voxtral-STT-3B-Satellit (Transformers)
|
||||
|
||||
Streaming-STT via **Voxtral-Mini-3B-2507** (Mistral, Apache 2.0) über
|
||||
🤗 Transformers. Ersetzt whisper als STT — genauer, und **ohne Treiber-Upgrade**:
|
||||
läuft auf dem Trixie-Standardtreiber (550/CUDA 12.4) via **torch 2.6.0+cu124**
|
||||
(derselbe Trick wie bei f5tts).
|
||||
|
||||
- Modell ~9 GB (bf16) → **GPU 1** (die 12-GB-Karte; per Compose gepinnt).
|
||||
- **F5-TTS + LLM** bleiben auf GPU 0 (8 GB).
|
||||
- Arbeitsweise = chunked wie whisper: live PCM → alle ~1 s transkribieren
|
||||
(Partials) → **adaptiver Endpointer** (Rausch-Boden-VAD + semantische
|
||||
Stagnation, aus M0.1) feuert `stt_endpoint`. RVS-Protokoll identisch → drop-in.
|
||||
|
||||
## Starten (Profil `voxtral`)
|
||||
|
||||
```bash
|
||||
cd xtts
|
||||
docker compose stop whisper-bridge # sonst beantworten beide stt_*
|
||||
docker compose --profile voxtral up -d --build
|
||||
docker logs -f aria-voxtral-bridge # Modell laedt (mehrere GB), dann "RVS verbunden"
|
||||
```
|
||||
Zurück zu whisper: `docker compose --profile voxtral down && docker compose up -d whisper-bridge`.
|
||||
|
||||
## ⚠️ Auf echter Hardware verifizieren (blind gebaut)
|
||||
|
||||
1. **Transformers-API.** Die exakte Voxtral-Transkriptions-API ist in `bridge.py`
|
||||
in **einer** Methode gekapselt (`VoxtralRunner._transcribe_blocking`), modelliert
|
||||
nach der HF-Modelcard (`apply_transcription_request` → `generate` → `batch_decode`).
|
||||
Beim ersten Lauf gegen die Modelcard prüfen und dort anpassen.
|
||||
2. **HF-Gating.** Ist `Voxtral-Mini-3B-2507` gated, `HF_TOKEN` in `xtts/.env` setzen
|
||||
(wird als `HUGGING_FACE_HUB_TOKEN` durchgereicht).
|
||||
3. **VRAM/Tempo.** 3B in bf16 ~9 GB auf der 12-GB-Karte — Rest fürs KV-Cache. Ist die
|
||||
Partial-Transkription (alle 1 s) zu schwer, `STREAM_TRANSCRIBE_INTERVAL_MS` hochsetzen.
|
||||
4. **torch-Konflikt.** Falls `transformers`/`mistral-common` beim Build torch>2.6
|
||||
erzwingen, meldet der Constraint einen Konflikt → dann brauchen wir doch das
|
||||
Treiber-Upgrade (`bootstrap.sh --upgrade-driver` via NVIDIA-CUDA-Repo) + cu126-torch.
|
||||
|
||||
## Protokoll (RVS, identisch zu whisper — drop-in)
|
||||
Rein: `stt_stream_start`, `stt_audio_chunk` (16 kHz mono s16le, base64), `stt_stream_end`.
|
||||
Raus: `stt_partial`, `stt_endpoint`, `stt_stream_done`.
|
||||
|
||||
## TTS
|
||||
Bleibt **F5-TTS** (klingt gut, passt auf GPU 0). Voxtral-TTS bräuchte ~24 GB — separates Thema.
|
||||
|
||||
## Quellen
|
||||
- Modell: https://huggingface.co/mistralai/Voxtral-Mini-3B-2507
|
||||
- Transformers-Nutzung: HF-Modelcard (Voxtral) + `mistral-common`
|
||||
@@ -0,0 +1,775 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ARIA Voxtral-STT-3B Bridge (Transformers) — Ersatz fuer whisper.
|
||||
|
||||
Laeuft auf Treiber 550/CUDA 12.4 via torch cu124 (kein Treiber-Upgrade noetig).
|
||||
Modell: Voxtral-Mini-3B-2507 (bf16, ~9 GB) → GPU 1 (12 GB, per Compose gepinnt).
|
||||
|
||||
Arbeitsweise = Zwilling der whisper-Bridge: App schickt live PCM-Chunks; wir
|
||||
transkribieren alle ~STREAM_TRANSCRIBE_INTERVAL_MS auf dem Ringbuffer (Partials)
|
||||
und feuern stt_endpoint, sobald der ADAPTIVE Endpointer (Rausch-Boden-VAD +
|
||||
semantische Stagnation, aus M0.1) "fertig" sagt. RVS-Wire-Protokoll identisch zu
|
||||
whisper → drop-in (die App merkt nur bessere Genauigkeit).
|
||||
|
||||
⚠️ VERIFY-ON-FIRST-RUN: Die exakte Transformers-Transkriptions-API von Voxtral
|
||||
(apply_transcription_request / generate / decode) ist unten in EINER Methode
|
||||
(VoxtralRunner._transcribe_blocking) gekapselt und nach dem HF-Modelcard-Muster
|
||||
modelliert. Beim ersten echten Lauf gegen die Voxtral-Modelcard pruefen und dort
|
||||
anpassen. Alles andere (RVS, Endpointer) ist bewaehrt.
|
||||
|
||||
Env:
|
||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||
VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-3B-2507
|
||||
VOXTRAL_LANGUAGE Default: de
|
||||
VOXTRAL_DEVICE Default: cuda
|
||||
STREAM_TRANSCRIBE_INTERVAL_MS Default 1000 (3B ist schwerer als whisper-small)
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import websockets
|
||||
|
||||
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("voxtral-bridge")
|
||||
|
||||
RVS_HOST = os.getenv("RVS_HOST", "").strip()
|
||||
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
|
||||
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
|
||||
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
||||
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
|
||||
|
||||
STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
|
||||
STREAM_DEFAULT_ENDPOINT_MS = 2400
|
||||
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
||||
STREAM_MIN_AUDIO_MS = 600
|
||||
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
|
||||
STREAM_SESSION_TTL_S = 120
|
||||
STREAM_ENERGY_WINDOW_MS = 300
|
||||
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
||||
# Adaptiver Voice-Schwellwert (M0.1): relativ zum gemessenen Rausch-Boden.
|
||||
STREAM_VOICE_FACTOR = 2.5
|
||||
STREAM_VOICE_RMS_MIN = 0.005
|
||||
STREAM_VOICE_RMS_MAX = 0.020
|
||||
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
|
||||
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
|
||||
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
|
||||
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
|
||||
|
||||
# Halluzinations-Filter (2. Netz NACH der Transkription). Der voiced_frames-Guard
|
||||
# oben faengt die reine Stille; hier kommt das "borderline"-Band dazu: wenn wenig
|
||||
# echte Stimme da war UND das Transkript ein bekanntes Voxtral-Silence-Artefakt
|
||||
# ist (Untertitel-Credits, Staedte-/Geo-Fakten "Flaeche von X km2"), ist es fast
|
||||
# sicher ein Phantom aus Fast-Nichts → verwerfen. Gegated auf wenig voiced_frames,
|
||||
# damit eine ECHTE Geografie-Frage (die hat normale Stimm-Energie) durchgeht.
|
||||
STREAM_HALLUC_GUARD_FRAMES = int(os.getenv("STREAM_HALLUC_GUARD_FRAMES",
|
||||
str(STREAM_MIN_VOICED_FRAMES * 4))) # ~1.6s
|
||||
_HALLUCINATION_RE = re.compile(
|
||||
r"untertitel"
|
||||
r"|amara\.org"
|
||||
r"|vielen\s+dank\s+f[uü]r'?s?\s+(zuschauen|zusehen|zuh[oö]ren)"
|
||||
r"|bis\s+zum\s+n[aä]chsten\s+mal"
|
||||
r"|abonnier"
|
||||
r"|fl[aä]che\s+von\s+[\d.,]+\s*(km|quadratkilometer)"
|
||||
r"|[\d.,]+\s*(km²|quadratkilometern?|einwohnern?)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
|
||||
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
|
||||
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
|
||||
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
|
||||
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _collapse_repetitions(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
|
||||
new = _REPEAT_RE.sub(r"\1", out)
|
||||
if new == out:
|
||||
break
|
||||
out = new
|
||||
return out.strip()
|
||||
|
||||
|
||||
# ── Silero VAD: echte Sprach-Erkennung VOR dem Transkribieren ──────────────
|
||||
# Der Muster-Filter oben kennt nur spezifische Artefakte. Generische Phantome
|
||||
# ("Ich bin ein guter Mann" aus Fast-Stille) kann ein Text-Regex nicht fangen —
|
||||
# aber ein VAD schon, weil es am AUDIO entscheidet, nicht am Text. Silero trennt
|
||||
# Sprache zuverlaessig von Stille / Rauschen / MUSIK. Kein Speech-Segment →
|
||||
# no-speech → nicht transkribieren → kein Phantom (und Musik/Instrumental fliegt
|
||||
# gleich mit raus).
|
||||
# FAIL-OPEN: klappt das VAD nicht (Import/Load/Inferenz), wird trotzdem normal
|
||||
# transkribiert. Die STT darf NIE komplett sterben (Speaker-ID-Lektion).
|
||||
SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
||||
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
||||
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
|
||||
|
||||
_vad_state = {"model": None, "get_ts": None, "failed": False}
|
||||
|
||||
|
||||
def _speech_segments(audio_f32):
|
||||
"""Silero-VAD-Sprachsegmente (Liste von {start,end} Sample-Indizes) im
|
||||
16kHz-float32-Audio. Rueckgabe:
|
||||
[] → kein Speech (Stille/Rauschen/Musik) → Phantom-Verdacht, verwerfen.
|
||||
[...] → Speech vorhanden.
|
||||
None → VAD nicht verfuegbar → fail-open (Aufrufer transkribiert normal)."""
|
||||
if not SILERO_VAD_ENABLED or _vad_state["failed"]:
|
||||
return None
|
||||
if _vad_state["model"] is None:
|
||||
try:
|
||||
from silero_vad import load_silero_vad, get_speech_timestamps
|
||||
_vad_state["model"] = load_silero_vad()
|
||||
_vad_state["get_ts"] = get_speech_timestamps
|
||||
logger.info("Silero VAD geladen (threshold=%.2f, min_speech=%dms)",
|
||||
SILERO_VAD_THRESHOLD, SILERO_MIN_SPEECH_MS)
|
||||
except Exception:
|
||||
logger.exception("Silero VAD Laden fehlgeschlagen — dauerhaft aus (fail-open)")
|
||||
_vad_state["failed"] = True
|
||||
return None
|
||||
try:
|
||||
import torch as _torch
|
||||
segs = _vad_state["get_ts"](
|
||||
_torch.from_numpy(audio_f32), _vad_state["model"],
|
||||
sampling_rate=16000, threshold=SILERO_VAD_THRESHOLD,
|
||||
min_speech_duration_ms=SILERO_MIN_SPEECH_MS,
|
||||
)
|
||||
return segs or []
|
||||
except Exception:
|
||||
logger.exception("Silero VAD Inferenz fehlgeschlagen — dieser Turn fail-open")
|
||||
return None
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
|
||||
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
|
||||
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
|
||||
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
|
||||
# vorbelegt werden.
|
||||
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _set_speaker_id_enabled(val: bool) -> None:
|
||||
global SPEAKER_ID_ENABLED
|
||||
SPEAKER_ID_ENABLED = bool(val)
|
||||
|
||||
|
||||
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
|
||||
if not data:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
return np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
"type": mtype, "payload": payload, "timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Send fehlgeschlagen (%s): %s", mtype, e)
|
||||
|
||||
|
||||
class VoxtralRunner:
|
||||
"""Haelt das Voxtral-Modell (Transformers). transcribe() blockiert → aus dem
|
||||
Event-Loop via run_in_executor aufrufen. Ein Lock serialisiert GPU-Zugriffe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.model = None
|
||||
self.processor = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def load(self) -> None:
|
||||
import torch
|
||||
from transformers import AutoProcessor, VoxtralForConditionalGeneration
|
||||
t0 = time.time()
|
||||
logger.info("Lade Voxtral '%s' (device=%s, bf16)…", VOXTRAL_MODEL, VOXTRAL_DEVICE)
|
||||
self.processor = AutoProcessor.from_pretrained(VOXTRAL_MODEL)
|
||||
self.model = VoxtralForConditionalGeneration.from_pretrained(
|
||||
VOXTRAL_MODEL, torch_dtype=torch.bfloat16, device_map=VOXTRAL_DEVICE,
|
||||
)
|
||||
logger.info("Voxtral geladen in %.1fs", time.time() - t0)
|
||||
|
||||
def _transcribe_blocking(self, audio_f32: np.ndarray, language: str) -> str:
|
||||
import torch
|
||||
proc, model = self.processor, self.model
|
||||
if proc is None or model is None or audio_f32.size == 0:
|
||||
return ""
|
||||
# VoxtralProcessor verlangt bei rohen Arrays ein 'format'. Robuster:
|
||||
# in ein temp-WAV schreiben und den PFAD uebergeben — der Processor liest
|
||||
# Format + Samplerate selbst, kein 'format'-Argument noetig.
|
||||
wav_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
|
||||
wav_path = tf.name
|
||||
sf.write(wav_path, audio_f32, 16000, subtype="PCM_16")
|
||||
inputs = proc.apply_transcription_request(
|
||||
language=language, audio=wav_path, model_id=VOXTRAL_MODEL,
|
||||
)
|
||||
inputs = inputs.to(VOXTRAL_DEVICE, dtype=torch.bfloat16)
|
||||
with torch.no_grad():
|
||||
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
|
||||
# mehrminutige Aufnahmen abgeschnitten.
|
||||
# Repetition-Bremse: Voxtral kippt bei Stille/Rauschen am Ende
|
||||
# gern in eine Schleife und wiederholt einen Satz zig-mal
|
||||
# ("Vergiss das, das ist nur... Vergiss das, das ist nur..."
|
||||
# x15). no_repeat_ngram_size=4 laesst die ERSTE echte Nennung
|
||||
# durch, verbietet aber die exakte 4-Gramm-Wiederholung → Loop
|
||||
# bricht ab; repetition_penalty daempft zusaetzlich. Beides mild,
|
||||
# damit normale Sprache (auch mal ein doppeltes Wort) unberuehrt
|
||||
# bleibt.
|
||||
outputs = model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=4096,
|
||||
no_repeat_ngram_size=4,
|
||||
repetition_penalty=1.15,
|
||||
)
|
||||
trimmed = outputs[:, inputs.input_ids.shape[1]:]
|
||||
text = proc.batch_decode(trimmed, skip_special_tokens=True)
|
||||
return (text[0] if text else "").strip()
|
||||
finally:
|
||||
if wav_path:
|
||||
try:
|
||||
os.unlink(wav_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def transcribe(self, audio_f32: np.ndarray, language: str) -> str:
|
||||
loop = asyncio.get_running_loop()
|
||||
async with self._lock:
|
||||
return await loop.run_in_executor(None, self._transcribe_blocking, audio_f32, language)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamSession:
|
||||
request_id: str
|
||||
audio_request_id: str
|
||||
language: str
|
||||
endpoint_ms: int
|
||||
hard_cap_ms: int
|
||||
voice: str = ""
|
||||
speed: float = 1.0
|
||||
interrupted: bool = False
|
||||
location: Optional[dict] = None
|
||||
sample_rate: int = 16000
|
||||
voice_factor: float = STREAM_VOICE_FACTOR
|
||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||
pcm_buffer: bytearray = field(default_factory=bytearray)
|
||||
started_at: float = field(default_factory=time.time)
|
||||
last_chunk_at: float = field(default_factory=time.time)
|
||||
last_partial: str = ""
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_at: float = 0.0
|
||||
last_voice_at: float = 0.0
|
||||
noise_floor: float = 0.0
|
||||
closed: bool = False
|
||||
endpoint_sent: bool = False
|
||||
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
|
||||
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
|
||||
speech_signaled: bool = False
|
||||
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
|
||||
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
|
||||
voiced_frames: int = 0
|
||||
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
|
||||
speaker_checked: bool = False
|
||||
speaker_match: Optional[bool] = None
|
||||
speaker_similarity: float = 0.0
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self, runner: VoxtralRunner) -> None:
|
||||
self.runner = runner
|
||||
self._sessions: dict[str, StreamSession] = {}
|
||||
self._ws = None
|
||||
|
||||
def attach_ws(self, ws) -> None:
|
||||
self._ws = ws
|
||||
|
||||
def start_session(self, payload: dict) -> None:
|
||||
rid = (payload.get("requestId") or "").strip()
|
||||
if not rid:
|
||||
return
|
||||
try:
|
||||
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
|
||||
except (TypeError, ValueError):
|
||||
endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS
|
||||
try:
|
||||
hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS)
|
||||
except (TypeError, ValueError):
|
||||
hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS
|
||||
try:
|
||||
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
|
||||
except (TypeError, ValueError):
|
||||
voice_factor = STREAM_VOICE_FACTOR
|
||||
self._sessions[rid] = StreamSession(
|
||||
request_id=rid,
|
||||
audio_request_id=payload.get("audioRequestId", "") or "",
|
||||
language=payload.get("language") or VOXTRAL_LANGUAGE,
|
||||
endpoint_ms=endpoint_ms,
|
||||
hard_cap_ms=hard_cap_ms,
|
||||
voice=payload.get("voice", "") or "",
|
||||
speed=float(payload.get("speed") or 1.0),
|
||||
voice_factor=voice_factor,
|
||||
interrupted=bool(payload.get("interrupted", False)),
|
||||
location=payload.get("location") or None,
|
||||
sample_rate=int(payload.get("sampleRate") or 16000),
|
||||
)
|
||||
logger.info("Voxtral-Session offen: id=%s lang=%s endpointMs=%d",
|
||||
rid[:8], self._sessions[rid].language, endpoint_ms)
|
||||
|
||||
def feed_chunk(self, payload: dict) -> bool:
|
||||
sess = self._sessions.get(payload.get("requestId", ""))
|
||||
if sess is None or sess.closed:
|
||||
return False
|
||||
pcm_b64 = payload.get("pcm", "")
|
||||
if pcm_b64:
|
||||
try:
|
||||
sess.pcm_buffer.extend(base64.b64decode(pcm_b64))
|
||||
except Exception:
|
||||
pass
|
||||
sess.last_chunk_at = time.time()
|
||||
return True
|
||||
|
||||
def end_session(self, request_id: str) -> None:
|
||||
sess = self._sessions.get(request_id)
|
||||
if sess is not None:
|
||||
sess.closed = True
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
self._sessions.pop(request_id, None)
|
||||
|
||||
# ── Endpointer (adaptiv, M0.1) ──
|
||||
def _buffer_ms(self, sess: StreamSession) -> float:
|
||||
samples = len(sess.pcm_buffer) // 2
|
||||
return (samples / sess.sample_rate) * 1000.0 if samples else 0.0
|
||||
|
||||
def _tail_rms(self, sess: StreamSession) -> float:
|
||||
win = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
|
||||
if win <= 0:
|
||||
return 0.0
|
||||
tail = sess.pcm_buffer[-win:]
|
||||
if len(tail) < 2:
|
||||
return 0.0
|
||||
arr = pcm_s16le_to_float32(bytes(tail))
|
||||
return float(np.sqrt(np.mean(arr * arr))) if arr.size else 0.0
|
||||
|
||||
def _voice_threshold(self, sess: StreamSession) -> float:
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
return sess.voice_rms_min
|
||||
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
|
||||
|
||||
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
sess.noise_floor = rms
|
||||
elif rms < nf:
|
||||
sess.noise_floor = 0.90 * nf + 0.10 * rms
|
||||
else:
|
||||
sess.noise_floor = 0.98 * nf + 0.02 * rms
|
||||
|
||||
async def _check_speaker(self, sess: StreamSession) -> None:
|
||||
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
|
||||
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
|
||||
sess.speaker_checked = True
|
||||
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||
if not SPEAKER_ID_ENABLED:
|
||||
sess.speaker_match = True
|
||||
return
|
||||
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
|
||||
sess.speaker_match = True
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
is_match, sim = await loop.run_in_executor(None, speaker_id.verify, head)
|
||||
except Exception as exc:
|
||||
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
|
||||
sess.request_id[:8], exc)
|
||||
sess.speaker_match = True
|
||||
return
|
||||
sess.speaker_match = is_match
|
||||
sess.speaker_similarity = sim
|
||||
logger.info("Stream %s: speaker-check sim=%.2f → %s (thr=%.2f)",
|
||||
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
|
||||
speaker_id.DEFAULT_THRESHOLD)
|
||||
if not is_match:
|
||||
await self._finalize_speaker_mismatch(sess, sim)
|
||||
|
||||
async def _finalize_speaker_mismatch(self, sess: StreamSession, similarity: float) -> None:
|
||||
"""Fremde Stimme: synthetisches leeres stt_endpoint (reason=speaker_mismatch),
|
||||
Session droppen — kein Voxtral-Transcribe, kein Brain-Call."""
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
duration_s = self._buffer_ms(sess) / 1000.0
|
||||
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
|
||||
sess.request_id[:8], similarity, duration_s)
|
||||
if self._ws is not None:
|
||||
payload = {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": "speaker_mismatch",
|
||||
"durationS": duration_s, "sttMs": 0,
|
||||
"voice": sess.voice, "speed": sess.speed,
|
||||
"interrupted": sess.interrupted,
|
||||
"speakerSimilarity": float(similarity),
|
||||
}
|
||||
if sess.location:
|
||||
payload["location"] = sess.location
|
||||
await _send(self._ws, "stt_endpoint", payload)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": "speaker_mismatch",
|
||||
})
|
||||
self.drop(sess.request_id)
|
||||
|
||||
async def run_endpointer(self) -> None:
|
||||
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
|
||||
STREAM_TRANSCRIBE_INTERVAL_MS)
|
||||
while True:
|
||||
await asyncio.sleep(0.2)
|
||||
now = time.time()
|
||||
for sid, sess in list(self._sessions.items()):
|
||||
try:
|
||||
await self._tick(sess, now)
|
||||
except Exception:
|
||||
logger.exception("Tick crashed (session=%s)", sid[:8])
|
||||
for sid, sess in list(self._sessions.items()):
|
||||
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
|
||||
logger.info("Stream %s: TTL — drop", sid[:8])
|
||||
self.drop(sid)
|
||||
|
||||
async def _tick(self, sess: StreamSession, now: float) -> None:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
if (now - sess.started_at) * 1000.0 > sess.hard_cap_ms and not sess.closed:
|
||||
await self._finalize(sess, "hardcap")
|
||||
return
|
||||
if sess.closed:
|
||||
await self._finalize(sess, "stream_end")
|
||||
return
|
||||
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
|
||||
return
|
||||
# Speaker-ID einmalig: ist es Stefans Stimme? Fremde → Session verwerfen
|
||||
# (kein Transcribe, kein Brain-Call). Ohne Enrollment fail-open.
|
||||
if not sess.speaker_checked and self._buffer_ms(sess) >= STREAM_SPEAKER_CHECK_MS:
|
||||
await self._check_speaker(sess)
|
||||
if sess.speaker_match is False:
|
||||
return
|
||||
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
|
||||
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
|
||||
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
|
||||
# hat den semantischen Endpoint faelschlich ausgeloest (Partial-Latenz >
|
||||
# Timeout → willkuerliche Abbrueche nach 20-40 s). Deshalb: Turn-Ende rein
|
||||
# AKUSTISCH, transkribiert wird nur EINMAL im _finalize.
|
||||
rms = self._tail_rms(sess)
|
||||
if rms >= self._voice_threshold(sess):
|
||||
sess.last_voice_at = now
|
||||
sess.voiced_frames += 1
|
||||
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
|
||||
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
|
||||
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
|
||||
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
|
||||
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
|
||||
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
|
||||
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
|
||||
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
|
||||
if (not sess.speech_signaled and self._ws is not None
|
||||
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
|
||||
sess.speech_signaled = True
|
||||
await _send(self._ws, "stt_partial", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "",
|
||||
})
|
||||
else:
|
||||
self._update_noise_floor(sess, rms)
|
||||
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
||||
# (last_voice_at==0), feuert der normale Endpoint unten NIE — der braucht
|
||||
# last_voice_at>0. Ohne das bleibt ein reines Stille-Fenster offen bis
|
||||
# Hardcap/manuellem Stop → genau Stefans Repro: "die Stille-Ende wird nie
|
||||
# erreicht, stop ich selbst ist es weg". Nach endpoint_ms Stille ab Start
|
||||
# schliessen wir das Fenster selbst als no-speech (leer, lautlos, zurueck
|
||||
# aufs Wake-Word). voiced_frames==0 → _finalize verwirft ohne Transkript,
|
||||
# also KEIN Phantom.
|
||||
if sess.last_voice_at == 0 and (now - sess.started_at) * 1000.0 >= sess.endpoint_ms:
|
||||
await self._finalize(sess, "no_speech")
|
||||
return
|
||||
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
||||
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
|
||||
await self._finalize(sess, "endpoint")
|
||||
|
||||
async def _emit_no_speech(self, sess: "StreamSession", reason_label: str) -> None:
|
||||
"""Leeres no-speech-Endpoint senden + Session droppen (kein Transkript).
|
||||
App re-armt still, zurueck aufs Wake-Word."""
|
||||
if self._ws is not None:
|
||||
payload = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": reason_label,
|
||||
"durationS": 0.0, "sttMs": 0}
|
||||
await _send(self._ws, "stt_endpoint", payload)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": reason_label})
|
||||
self.drop(sess.request_id)
|
||||
|
||||
async def _finalize(self, sess: StreamSession, reason: str) -> None:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
|
||||
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
|
||||
# aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
|
||||
# 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
|
||||
# entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
|
||||
# nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
|
||||
#
|
||||
# WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
|
||||
# reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
|
||||
# sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
|
||||
# Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
|
||||
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
||||
if sess.voiced_frames < _min_voiced:
|
||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
||||
if self._ws is not None:
|
||||
nospeech = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"no_speech:{reason}",
|
||||
"durationS": 0.0, "sttMs": 0}
|
||||
await _send(self._ws, "stt_endpoint", nospeech)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"no_speech:{reason}"})
|
||||
self.drop(sess.request_id)
|
||||
return
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
|
||||
# Silero VAD: ist ueberhaupt echte Sprache im Audio? Das entscheidet am
|
||||
# AUDIO, nicht am Text — faengt also generische Phantome ("Ich bin ein
|
||||
# guter Mann") UND Musik/Rauschen, die der Muster-Filter nicht kennt.
|
||||
# Kein Speech-Segment → no-speech, gar nicht erst transkribieren.
|
||||
# fail-open: segs=None (VAD nicht verfuegbar) → normal weiter.
|
||||
segs = _speech_segments(audio)
|
||||
if segs is not None and len(segs) == 0:
|
||||
logger.info("Stream %s: Silero VAD — keine Sprache (%.1fs, reason=%s) → no-speech",
|
||||
sess.request_id[:8], audio.size / 16000.0, reason)
|
||||
await self._emit_no_speech(sess, f"vad_no_speech:{reason}")
|
||||
return
|
||||
if segs:
|
||||
# Auf die Sprach-Spanne trimmen (Stille-Raender weg → Voxtral
|
||||
# halluziniert an den Enden weniger). Kleiner Pad gegen abgeschnittene
|
||||
# leise Wort-Anfaenge/-Enden.
|
||||
pad = int(SILERO_PAD_MS / 1000.0 * 16000)
|
||||
s0 = max(0, segs[0]["start"] - pad)
|
||||
s1 = min(int(audio.size), segs[-1]["end"] + pad)
|
||||
if s1 > s0 and (s1 - s0) < audio.size:
|
||||
audio = audio[s0:s1]
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
|
||||
except Exception:
|
||||
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
|
||||
final_text = sess.last_partial
|
||||
stt_ms = int((time.time() - t0) * 1000)
|
||||
duration_s = audio.size / 16000.0
|
||||
# Repetition-Loop einkassieren, falls trotz no_repeat_ngram was durchkam.
|
||||
_collapsed = _collapse_repetitions(final_text)
|
||||
if _collapsed != final_text:
|
||||
logger.info("Stream %s: Repetition-Loop kollabiert (%d→%d Zeichen)",
|
||||
sess.request_id[:8], len(final_text), len(_collapsed))
|
||||
final_text = _collapsed
|
||||
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
|
||||
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
|
||||
|
||||
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
|
||||
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
|
||||
# Flaeche von 1,5 km2") ans Brain zu schicken. Gilt fuer ALLE reasons inkl.
|
||||
# stream_end (dort kamen die realen Phantome!) — aber das borderline-Band
|
||||
# (wenig voiced_frames) schuetzt echte, klar gesprochene Eingaben: eine echte
|
||||
# Geografie-FRAGE hat normale Stimm-Energie (voiced_frames >> Schwelle) und
|
||||
# geht durch; das Phantom aus Stille hat ~0 und wird verworfen. Ein leeres
|
||||
# Transkript wird immer verworfen (nichts gesagt = nichts senden).
|
||||
_clean = final_text.strip(" .,!?…-\t\n\r")
|
||||
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
|
||||
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
|
||||
if _is_phantom:
|
||||
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
|
||||
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
|
||||
duration_s, final_text[:80])
|
||||
if self._ws is not None:
|
||||
nospeech = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"hallucination:{reason}",
|
||||
"durationS": 0.0, "sttMs": stt_ms}
|
||||
await _send(self._ws, "stt_endpoint", nospeech)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"hallucination:{reason}"})
|
||||
self.drop(sess.request_id)
|
||||
return
|
||||
|
||||
if self._ws is not None:
|
||||
payload = {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": final_text,
|
||||
"reason": reason,
|
||||
"durationS": duration_s,
|
||||
"sttMs": stt_ms,
|
||||
"voice": sess.voice,
|
||||
"speed": sess.speed,
|
||||
"interrupted": sess.interrupted,
|
||||
}
|
||||
if sess.location:
|
||||
payload["location"] = sess.location
|
||||
await _send(self._ws, "stt_endpoint", payload)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": final_text,
|
||||
"reason": reason,
|
||||
})
|
||||
self.drop(sess.request_id)
|
||||
|
||||
|
||||
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
payload = {"service": "voxtral", "state": state}
|
||||
payload.update(extra)
|
||||
await _send(ws, "service_status", payload)
|
||||
|
||||
|
||||
async def run_loop(sessions: SessionManager) -> None:
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
|
||||
max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
sessions.attach_ws(ws)
|
||||
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
|
||||
await _send(ws, "config_request", {"service": "voxtral"})
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
if mtype == "stt_stream_start":
|
||||
sessions.start_session(payload)
|
||||
elif mtype == "stt_audio_chunk":
|
||||
sessions.feed_chunk(payload)
|
||||
elif mtype == "stt_stream_end":
|
||||
sessions.end_session(payload.get("requestId", ""))
|
||||
elif mtype == "voice_id_status_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
try:
|
||||
status = speaker_id.status()
|
||||
await _send(ws, "voice_id_status_response",
|
||||
{"requestId": req_id, "ok": True, **status})
|
||||
except Exception as exc:
|
||||
await _send(ws, "voice_id_status_response",
|
||||
{"requestId": req_id, "ok": False, "error": str(exc)[:200]})
|
||||
elif mtype == "voice_id_enroll_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
samples = payload.get("samples") or []
|
||||
logger.info("voice_id_enroll_request: %d Samples (id=%s)", len(samples), req_id[:8])
|
||||
try:
|
||||
result = await asyncio.get_running_loop().run_in_executor(
|
||||
None, speaker_id.enroll_from_samples, samples)
|
||||
await _send(ws, "voice_id_enroll_response", {
|
||||
"requestId": req_id, "ok": True,
|
||||
"sample_count": result.get("sample_count", 0),
|
||||
"rejected": result.get("rejected", []),
|
||||
"updated_at": result.get("updated_at"),
|
||||
"embedding_dim": result.get("embedding_dim"),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("voice_id_enroll failed: %s", exc)
|
||||
await _send(ws, "voice_id_enroll_response",
|
||||
{"requestId": req_id, "ok": False, "error": str(exc)[:300]})
|
||||
elif mtype == "voice_id_delete_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
removed = speaker_id.delete_fingerprint()
|
||||
await _send(ws, "voice_id_delete_response",
|
||||
{"requestId": req_id, "ok": True, "removed": removed})
|
||||
elif mtype == "config":
|
||||
if "voiceIdThreshold" in payload:
|
||||
try:
|
||||
t = float(payload.get("voiceIdThreshold", 0.5))
|
||||
if 0.0 <= t <= 1.0:
|
||||
speaker_id.DEFAULT_THRESHOLD = t
|
||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "voiceIdEnabled" in payload:
|
||||
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
continue
|
||||
await asyncio.sleep(retry_s)
|
||||
retry_s = min(retry_s * 2, 30)
|
||||
use_tls = RVS_TLS
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.")
|
||||
return
|
||||
runner = VoxtralRunner()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
|
||||
sessions = SessionManager(runner)
|
||||
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
|
||||
await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,11 @@
|
||||
# Voxtral-3B via Transformers. torch/torchaudio kommen cu124-gepinnt aus dem
|
||||
# Dockerfile (nicht hier, sonst zieht pip das Default-CUDA-Wheel).
|
||||
transformers>=4.54
|
||||
mistral-common[audio]>=1.8.1
|
||||
accelerate>=0.30
|
||||
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
|
||||
silero-vad>=5.1 # neuronales VAD: echte Sprache vs Stille/Rauschen/Musik
|
||||
soundfile>=0.12
|
||||
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
|
||||
numpy>=1.24
|
||||
websockets>=12.0
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
|
||||
|
||||
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
|
||||
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
|
||||
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
|
||||
|
||||
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
|
||||
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
|
||||
|
||||
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
|
||||
verify() → (True, 0.0) — Fail-open, damit Speaker-ID-Gating den
|
||||
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
|
||||
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
|
||||
|
||||
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
|
||||
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
|
||||
# Diagnostic-Setting feintunen.
|
||||
DEFAULT_THRESHOLD = 0.5
|
||||
|
||||
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
|
||||
MIN_SAMPLE_BYTES = 32000
|
||||
|
||||
_model = None
|
||||
|
||||
|
||||
def _ensure_loaded():
|
||||
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
|
||||
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
|
||||
danach <1s warm. Wirft bei Fehler — Caller muss catchen + fail-open."""
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
import torch
|
||||
from speechbrain.inference.speaker import EncoderClassifier
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
|
||||
_model = EncoderClassifier.from_hparams(
|
||||
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||
savedir="/root/.cache/huggingface/speechbrain-ecapa",
|
||||
run_opts={"device": device},
|
||||
)
|
||||
logger.info("[speaker-id] model ready (device=%s)", device)
|
||||
return _model
|
||||
|
||||
|
||||
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||
tf.write(audio_bytes)
|
||||
tmp = tf.name
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ValueError(
|
||||
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return proc.stdout
|
||||
finally:
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
import io
|
||||
import wave
|
||||
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
|
||||
sr = wav.getframerate()
|
||||
ch = wav.getnchannels()
|
||||
sw = wav.getsampwidth()
|
||||
if sr != 16000:
|
||||
raise ValueError(f"WAV-Samplerate {sr} != 16000")
|
||||
if ch != 1:
|
||||
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||
return _decode_compressed_to_pcm(audio_bytes)
|
||||
return audio_bytes
|
||||
|
||||
|
||||
def _audio_bytes_to_tensor(audio_bytes: bytes):
|
||||
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
|
||||
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
|
||||
import torch
|
||||
raw = _normalize_audio_bytes(audio_bytes)
|
||||
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
return torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
|
||||
def embed(audio_bytes: bytes) -> np.ndarray:
|
||||
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
|
||||
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
|
||||
import torch
|
||||
model = _ensure_loaded()
|
||||
wav = _audio_bytes_to_tensor(audio_bytes)
|
||||
with torch.no_grad():
|
||||
emb = model.encode_batch(wav)
|
||||
return emb.squeeze().cpu().numpy().astype(np.float32)
|
||||
|
||||
|
||||
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
|
||||
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
|
||||
na = np.linalg.norm(a)
|
||||
nb = np.linalg.norm(b)
|
||||
if na < 1e-9 or nb < 1e-9:
|
||||
return 0.0
|
||||
return float(np.dot(a, b) / (na * nb))
|
||||
|
||||
|
||||
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
|
||||
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
|
||||
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
|
||||
if not embeddings:
|
||||
raise ValueError("Keine Embeddings zum Speichern")
|
||||
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stacked = np.stack(embeddings)
|
||||
mean = stacked.mean(axis=0)
|
||||
mean = mean / max(np.linalg.norm(mean), 1e-9)
|
||||
data = {
|
||||
"version": 1,
|
||||
"embedding": mean.tolist(),
|
||||
"embedding_dim": int(mean.shape[0]),
|
||||
"sample_count": len(embeddings),
|
||||
"sample_durations_s": [float(s) for s in sample_durations_s],
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
|
||||
len(embeddings), mean.shape[0], sum(sample_durations_s))
|
||||
return data
|
||||
|
||||
|
||||
def load_fingerprint() -> Optional[dict]:
|
||||
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
|
||||
if not FINGERPRINT_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def delete_fingerprint() -> bool:
|
||||
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
|
||||
if FINGERPRINT_FILE.exists():
|
||||
FINGERPRINT_FILE.unlink()
|
||||
logger.info("[speaker-id] fingerprint geloescht")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
|
||||
"""Returns (is_match, similarity).
|
||||
|
||||
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) — der wird
|
||||
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
|
||||
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
|
||||
|
||||
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
|
||||
crasht, returnt (True, 0.0) — kein Filtering. Sonst wuerde ein kaputter
|
||||
Speaker-ID-Service die ganze Aufnahme blockieren."""
|
||||
if threshold is None:
|
||||
threshold = DEFAULT_THRESHOLD
|
||||
fp = load_fingerprint()
|
||||
if fp is None:
|
||||
return True, 0.0
|
||||
if len(audio_bytes) < MIN_SAMPLE_BYTES:
|
||||
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
|
||||
return True, 0.0
|
||||
try:
|
||||
saved_emb = np.array(fp["embedding"], dtype=np.float32)
|
||||
new_emb = embed(audio_bytes)
|
||||
except Exception as exc:
|
||||
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
|
||||
return True, 0.0
|
||||
sim = cosine_similarity(new_emb, saved_emb)
|
||||
return sim >= threshold, sim
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Status-Snapshot fuer die App / Diagnostic."""
|
||||
fp = load_fingerprint()
|
||||
return {
|
||||
"enrolled": fp is not None,
|
||||
"sample_count": fp.get("sample_count", 0) if fp else 0,
|
||||
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
|
||||
"updated_at": fp.get("updated_at") if fp else None,
|
||||
"embedding_dim": fp.get("embedding_dim") if fp else None,
|
||||
"default_threshold": DEFAULT_THRESHOLD,
|
||||
}
|
||||
|
||||
|
||||
def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
|
||||
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
|
||||
if not samples_b64:
|
||||
raise ValueError("Keine Samples uebergeben")
|
||||
embeddings: list[np.ndarray] = []
|
||||
durations: list[float] = []
|
||||
rejected: list[dict] = []
|
||||
for idx, s in enumerate(samples_b64):
|
||||
try:
|
||||
raw = base64.b64decode(s)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||
continue
|
||||
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||
try:
|
||||
pcm = _normalize_audio_bytes(raw)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||
continue
|
||||
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||
continue
|
||||
try:
|
||||
emb = embed(pcm)
|
||||
embeddings.append(emb)
|
||||
durations.append(len(pcm) / 2 / 16000.0)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||
if not embeddings:
|
||||
raise ValueError(
|
||||
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
|
||||
f"Details: {rejected[:3]}"
|
||||
)
|
||||
fingerprint = save_fingerprint(embeddings, durations)
|
||||
fingerprint["rejected"] = rejected
|
||||
return fingerprint
|
||||
+68
-2
@@ -75,7 +75,27 @@ STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
|
||||
# (Whisper oszilliert/halluziniert). Echte akustische Stille ist das robuste
|
||||
# „User hat aufgehoert"-Signal.
|
||||
STREAM_ENERGY_WINDOW_MS = 300 # RMS ueber die letzten 300ms Audio messen
|
||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # RMS darueber = Sprache (haelt Session am Leben)
|
||||
# --- Adaptiver Voice-Schwellwert (ersetzt die fixe 0.012-Grenze) ---
|
||||
# Problem (Repro dokumentiert in audio.ts): eine FESTE RMS-Grenze schneidet
|
||||
# leises/entferntes Sprechen faelschlich als „Stille" (Handy weiter weg vom Mund,
|
||||
# ruhig im Auto, kurze Sprech-Pause) → Cut mitten im Satz. Loesung: die Grenze
|
||||
# relativ zum gemessenen Rausch-Boden der Session fuehren. Sprache =
|
||||
# noise_floor * Faktor, geklammert auf [MIN, MAX]. Bei noch ungelerntem Boden
|
||||
# gilt MIN → sensibel, lieber nicht abschneiden.
|
||||
STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
|
||||
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
|
||||
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
|
||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
|
||||
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
|
||||
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
|
||||
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _set_speaker_id_enabled(val: bool) -> None:
|
||||
global SPEAKER_ID_ENABLED
|
||||
SPEAKER_ID_ENABLED = bool(val)
|
||||
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
||||
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
||||
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
||||
@@ -323,6 +343,10 @@ class StreamSession:
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_at: float = 0.0
|
||||
last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie
|
||||
noise_floor: float = 0.0 # adaptiver Rausch-Boden (0.0 = noch ungelernt)
|
||||
voice_factor: float = STREAM_VOICE_FACTOR # per-Session konfigurierbar (Payload voiceFactor)
|
||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||
closed: bool = False # nach stream_end gesetzt
|
||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||
@@ -372,6 +396,10 @@ class SessionManager:
|
||||
speed = float(payload.get("speed") or 1.0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
try:
|
||||
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
|
||||
except (TypeError, ValueError):
|
||||
voice_factor = STREAM_VOICE_FACTOR
|
||||
session = StreamSession(
|
||||
request_id=request_id,
|
||||
audio_request_id=payload.get("audioRequestId", "") or "",
|
||||
@@ -381,6 +409,7 @@ class SessionManager:
|
||||
hard_cap_ms=hard_cap_ms,
|
||||
voice=payload.get("voice", "") or "",
|
||||
speed=speed,
|
||||
voice_factor=voice_factor,
|
||||
interrupted=bool(payload.get("interrupted", False)),
|
||||
location=payload.get("location") or None,
|
||||
sample_rate=int(payload.get("sampleRate") or 16000),
|
||||
@@ -448,6 +477,11 @@ class SessionManager:
|
||||
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
||||
Session sofort beendet mit synthetischem stt_endpoint."""
|
||||
sess.speaker_checked = True
|
||||
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||
if not SPEAKER_ID_ENABLED:
|
||||
sess.speaker_match = True
|
||||
sess.speaker_similarity = 0.0
|
||||
return
|
||||
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
|
||||
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
|
||||
@@ -549,8 +583,16 @@ class SessionManager:
|
||||
# Akustische Sprach-Aktivitaet JEDEN Tick (~200ms) messen — unabhaengig
|
||||
# vom Transcribe-Throttle. Solange wirklich gesprochen wird, bleibt die
|
||||
# Session am Leben, auch wenn Whisper gerade keinen neuen Text liefert.
|
||||
if self._tail_rms(sess) >= STREAM_VOICE_RMS_THRESHOLD:
|
||||
# ADAPTIV: der Schwellwert richtet sich nach dem gemessenen Rausch-Boden
|
||||
# (fast-down/slow-up), damit leises/entferntes Sprechen nicht faelschlich
|
||||
# als Stille gilt und der Satz mitten drin abgeschnitten wird.
|
||||
rms = self._tail_rms(sess)
|
||||
if rms >= self._voice_threshold(sess):
|
||||
sess.last_voice_at = now
|
||||
else:
|
||||
# Rausch-Boden NUR aus Nicht-Sprache lernen — waehrend Sprache
|
||||
# einfrieren, sonst wandert die Grenze hoch und sperrt Sprache aus.
|
||||
self._update_noise_floor(sess, rms)
|
||||
|
||||
# Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde:
|
||||
# (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist
|
||||
@@ -620,6 +662,26 @@ class SessionManager:
|
||||
return 0.0
|
||||
return (samples / sess.sample_rate) * 1000.0
|
||||
|
||||
def _voice_threshold(self, sess: StreamSession) -> float:
|
||||
"""Adaptiver Voice-Schwellwert = Rausch-Boden * Faktor, geklammert auf
|
||||
[min, max]. Bei noch ungelerntem Boden (0.0) → Untergrenze: sensibel,
|
||||
lieber nicht abschneiden (das war der eigentliche Cutoff-Bug)."""
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
return sess.voice_rms_min
|
||||
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
|
||||
|
||||
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
|
||||
"""Rausch-Boden nachfuehren: schnell runter (neue, leisere Stille),
|
||||
langsam rauf (Umgebung wird lauter). NUR mit Nicht-Sprache aufrufen."""
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
sess.noise_floor = rms
|
||||
elif rms < nf:
|
||||
sess.noise_floor = 0.90 * nf + 0.10 * rms
|
||||
else:
|
||||
sess.noise_floor = 0.98 * nf + 0.02 * rms
|
||||
|
||||
def _tail_rms(self, sess: StreamSession) -> float:
|
||||
"""RMS-Energie der letzten STREAM_ENERGY_WINDOW_MS des Audio-Buffers.
|
||||
Dient als akustisches „redet noch / ist still"-Signal."""
|
||||
@@ -928,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "voiceIdEnabled" in payload:
|
||||
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
if "whisperDebugLog" in payload:
|
||||
global _DEBUG_LOG_TO_BRIDGE
|
||||
old = _DEBUG_LOG_TO_BRIDGE
|
||||
|
||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
||||
return _model
|
||||
|
||||
|
||||
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||
tf.write(audio_bytes)
|
||||
tmp = tf.name
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ValueError(
|
||||
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return proc.stdout
|
||||
finally:
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
||||
Ergebnis: rohes PCM."""
|
||||
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||
return _decode_compressed_to_pcm(audio_bytes)
|
||||
return audio_bytes
|
||||
|
||||
|
||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||
continue
|
||||
if len(raw) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
||||
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||
try:
|
||||
pcm = _normalize_audio_bytes(raw)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||
continue
|
||||
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||
continue
|
||||
try:
|
||||
emb = embed(raw)
|
||||
emb = embed(pcm)
|
||||
embeddings.append(emb)
|
||||
durations.append(len(raw) / 2 / 16000.0)
|
||||
durations.append(len(pcm) / 2 / 16000.0)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||
if not embeddings:
|
||||
|
||||
Reference in New Issue
Block a user