Compare commits

...
18 Commits
Author SHA1 Message Date
duffyduck bbfd544013 release: bump version to 0.2.1.8 2026-07-17 00:12:38 +02:00
duffyduckandClaude Opus 4.8 aadc030407 docs(readme): Installations-Schritt 6 — QEMU-Host-Provisionierung
qemu-setup.sh laeuft NICHT ueber docker compose (QEMU sitzt auf dem Host, nicht
im Container). Neuer optionaler Schritt 6 im Installations-Abschnitt: einmalig
`sudo bash host-provisioning/qemu-setup.sh` fuer den Desktop-Workspace (VM-Bauen/
VNC), plus Hinweis auf den noetigen App-Rebuild (neue native Module).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:09:30 +02:00
duffyduckandClaude Opus 4.8 85756a161b docs: Desktop-Workspace dokumentieren + Version 0.2.2.0
CHANGELOG-Eintrag [0.2.2.0] (Canvas, Live-Editor, QEMU/VNC), README-Features +
Completed-Log erweitert, App-Version 0.2.1.7 → 0.2.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:05:38 +02:00
duffyduckandClaude Opus 4.8 ea3de70d05 feat(app): Workspace-Umbau Schritt 8 — Live-Reveal + Layout-Persistenz
- Progressive Reveal: ChatScreen spiegelt bei project_changed den Projekt-Typ
  sofort in projectFocus (kind_changed nach set_project_kind) → Editor/Desktop-
  Kacheln erscheinen live, nicht erst beim Reconnect.
- useWorkspaceLayout: merkt pro Projekt die zuletzt fokussierte Kachel
  (AsyncStorage aria_workspace_layout) und stellt sie beim Zurueckkehren in ein
  Code-Projekt wieder her.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:04:03 +02:00
duffyduckandClaude Opus 4.8 0b35ea9bde feat: Workspace-Umbau Schritt 7 — VNC-Live-Desktop durch RVS getunnelt
QEMU-VNC erscheint live in der App-Desktop-Kachel, komplett ueber RVS:
- Bridge: TCP↔RVS-VNC-Bruecke. vnc_open oeffnet asyncio-Verbindung zu
  host.docker.internal:5901, Reader-Loop streamt RFB-Bytes als vnc_data;
  vnc_input schreibt zurueck; vnc_close raeumt auf. check_desktop probt den
  Port und meldet desktop_status. Base64-in-JSON, kein websockify/noVNC auf
  dem Host noetig.
- App: novncHtml.ts laedt noVNC (CDN) und ersetzt window.WebSocket durch einen
  Shim, der RFB-Bytes per postMessage ueber RVS brueckt (server-speaks-first →
  timing-robust). VncTile mountet die WebView nur im Fokus, oeffnet bei 'ready'
  den Tunnel (desktop.openVnc), speist Server-Bytes ein und schickt Eingaben
  als vnc_input; beim Verlassen wird der Tunnel geschlossen (VM laeuft weiter).

tsc/py clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:02:04 +02:00
duffyduckandClaude Opus 4.8 a082c8398e feat: Workspace-Umbau Schritt 6 — QEMU fuer alle Architekturen + set_project_kind
QEMU auf dem Host + ARIA-Anbindung:
- host-provisioning/qemu-setup.sh: installiert qemu-system-* (x86/arm/mips/ppc/
  sparc/misc), qemu-utils, Firmware, socat, imagemagick + den aria-vm-Helper.
- host-provisioning/aria-vm: VM-Verwaltung fuer JEDE Architektur (create/boot/
  screenshot/list/stop/rm). VNC bindet nur 127.0.0.1:<display> (Tunnel via
  Bridge), KVM nur fuer x86-Gaeste, sonst TCG.
- Brain: Projekt-Modell bekommt kind ('chat'|'code'); neues Tool
  set_project_kind markiert das aktive Projekt (blendet Editor/Desktop in der
  App ein) + feuert project_changed.
- Seed-Regel: ARIA weiss jetzt, dass Code unter /shared/projects/<id>/ gehoert
  und wie sie per `ssh aria-wohnung aria-vm ...` VMs baut/testet; VNC landet
  automatisch in der Desktop-Kachel.

py-compile clean, Host-Skripte bash -n OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:57:57 +02:00
duffyduckandClaude Opus 4.8 a6cb152f55 feat: Workspace-Umbau Schritt 5 — Live-Code-Editor + code_file-Strom
Durchgaengiger Live-Editor fuer Code-Projekte:
- App: selbstenthaltener Highlight-Editor in einer WebView (editorHtml.ts,
  Textarea + Regex-Highlight-Layer, voll offline). CodeEditorTile mit Datei-Tabs,
  verdrahtet mit dem codeFile-Spiegel; Bridge-Protokoll setContent/applyPatch/
  setReadOnly ↔ onEditFromUser.
- Proxy-Hook (routes.js): faengt ARIAs Write/Edit/MultiEdit unter
  /shared/projects/<pid>/ ab und postet den Volltext bei Erfolg an
  /internal/code-file. tool_use_id→file_path-Korrelation, liest die Datei aus
  dem gemounteten /shared.
- Bridge: /internal/code-file relayt als RVS code_file an die App; eingehende
  code_file_edit schreiben den Volltext pfad-sicher nach /shared/projects/<pid>/
  (_write_project_file, kein Ausbruch via ..).

Arbeitsverzeichnis fuer Code-Projekte = /shared/projects/<projectId>/ (in proxy/
bridge/brain gemountet, kein SSH noetig). tsc/py/js clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:53:48 +02:00
duffyduckandClaude Opus 4.8 85363b1014 feat(app): Workspace-Umbau Schritt 4 — zoombarer Canvas + Fokus/Uebersicht
Der Chat-Tab hostet ab jetzt den Workspace-Canvas (eine ChatScreen-Instanz,
kein Doppel-Mount). Zwei Ebenen:
- Welt-Ebene (reanimated scale/translate): leichte Thumbnail-Kacheln, 2-Finger
  Pinch-Zoom + 2-Finger-Pan nur in der Uebersicht (gesture-handler).
- Identity-Content-Ebene (Scale 1): schwere Inhalte (ChatScreen + kommende
  WebViews) immer gemountet, nur die fokussierte per display sichtbar → Touch/
  Keyboard bleiben korrekt, nichts remountet beim Fokuswechsel.

Tap auf Kachel = Fokus (voll interaktiv), "⤢ Uebersicht"/Hardware-Back = zurueck
zur Landkarte. Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert
→ verhaelt sich exakt wie der bisherige Vollbild-Chat.

Neue Dateien unter android/src/workspace/: layout.ts (Kamera-Mathe, Center-
Origin fuer RN 0.73), WorkspaceCanvas.tsx, WorkspaceScreen.tsx, Tile.tsx,
tiles/{ChatTile,CodeEditorTile,VncTile,PreviewTile}.tsx. Editor/VNC sind noch
Platzhalter (Commit 5/7). tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:48:04 +02:00
duffyduckandClaude Opus 4.8 20c527c8ed feat: Workspace-Umbau Schritt 3 — Backend-Kanaele + code/desktop-Services
Verdrahtet die neuen Kanaele "dunkel" (noch ohne UI):
- rvs/server.js: ALLOWED_TYPES um code_file/code_file_edit, check_desktop/
  desktop_status und vnc_open/close/data/input erweitert (Base64-in-JSON-Relay
  wie audio_pcm, kein Binaer-Handling noetig).
- brainApi.ts: Project.kind ('code'|'chat') + desktop_url; ChatScreen publiziert
  die Kinds in den projectFocus-Spiegel.
- services/codeFile.ts: Live-Spiegel der Code-Dateien (content + Deltas) mit
  Ruckkanal code_file_edit fuer eigene Edits.
- services/desktop.ts: Desktop-Status + VNC-RFB-Tunnel (vnc_open/close/input/
  data) durch RVS, Session = Projekt-ID.

tsc clean, rvs/server.js syntaktisch OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:40:17 +02:00
duffyduckandClaude Opus 4.8 80b534cbab feat(app): Workspace-Umbau Schritt 2 — projectFocus-Spiegel
Neues Singleton services/projectFocus.ts (Publish/Subscribe wie rvs.ts): haelt
focusedProjectId, Projekt-Namen und Projekt-Kind. ChatScreen publiziert Focus +
Namen EINWEG hinein (rein additive Effekte), damit der kommende Workspace-Canvas
den aktiven Kontext und den Code-Projekt-Status kennt, ohne dass ChatScreen den
Workspace kennt oder umgebaut wird. Verhaltensneutral, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:36:09 +02:00
duffyduckandClaude Opus 4.8 747c67766c feat(app): Workspace-Umbau Schritt 1 — gesture-handler/reanimated/webview + Root-Wrapper
Fundament fuer den zoombaren Desktop-Workspace:
- react-native-gesture-handler@2.14.1, react-native-reanimated@3.6.2,
  react-native-webview@13.6.4 (kompatibel mit RN 0.73.4 / Hermes / old-arch)
- babel.config.js: reanimated/plugin als letztes Plugin
- index.js: gesture-handler als allererster Import
- App.tsx: GestureHandlerRootView um den gesamten Baum

Hinweis: nach npm install einmalig `npm start --reset-cache` + nativer Rebuild
(gradlew clean), sonst brechen die reanimated-Worklets still.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:09:59 +02:00
duffyduck c92e042e91 release: bump version to 0.2.1.7 2026-07-14 23:20:57 +02:00
duffyduckandClaude Opus 4.8 019b17ff97 fix(app): Queue friert nach Verbindungsabbruch nicht mehr ein (Issue 1)
Zwei Ursachen:
- pending_queue-Bubbles haben (noch) keine clientMsgId → der Reconnect-
  History-Sync erkannte sie nicht als lokal-only und verwarf sie, waehrend
  projectQueues den Eintrag behielt → 'N in Warteschlange' fror ein ohne
  sichtbare Nachricht. Jetzt bleiben pending_queue-Bubbles beim Sync erhalten.
- Watchdog: haengt ein Kontext >15s auf 'running', obwohl der Brain ihn NICHT
  als busy meldet (Antwort beim Abbruch verloren), schaltet die Queue jetzt
  selbst weiter (dequeue oder idle) statt fuer immer zu blockieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:18:06 +02:00
duffyduckandClaude Opus 4.8 55938c3173 fix(brain): Trigger-Zeit UTC-konsistent, project_summary liest Hauptchat, Privatkopie-Regel
- Trigger-Zeit (Issue 4): fires_at wird beim Anlegen IMMER auf UTC normalisiert
  (naiv = lokale Wanduhrzeit Europe/Berlin → UTC, aware → astimezone). Passt zur
  UTC-Ausfuehrung in background.py → feuert zur gemeinten Ortszeit, zeitzonen-
  portabel. Tool-Desc + Prompt: fires_at = lokale Wanduhrzeit ohne Zone schreiben,
  Server rechnet um. Behebt den 2h-Versatz.
- project_summary (Issue 2): liest jetzt auch den Hauptchat (name='Hauptchat')
  und die Beschreibung sagt ARIA, es fuer 'schau mal in Projekt X / Hauptchat
  rein' / 'hol dir die Infos aus ...' zu nutzen.
- Privatkopie (Issue 3): IDENTITY_ANCHOR-Regel — YouTube-Downloads fuer Stefans
  PRIVATgebrauch sind eine Privatkopie (§53 UrhG), nicht reflexartig ablehnen/
  moralisieren; Urteil bleibt fuer Weiterverbreitung/kommerziell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:12:02 +02:00
duffyduck 761217cb5a release: bump version to 0.2.1.6 2026-07-12 10:09:49 +02:00
duffyduckandClaude Opus 4.8 ff6d31acbd docs(readme): Pro-Projekt-Queue, Rueckfrage-Loop, Drafts + TTS-Abspiel-Queue
App-Features + Completed-Log aktualisiert; veraltetes Barge-In-Cancel-Verhalten
durch das neue Anstellen-statt-Abbrechen ersetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:07:18 +02:00
duffyduckandClaude Opus 4.8 2d7a5784c2 docs(changelog): 0.2.1.5 — Pro-Projekt-Queue mit Rueckfrage-Loop
Queue pro Projekt (anstellen statt abbrechen) + [[AWAIT]]-Rueckfrage-Loop,
Pro-Projekt-Textfeld-Entwuerfe, TTS-Abspiel-Queue (back-to-back sprechen
nacheinander), Voice bricht nicht mehr ab. App + Diagnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:05:08 +02:00
duffyduckandClaude Opus 4.8 fa871219ae feat(queue): TTS-Abspiel-Queue — back-to-back-Antworten sprechen nacheinander
Bisher war die serialisierte Sprachausgabe zweier fast gleichzeitig fertiger
Antworten Timing-Glueck: PcmStreamPlayer.start() ruft stopInternal() (flush+
release), eine neue Antwort haette die laufende also abgeschnitten, sobald ihr
Audio waehrend der Wiedergabe der ersten ankam.

Jetzt echte Abspiel-Queue im audioService: kommt eine neue HOERBARE Antwort
waehrend eine andere noch hoerbar spielt (pcmAudiblePlaying bis
PcmPlaybackFinished, nicht nur bis Stream-Ende), werden ihre PCM-Chunks
gepuffert und erst nach dem Drain der laufenden nachgespielt. Bei wartender
Antwort meldet PcmPlaybackFinished NICHT 'fertig' (kein Wake-Word-Re-Arm).
Harter Stop/Barge-In/Mute verwirft die Queue. Race gegen gleichzeitige Chunks
einer dritten Antwort geschlossen (Flags vor await gesetzt). onPcmCached meldet
den WAV-Pfad nachgespielter Antworten fuer Mund-Button-Replay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:03:31 +02:00
34 changed files with 2201 additions and 40 deletions
+46
View File
@@ -10,6 +10,52 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
---
## [0.2.2.0] — 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
+45 -1
View File
@@ -204,6 +204,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 +500,17 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Features
- **Desktop-Workspace (zoombarer Canvas)**: Die App ist eine desktop-artige Arbeitsfläche — rausgezoomt eine **Landkarte aus Kacheln** (Chat/Editor/Desktop/Vorschau), die man mit **2 Fingern zoomt und verschiebt**; Tippen auf eine Kachel zoomt sie voll auf und macht sie echt bedienbar („Übersicht + Fokus"). Reiner Chat verhält sich exakt wie bisher (eine Kachel, dauerhaft fokussiert). Zurück per „⤢ Übersicht" oder Hardware-Back. Basiert auf gesture-handler + reanimated; interaktive Inhalte werden nie unter Zoom-Transform gerendert (Keyboard/Touch bleiben korrekt)
- **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.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (130 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 +518,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.06.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 +1031,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 +1044,10 @@ 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-Workspace**: zoom-/verschiebbarer Kachel-Canvas (gesture-handler + reanimated), „Übersicht + Fokus" — Chat/Editor/Desktop/Vorschau als Kacheln; reiner Chat unverändert (eine Kachel)
- [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
+8 -4
View File
@@ -8,10 +8,11 @@
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 rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
@@ -132,7 +133,7 @@ const App: React.FC = () => {
}, []);
return (
<>
<GestureHandlerRootView style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
<NavigationContainer theme={DarkTheme}>
<Tab.Navigator
@@ -165,7 +166,7 @@ const App: React.FC = () => {
>
<Tab.Screen
name="Chat"
component={ChatScreen}
component={WorkspaceScreen}
options={{
title: 'ARIA Chat',
headerTitle: 'ARIA Cockpit',
@@ -180,13 +181,16 @@ const App: React.FC = () => {
/>
</Tab.Navigator>
</NavigationContainer>
</>
</GestureHandlerRootView>
);
};
// --- Styles ---
const styles = StyleSheet.create({
root: {
flex: 1,
},
header: {
backgroundColor: '#12122A',
elevation: 0,
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20105
versionName "0.2.1.5"
versionCode 20108
versionName "0.2.1.8"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+3
View File
@@ -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'],
};
+3
View File
@@ -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';
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.1.5",
"version": "0.2.1.8",
"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",
+55 -3
View File
@@ -38,6 +38,7 @@ import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import projectFocus from '../services/projectFocus';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
import {
@@ -318,6 +319,9 @@ const ChatScreen: React.FC = () => {
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
const projectStatesRef = useRef<Record<string, CtxState>>({});
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
// Wann ist ein Kontext in 'running' gegangen? Fuer den Watchdog, der eine
// haengende Queue (z.B. Antwort waehrend Verbindungsabbruch verloren) loest.
const ctxRunningSinceRef = useRef<Record<string, number>>({});
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
const projectDraftsRef = useRef<Record<string, string>>({});
const prevFocusedPidRef = useRef<string>('');
@@ -522,8 +526,13 @@ const ChatScreen: React.FC = () => {
brainApi.listProjects(true)
.then(list => {
const map: Record<string, string> = {};
for (const p of list) map[p.id] = p.name;
const kinds: Record<string, 'code' | 'chat'> = {};
for (const p of list) {
map[p.id] = p.name;
kinds[p.id] = p.kind === 'code' ? 'code' : 'chat';
}
setProjectNameById(prev => ({ ...prev, ...map }));
projectFocus.setKinds(kinds);
})
.catch(() => {});
};
@@ -551,6 +560,12 @@ const ChatScreen: React.FC = () => {
}
}, [focusedProjectId]);
// Focus + Projekt-Namen EINWEG in den projectFocus-Spiegel publizieren,
// damit der Workspace-Canvas den aktiven Kontext + Code-Projekt-Status
// kennt. Rein additiv — aendert ChatScreens eigenes Verhalten nicht.
useEffect(() => { projectFocus.setFocus(focusedProjectId); }, [focusedProjectId]);
useEffect(() => { projectFocus.setNames(projectNameById); }, [projectNameById]);
// inputText-Spiegel (damit der Draft-Wechsel oben inputText nicht als Dep braucht).
useEffect(() => { inputTextRef.current = inputText; }, [inputText]);
@@ -583,8 +598,24 @@ const ChatScreen: React.FC = () => {
try {
const s = await brainApi.getProjectQueueStatus();
if (cancelled) return;
setQueueStatus(s.contexts || {});
queueStatusRef.current = s.contexts || {};
const ctxs = s.contexts || {};
setQueueStatus(ctxs);
queueStatusRef.current = ctxs;
// Watchdog: haengt ein Kontext seit >15s auf 'running', obwohl der Brain
// ihn NICHT als busy meldet, ist die Antwort verloren gegangen (z.B.
// Verbindungsabbruch) — sonst friert die Queue ein. Dann weiterschalten.
const api = queueApiRef.current;
if (api) {
const now = Date.now();
for (const [pid, since] of Object.entries(ctxRunningSinceRef.current)) {
if (api.getCtxState(pid) !== 'running') continue;
const busy = !!ctxs[pid || '__main__']?.busy;
if (!busy && now - since > 15000) {
console.log('[Chat] Queue-Watchdog: Kontext %s haengt (running, brain idle) → weiterschalten', pid || '(main)');
api.advanceQueue(pid);
}
}
}
} catch {}
};
poll();
@@ -918,6 +949,11 @@ const ChatScreen: React.FC = () => {
const localOnly = prev.filter(m => {
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
// Wartende Queue-Bubbles (noch nicht gesendet → kein clientMsgId, nicht
// auf dem Server) MUESSEN erhalten bleiben — sonst verschwindet die
// Bubble beim Reconnect-Sync, waehrend projectQueues den Eintrag behaelt
// → "N in Warteschlange" friert ein ohne sichtbare Nachricht.
if (m.sender === 'user' && m.deliveryStatus === 'pending_queue') return true;
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
// Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
// User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
@@ -951,6 +987,11 @@ const ChatScreen: React.FC = () => {
if (p.id && p.name) {
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
}
// Projekt-Typ sofort spiegeln → Editor/Desktop-Kacheln erscheinen live
// (z.B. nach set_project_kind), nicht erst beim naechsten Reconnect.
if (p.id && (p.kind === 'code' || p.kind === 'chat')) {
projectFocus.setKind(p.id, p.kind);
}
if (action === 'entered' || action === 'created') {
if (p.id) setFocusedProjectId(p.id);
} else if (action === 'exited') {
@@ -1731,6 +1772,13 @@ const ChatScreen: React.FC = () => {
// das Mikro greifen kann.
wakeWordService.stopBargeListening().catch(() => {});
});
// Aus der TTS-Queue nachgespielte (zweite) Antwort: ihren WAV-Cache-Pfad an
// die Bubble haengen, damit der Mund-Button/Play sie auch abspielen kann.
const unsubPcmCached = audioService.onPcmCached((messageId, audioPath) => {
if (!messageId || !audioPath) return;
setMessages(prev => prev.map(m =>
m.messageId === messageId ? { ...m, audioPath } : m));
});
return () => {
unsubWake();
@@ -1739,6 +1787,7 @@ const ChatScreen: React.FC = () => {
unsubPassive();
unsubTtsStart();
unsubTtsEnd();
unsubPcmCached();
};
}, [wakeWordActive]);
@@ -2042,6 +2091,9 @@ const ChatScreen: React.FC = () => {
const setCtxState = useCallback((pid: string, s: CtxState) => {
projectStatesRef.current = { ...projectStatesRef.current, [pid]: s };
// Watchdog-Zeitstempel: nur 'running' bekommt einen Start, sonst raus.
if (s === 'running') ctxRunningSinceRef.current[pid] = Date.now();
else delete ctxRunningSinceRef.current[pid];
setProjectStates(prev => ({ ...prev, [pid]: s }));
}, []);
+127 -1
View File
@@ -284,6 +284,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 +381,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
@@ -1401,6 +1418,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 +1482,8 @@ class AudioService {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
this.pcmAudiblePlaying = true;
this.pcmPlayingMsgId = messageId;
}
}
@@ -1490,6 +1526,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 +1681,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);
@@ -1759,6 +1875,10 @@ class AudioService {
}
// 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(() => {});
@@ -1770,7 +1890,8 @@ class AudioService {
// 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);
@@ -1804,6 +1925,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
+6
View File
@@ -162,6 +162,12 @@ 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;
}
export interface ProjectStatus {
Binary file not shown.
+102
View File
@@ -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;
+105
View File
@@ -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;
+61
View File
@@ -0,0 +1,61 @@
/**
* Tile — leichte Thumbnail-Darstellung einer Kachel in der gezoomten "Landkarte".
*
* Zeigt NUR Icon + Titel (+ optionalen Untertitel). Die schweren, interaktiven
* Inhalte (ChatScreen, WebViews) liegen NICHT hier, sondern in der separaten
* Identity-Content-Ebene des WorkspaceCanvas — Thumbnails werden nie skaliert
* interaktiv. Ein Tap fokussiert die Kachel (zoomt sie voll auf).
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import { TileId, TileRect, TILE_META } from './layout';
interface Props {
id: TileId;
/** Welt-View-lokales Rect (relativ zur Bounds-Ecke). */
rect: TileRect;
subtitle?: string;
onFocus: (id: TileId) => void;
}
const Tile: React.FC<Props> = ({ id, rect, subtitle, onFocus }) => {
const meta = TILE_META[id];
const tap = Gesture.Tap()
.maxDuration(300)
.onEnd((_e, success) => {
if (success) runOnJS(onFocus)(id);
});
return (
<GestureDetector gesture={tap}>
<View style={[styles.tile, { left: rect.x, top: rect.y, width: rect.w, height: rect.h }]}>
<Text style={styles.icon}>{meta.icon}</Text>
<Text style={styles.title}>{meta.title}</Text>
{!!subtitle && <Text style={styles.subtitle} numberOfLines={2}>{subtitle}</Text>}
<Text style={styles.hint}>Tippen zum Öffnen</Text>
</View>
</GestureDetector>
);
};
const styles = StyleSheet.create({
tile: {
position: 'absolute',
backgroundColor: '#12122A',
borderRadius: 32,
borderWidth: 3,
borderColor: '#1E1E2E',
alignItems: 'center',
justifyContent: 'center',
padding: 40,
},
icon: { fontSize: 220, marginBottom: 24 },
title: { color: '#FFFFFF', fontSize: 96, fontWeight: '800' },
subtitle: { color: '#9090B0', fontSize: 52, marginTop: 20, textAlign: 'center' },
hint: { color: '#555570', fontSize: 46, marginTop: 40 },
});
export default Tile;
+244
View File
@@ -0,0 +1,244 @@
/**
* WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus.
*
* Zwei Ebenen uebereinander:
* 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier
* wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht).
* 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren,
* interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln
* sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar.
* Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet
* beim Fokuswechsel.
*
* Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der
* "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte.
* Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der
* Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { BackHandler, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import {
boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal,
} from './layout';
import Tile from './Tile';
import { useWorkspaceLayout } from './useWorkspaceLayout';
import ChatTile from './tiles/ChatTile';
import CodeEditorTile from './tiles/CodeEditorTile';
import VncTile from './tiles/VncTile';
import PreviewTile from './tiles/PreviewTile';
interface Props {
projectId: string;
visibleTiles: TileId[];
subtitles?: Partial<Record<TileId, string>>;
}
const ANIM = { duration: 260 };
const MIN_SCALE = 0.15;
const MAX_SCALE = 4;
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
const { width: vw, height: vh } = useWindowDimensions();
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('chat');
const visibleKey = visibleTiles.join(',');
const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]);
const worldW = bounds.w;
const worldH = bounds.h;
const single = visibleTiles.length <= 1;
const { loaded: layoutLoaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
// Kamera (shared values fuer 60fps auf dem UI-Thread).
const scale = useSharedValue(1);
const tx = useSharedValue(0);
const ty = useSharedValue(0);
const savedScale = useSharedValue(1);
const savedTx = useSharedValue(0);
const savedTy = useSharedValue(0);
// Bei nur einer Kachel: immer fokussiert. Verschwindet die fokussierte
// Kachel aus der Sichtbarkeit, auf Chat zurueckfallen.
useEffect(() => {
if (single) {
setFocusedTileId(visibleTiles[0] || 'chat');
} else if (focusedTileId && !visibleTiles.includes(focusedTileId)) {
setFocusedTileId('chat');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleKey]);
// Zuletzt fokussierte Kachel pro Projekt wiederherstellen (nur Mehr-Kachel-
// Projekte; laeuft NACH dem single-Effekt oben, gewinnt also fuer Code-Projekte).
useEffect(() => {
if (!layoutLoaded || single) return;
const stored = getFocus();
if (stored === undefined) return;
if (stored === null || visibleTiles.includes(stored)) setFocusedTileId(stored);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, layoutLoaded, visibleKey]);
// Fokus-Wahl persistieren.
useEffect(() => {
if (layoutLoaded) saveFocus(focusedTileId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedTileId, layoutLoaded]);
// Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht).
useEffect(() => {
const cam: Camera = focusedTileId
? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh)
: overviewCamera(worldW, worldH, vw, vh);
scale.value = withTiming(cam.scale, ANIM);
tx.value = withTiming(cam.tx, ANIM);
ty.value = withTiming(cam.ty, ANIM);
savedScale.value = cam.scale;
savedTx.value = cam.tx;
savedTy.value = cam.ty;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedTileId, visibleKey, vw, vh]);
// Hardware-Back: im Fokus (und mehr als eine Kachel) → zurueck zur Uebersicht.
useEffect(() => {
const onBack = () => {
if (focusedTileId && !single) {
setFocusedTileId(null);
return true;
}
return false;
};
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
return () => sub.remove();
}, [focusedTileId, single]);
// Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel).
const gesturesEnabled = !focusedTileId && !single;
const canvasGesture = useMemo(() => {
const pinch = Gesture.Pinch()
.enabled(gesturesEnabled)
.onUpdate((e) => {
'worklet';
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale));
})
.onEnd(() => {
'worklet';
savedScale.value = scale.value;
});
const pan = Gesture.Pan()
.enabled(gesturesEnabled)
.minPointers(2)
.onUpdate((e) => {
'worklet';
tx.value = savedTx.value + e.translationX;
ty.value = savedTy.value + e.translationY;
})
.onEnd(() => {
'worklet';
savedTx.value = tx.value;
savedTy.value = ty.value;
});
return Gesture.Simultaneous(pinch, pan);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gesturesEnabled]);
const worldStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: tx.value },
{ translateY: ty.value },
{ scale: scale.value },
],
}));
const renderContent = (id: TileId) => {
switch (id) {
case 'chat': return <ChatTile />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
case 'preview': return <PreviewTile />;
default: return null;
}
};
return (
<View style={styles.root}>
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
<GestureDetector gesture={canvasGesture}>
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
{visibleTiles.map((id) => (
<Tile
key={id}
id={id}
rect={toLocal(TILE_RECTS[id], bounds)}
subtitle={subtitles?.[id]}
onFocus={setFocusedTileId}
/>
))}
</Animated.View>
</GestureDetector>
</View>
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
{visibleTiles.map((id) => (
<View
key={id}
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
>
{renderContent(id)}
</View>
))}
</View>
{/* Steuerung */}
{focusedTileId && !single && (
<TouchableOpacity style={styles.overviewBtn} onPress={() => setFocusedTileId(null)} activeOpacity={0.8}>
<Text style={styles.overviewBtnText}> Übersicht</Text>
</TouchableOpacity>
)}
{!focusedTileId && (
<View style={styles.hintBar} pointerEvents="none">
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#0D0D1A' },
world: { position: 'absolute', left: 0, top: 0 },
overviewBtn: {
position: 'absolute',
top: 10,
right: 12,
backgroundColor: 'rgba(18,18,42,0.92)',
borderColor: '#1E1E2E',
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 14,
paddingVertical: 8,
},
overviewBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
hintBar: {
position: 'absolute',
bottom: 16,
left: 0,
right: 0,
alignItems: 'center',
},
hintText: {
color: '#9090B0',
fontSize: 12,
backgroundColor: 'rgba(18,18,42,0.85)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
overflow: 'hidden',
},
});
export default WorkspaceCanvas;
+59
View File
@@ -0,0 +1,59 @@
/**
* WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas.
*
* Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind:
* - projectFocus: aktives Projekt + dessen kind ('code'|'chat')
* - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors)
* - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel)
*
* Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas,
* bleibt aber genau eine Instanz.
*/
import React, { useEffect, useMemo, useState } from 'react';
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
import codeFile from '../services/codeFile';
import desktop from '../services/desktop';
import { TileId, visibleTilesFor } from './layout';
import WorkspaceCanvas from './WorkspaceCanvas';
const WorkspaceScreen: React.FC = () => {
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
const [hasCode, setHasCode] = useState(false);
const [hasDesktop, setHasDesktop] = useState(false);
useEffect(() => projectFocus.subscribe(setFocus), []);
const pid = focus.focusedProjectId;
const kind = projectFocus.getProjectKind(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 + einmaliger Check beim Betreten eines Code-Projekts.
useEffect(() => {
setHasDesktop(desktop.getStatus().available);
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
if (kind === 'code') desktop.requestCheck(pid);
return unsub;
}, [pid, kind]);
const visibleTiles: TileId[] = useMemo(
() => visibleTilesFor({ kind, hasCode, hasDesktop }),
[kind, hasCode, hasDesktop],
);
const subtitles = useMemo(
() => ({ chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat' } as Partial<Record<TileId, string>>),
[pid, focus.projectNameById],
);
return <WorkspaceCanvas projectId={pid} visibleTiles={visibleTiles} subtitles={subtitles} />;
};
export default WorkspaceScreen;
+167
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>`;
+97
View File
@@ -0,0 +1,97 @@
/**
* 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');
// noVNC per ESM vom CDN laden.
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/', {});
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;
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>`;
+95
View File
@@ -0,0 +1,95 @@
/**
* layout — Kachel-Geometrie + Kamera-Mathematik fuer den Workspace-Canvas.
*
* Welt-Koordinaten = Pixel bei Scale 1. Kacheln liegen auf einem festen 2x2-
* Raster. Die "Welt-View" (Animated.View) ist exakt die Bounding-Box der gerade
* sichtbaren Kacheln; Kinder werden relativ zu deren Ursprung positioniert.
*
* RN 0.73 kennt noch kein transformOrigin — Scale dreht um die View-MITTE.
* Alle Kamera-Formeln rechnen deshalb mit Center-Origin:
* screen = center + scale*(p - center) + translate
* wobei center = (worldW/2, worldH/2) (die Welt-View sitzt bei screen 0,0).
*/
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
export interface TileRect { x: number; y: number; w: number; h: number; }
export interface TileDef { id: TileId; title: string; icon: string; }
// Feste Kachelgroesse im Welt-Raster (Pixel bei Scale 1).
const TILE_W = 1100;
const TILE_H = 1500;
const GAP = 140;
/** Absolute Welt-Rects pro Kachel (2x2-Raster). */
export const TILE_RECTS: Record<TileId, TileRect> = {
chat: { x: 0, y: 0, w: TILE_W, h: TILE_H },
editor: { x: TILE_W + GAP, y: 0, w: TILE_W, h: TILE_H },
vnc: { x: 0, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
preview: { x: TILE_W + GAP, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
};
export const TILE_META: Record<TileId, TileDef> = {
chat: { id: 'chat', title: 'Chat', icon: '💬' },
editor: { id: 'editor', title: 'Editor', icon: '📝' },
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
};
// Reihenfolge fuer stabiles Rendering.
export const TILE_ORDER: TileId[] = ['chat', 'editor', 'vnc', 'preview'];
export interface VisibilitySignals {
kind: 'code' | 'chat';
hasCode: boolean; // schon ein code_file empfangen
hasDesktop: boolean; // Desktop verfuegbar gemeldet
}
/** Welche Kacheln sind fuer den aktuellen Kontext sichtbar?
* Chat ist immer da; Code-Projekt (explizit ODER durch ein Live-Signal)
* blendet Editor/Desktop/Vorschau ein. */
export function visibleTilesFor(sig: VisibilitySignals): TileId[] {
const isCode = sig.kind === 'code' || sig.hasCode || sig.hasDesktop;
if (!isCode) return ['chat'];
return ['chat', 'editor', 'vnc', 'preview'];
}
/** Bounding-Box mehrerer Kacheln. */
export function boundsOf(ids: TileId[]): TileRect {
if (ids.length === 0) return { x: 0, y: 0, w: TILE_W, h: TILE_H };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of ids) {
const r = TILE_RECTS[id];
minX = Math.min(minX, r.x);
minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.w);
maxY = Math.max(maxY, r.y + r.h);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
/** Rect in Welt-View-lokale Koordinaten (relativ zur Bounds-Ecke) umrechnen. */
export function toLocal(rect: TileRect, bounds: TileRect): TileRect {
return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h };
}
export interface Camera { scale: number; tx: number; ty: number; }
/** Kamera, die ein (lokales) Rect bildschirmfuellend zeigt (Fokus-Modus). */
export function focusCamera(localRect: TileRect, worldW: number, worldH: number, vw: number, vh: number): Camera {
const s = Math.min(vw / localRect.w, vh / localRect.h);
const pcx = localRect.x + localRect.w / 2;
const pcy = localRect.y + localRect.h / 2;
const tx = vw / 2 - worldW / 2 - s * (pcx - worldW / 2);
const ty = vh / 2 - worldH / 2 - s * (pcy - worldH / 2);
return { scale: s, tx, ty };
}
/** Kamera, die die gesamte Welt zentriert einpasst (Uebersicht). */
export function overviewCamera(worldW: number, worldH: number, vw: number, vh: number, pad = 0.86): Camera {
const s = Math.min(vw / worldW, vh / worldH) * pad;
const tx = vw / 2 - worldW / 2;
const ty = vh / 2 - worldH / 2;
return { scale: s, tx, ty };
}
+15
View File
@@ -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,128 @@
/**
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
*
* Zeigt live, was ARIA in diesem Projekt schreibt (aus dem codeFile-Spiegel)
* und laesst Stefan selbst editieren — Aenderungen gehen als code_file_edit
* zurueck an die Bridge. Datei-Tabs oben zum Umschalten.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import codeFile, { CodeFileState } from '../../services/codeFile';
import { EDITOR_HTML } from '../assets/editorHtml';
interface Props {
projectId: string;
}
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
const webRef = useRef<WebView>(null);
const [files, setFiles] = useState<CodeFileState[]>(() => codeFile.getFiles(projectId));
const [currentPath, setCurrentPath] = useState<string | null>(files[0]?.path ?? null);
const readyRef = useRef(false);
const currentPathRef = useRef<string | null>(currentPath);
currentPathRef.current = currentPath;
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((path: string | null) => {
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
const f = codeFile.getFile(projectId, path);
sendToWeb({ cmd: 'setContent', content: f?.content ?? '', language: f?.language ?? 'text', version: f?.version ?? 0 });
}, [projectId, sendToWeb]);
// Projektwechsel: Dateiliste + Auswahl neu.
useEffect(() => {
const list = codeFile.getFiles(projectId);
setFiles(list);
setCurrentPath((prev) => (prev && list.some((f) => f.path === prev) ? prev : list[0]?.path ?? null));
}, [projectId]);
// Eingehende Updates aus dem Spiegel.
useEffect(() => {
return codeFile.subscribe((u) => {
if ((u.projectId || '') !== (projectId || '')) return;
setFiles(codeFile.getFiles(projectId));
// Noch keine Datei gewaehlt → diese oeffnen.
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
if (u.path !== currentPathRef.current) return;
if (!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 → in den Editor 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}>Noch keine Datei</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
{files.map((f) => {
const active = f.path === currentPath;
const name = f.path.split('/').pop() || f.path;
return (
<TouchableOpacity key={f.path} onPress={() => setCurrentPath(f.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,40 @@
/**
* PreviewTile — zeigt den letzten Screenshot/Vorschau-Frame eines Code-Projekts
* (z. B. aria-vm screenshot). Fuellt sich, sobald ARIA ein Bild in den
* Vorschau-Kanal legt; bis dahin ein ruhiger Platzhalter.
*
* (Screenshot-Anbindung folgt mit dem QEMU-Track; hier zunaechst die Kachel.)
*/
import React from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
interface Props {
imageUri?: string;
}
const PreviewTile: React.FC<Props> = ({ imageUri }) => {
return (
<View style={styles.container}>
{imageUri ? (
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="contain" />
) : (
<>
<Text style={styles.icon}>🖼</Text>
<Text style={styles.text}>Noch keine Vorschau</Text>
<Text style={styles.sub}>Screenshots der VM erscheinen hier.</Text>
</>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
image: { width: '100%', height: '100%' },
icon: { fontSize: 64, marginBottom: 16 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
});
export default PreviewTile;
+105
View File
@@ -0,0 +1,105 @@
/**
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
*
* Nur im Fokus aktiv: dann wird die WebView gemountet, bei 'ready' der
* RVS-VNC-Tunnel geoeffnet (desktop.openVnc). Server-Bytes (vnc_data) werden in
* die WebView injiziert, RFB-Bytes der WebView (vnc_send) gehen als vnc_input
* zurueck. Verlaesst man die Kachel, wird der Tunnel geschlossen (die VM laeuft
* auf dem Host weiter).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, 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;
}
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
const webRef = useRef<WebView>(null);
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
const unsubDataRef = useRef<null | (() => void)>(null);
// Aufraeumen: Tunnel zu + Daten-Abo weg.
const teardown = useCallback(() => {
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
desktop.closeVnc();
}, []);
// Fokus verloren / Unmount → Tunnel schliessen.
useEffect(() => {
if (!focused) { teardown(); setStatus('idle'); }
return () => teardown();
}, [focused, teardown]);
const onMessage = useCallback((e: WebViewMessageEvent) => {
let m: any;
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
if (m.event === 'ready') {
// WebView + RFB bereit → Tunnel oeffnen und Server-Bytes einspeisen.
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);
} 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]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>🖥</Text>
<Text style={styles.text}>Desktop</Text>
<Text style={styles.sub}>Antippen zum Verbinden</Text>
</View>
);
}
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"
/>
{status !== '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 },
overlay: { position: 'absolute', top: 10, 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 };
}
+72 -19
View File
@@ -540,10 +540,13 @@ META_TOOLS = [
"fires_at": {
"type": "string",
"description": (
"Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. "
"'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im "
"System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben "
"lieber `in_seconds` nutzen."
"Fester Termin als ISO-Timestamp. Schreib einfach die LOKALE "
"Wanduhrzeit, die Stefan meint, OHNE Zeitzone — z.B. 'um 14:30' "
"'2026-05-12T14:30:00'. Der Server rechnet sie selbst in UTC "
"um (naiv = Ortszeit Europe/Berlin). Nur wenn du explizit UTC "
"willst, haeng Z an ('...T12:30:00Z'). Die aktuelle Lokal-/UTC-"
"Zeit steht im System-Prompt unter '## Aktuelle Zeit'. Fuer "
"relative Angaben ('in 2 Stunden') lieber `in_seconds`."
),
},
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
@@ -1042,15 +1045,17 @@ META_TOOLS = [
"function": {
"name": "project_summary",
"description": (
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
"halluzinierst Du Inhalte die nicht da sind."
"Schau in einen ANDEREN Chat rein und fass zusammen was dort zuletzt "
"passiert ist (letzte ~12 Turns). Funktioniert fuer jedes Projekt (per "
"Name, Fuzzy-Match) UND fuer den Hauptchat (name='Hauptchat'). Nutze es "
"IMMER wenn Stefan sagt 'hol dir die Infos aus Projekt X', 'schau mal in "
"den Hauptchat/in Projekt Y rein', 'was war zuletzt bei ...', 'hol mich "
"ab' — sonst halluzinierst Du Inhalte die nicht da sind."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match) oder 'Hauptchat' fuer den Hauptthread."},
},
"required": ["name"],
},
@@ -1075,6 +1080,28 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "set_project_kind",
"description": (
"Markiert das AKTUELLE Projekt als Code-Projekt ('code') oder "
"normalen Chat ('chat'). Bei 'code' blendet die App zusaetzlich "
"einen Live-Code-Editor und den QEMU-Desktop (VNC) ein. Rufe es auf, "
"sobald aus einem Gespraech ein Programmier-/Bau-Projekt wird (Du "
"faengst an Code zu schreiben, eine VM zu bauen, etc.). Dein "
"Arbeitsverzeichnis fuer Code-Dateien ist dann /shared/projects/"
"<projekt-id>/ — nur was Du DORT schreibst erscheint im Editor."
),
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["code", "chat"], "description": "'code' oder 'chat'."},
},
"required": ["kind"],
},
},
},
]
@@ -2535,22 +2562,27 @@ class Agent:
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden."
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
# Hauptchat (project_id="") explizit unterstuetzen — damit ARIA auch
# aus einem Projekt heraus in den Hauptthread reinschauen kann.
if pname.lower() in {"hauptchat", "hauptthread", "haupt", "main",
"mainchat", "haupt-chat", "hauptchat-thread"}:
turns = [t for t in self.conversation.turns if not t.project_id]
label, desc = "Hauptchat", "der Hauptthread (kein Projekt)"
else:
p = projects_mod.find_project(pname)
if not p:
return f"Kein Chat/Projekt '{pname}' gefunden (fuer den Hauptthread: name='Hauptchat')."
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
label, desc = p["name"], p.get("description", "(keine Beschreibung)")
if not turns:
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
f"aktuellen Conversation-Window stehen noch keine Turns. "
f"Beschreibung: {p.get('description', '(keine)')}")
return (f"'{label}' hat im aktuellen Conversation-Window noch keine "
f"Turns. {desc}")
tail = turns[-12:]
summary_lines = []
for t in tail:
prefix = "Stefan" if t.role == "user" else "Du"
summary_lines.append(f"{prefix}: {t.content[:280]}")
preamble = (f"Projekt '{p['name']}'{p.get('description', '(keine Beschreibung)')}.\n"
f"Letzte {len(tail)} Turns:\n")
preamble = f"'{label}'{desc}.\nLetzte {len(tail)} Turns:\n"
return preamble + "\n".join(summary_lines)
if name == "project_end":
pname = (arguments.get("name") or "").strip()
@@ -2566,6 +2598,27 @@ class Agent:
"action": "ended",
})
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
if name == "set_project_kind":
kind = (arguments.get("kind") or "").strip().lower()
if kind not in ("code", "chat"):
return "FEHLER: kind muss 'code' oder 'chat' sein."
active_id = projects_mod.get_active()
if not active_id:
return ("Kein aktives Projekt — Du bist im Hauptthread. Erst ein Projekt "
"anlegen/betreten (project_create/project_enter), dann set_project_kind.")
updated = projects_mod.update_project(active_id, {"kind": kind})
if not updated:
return f"FEHLER: aktives Projekt '{active_id}' nicht gefunden."
self._pending_events.append({
"type": "project_changed",
"project": updated,
"action": "kind_changed",
})
if kind == "code":
return (f"OK — Projekt '{updated['name']}' ist jetzt ein Code-Projekt. "
f"Editor + Desktop erscheinen in der App. Code-Dateien unter "
f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.")
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)
+2 -1
View File
@@ -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()
+20 -4
View File
@@ -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,10 +153,12 @@ 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)
+37
View File
@@ -400,6 +400,43 @@ 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": (
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
"(Du schreibst Code, baust ein System, testest eine VM):\n"
"\n"
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
"\n"
"QEMU (VMs fuer JEDE Architektur — x86, ARM, MIPS, PPC, RISC-V, SPARC) "
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
"fuer Win 3.11, aarch64, mips ...).\n"
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
"5901). Nicht selbst nach aussen oeffnen!\n"
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
"Stefan mit [FILE:] schicken).\n"
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
"\n"
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
"wie gewohnt einfach machen, nicht moralisieren."
),
},
{
"migration_key": "seed/architecture/brain-tools-xml-tag",
"type": "rule",
+27 -3
View File
@@ -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 = {
+192
View File
@@ -734,6 +734,11 @@ 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] = {}
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
# 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] = {}
@@ -3351,6 +3356,56 @@ 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 == "config_request":
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
# Config — passiert wenn sie sich connected, weil sie sonst die
@@ -4192,6 +4247,33 @@ 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/flux-generate":
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
# Render-Request via RVS an die flux-bridge (Gamebox),
@@ -4301,6 +4383,116 @@ 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)
async def _delete_chat_message(self, ts: int) -> dict:
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
broadcastet chat_message_deleted via RVS.
+185
View File
@@ -0,0 +1,185 @@
#!/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}")"
[[ -e "${d}/disk.qcow2" ]] && die "VM '${name}' existiert schon"
mkdir -p "${d}"
echo "${arch}" > "${d}/arch"
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
echo "VM '${name}' angelegt (${arch}, ${size})."
}
cmd_boot() {
local name="${1:?name}"; shift || true
local d; d="$(vm_dir "${name}")"
[[ -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="" bootdev="c" display=1 mem=1024 machine=""
while [[ $# -gt 0 ]]; do
case "$1" in
--iso) iso="${2:?}"; bootdev="d"; shift 2 ;;
--disk-boot) bootdev="c"; shift ;;
--vnc-display) display="${2:?}"; shift 2 ;;
--mem) mem="${2:?}"; shift 2 ;;
--machine) machine="${2:?}"; shift 2 ;;
*) die "unbekannte Option: $1" ;;
esac
done
local args=(-name "${name}" -m "${mem}"
-drive "file=${d}/disk.qcow2,format=qcow2"
-vnc "127.0.0.1:${display}"
-monitor "unix:${d}/monitor.sock,server,nowait"
-pidfile "${d}/pid" -daemonize)
# 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}")
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
args+=(-boot "${bootdev}")
"${bin}" "${args[@]}"
echo "VM '${name}' gestartet (${arch}) — VNC 127.0.0.1:${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?)"
mkdir -p "${SHOT_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="${SHOT_DIR}/${name}-${ts}.png"
if command -v convert >/dev/null; then
convert "${ppm}" "${out}" && rm -f "${ppm}"
else
out="${SHOT_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 "$@"
+53
View File
@@ -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"
+49
View File
@@ -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 */ }
+6
View File
@@ -66,6 +66,12 @@ 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",
"check_desktop", "desktop_status",
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
]);
// Token-Raum: token -> { clients: Set<ws> }