Compare commits

...
37 Commits
Author SHA1 Message Date
duffyduck 9950cdea87 release: bump version to 0.2.1.9 2026-07-17 00:35:01 +02:00
duffyduckandClaude Opus 4.8 7329257830 feat(app): Kompakt ↔ Cockpit — Ansichts-Umschalter im Header (0.2.1.9)
Nach dem 0.2.1.8-Deploy sah die App "unveraendert" aus — korrekt, weil der
Hauptchat nur eine Kachel hat (= Vollbild-Chat). Jetzt explizit umschaltbar:
- services/viewMode.ts: 'compact' | 'cockpit', persistiert (aria_view_mode),
  Default 'compact' (nichts aendert sich fuer normale Nutzung).
- ViewModeToggle im Navigations-Header (rechts, kollisionsfrei): "⧉ Kompakt" /
  "⧉ Cockpit".
- WorkspaceScreen: compact → klassische ChatScreen direkt; cockpit → Canvas.
- Canvas: Uebersicht/Gesten/Back jetzt auch bei einer Kachel erreichbar (der
  single-Force-Fokus entfaellt), damit sich Cockpit auch im Hauptchat wie ein
  Desktop anfuehlt. "⤢ Uebersicht"-Button nach unten rechts verschoben (weg von
  ChatScreens Kopf-Icons).

Changelog: Workspace-Release als 0.2.1.8 gefuehrt, Umschalter als 0.2.1.9.
tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:32:33 +02:00
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
duffyduck e61a0ff871 release: bump version to 0.2.1.5 2026-07-12 09:44:34 +02:00
duffyduckandClaude Opus 4.8 c9a1d80696 feat(queue): Diagnostic spiegelt Pro-Kontext-Queue + Rueckfrage + Drafts
Diagnostic bekommt denselben Queue-Automaten wie die App: anstellen statt
abbrechen, awaiting_reply pausiert die Queue (naechste Eingabe beantwortet die
Rueckfrage), Queue-Banner mit loeschbaren Eintraegen ueber dem Eingabefeld,
und Pro-Kontext-Textfeld-Entwuerfe (Feldinhalt bleibt beim Kontextwechsel
erhalten). rvs_chat-Handler liest awaiting_reply aus der Bridge-Payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:41:24 +02:00
duffyduckandClaude Opus 4.8 8a0670a3d2 feat(queue): Pro-Projekt-Nachrichten-Queue mit Rueckfrage-Loop + Textfeld-Entwuerfe (App)
Zweite Nachricht waehrend ARIA arbeitet wird jetzt ANGESTELLT statt den
laufenden Task abzubrechen. Stellt ARIA eine blockierende Rueckfrage, pausiert
die Queue und die naechste Eingabe beantwortet sie — bis eine finale Antwort
kommt, dann laeuft der naechste Queue-Eintrag (pro Projekt unabhaengig).

- Brain: ARIA deklariert Rueckfragen per unsichtbarem [[AWAIT]]-Marker
  (wie speak/converse; kein '?'-Raten). _extract_await_marker strippt ihn,
  chat() gibt 5-Tupel (+awaiting_reply), System-Prompt erklaert den Marker.
- Bridge: awaiting_reply aus Brain-Response in die chat-Broadcast-Payload.
- App: app-lokale Queue + Zustandsautomat (idle/running/awaiting_reply) pro
  Projekt; Send-Flow von Abbruch auf Anstellen; Stop-Button (cancelRequest)
  schaltet die Queue weiter; sichtbare pending_queue-Bubbles (tippen loescht);
  Rueckfrage-/Queue-Banner ueber dem Eingabefeld. Voice bricht nicht mehr ab
  (haltet nur TTS, serialisiert im Brain-Lock); Text-Send erkennt Brain-busy
  als Fallback. Pro-Projekt-Textfeld-Entwuerfe (Draft-Map + AsyncStorage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:35:46 +02:00
duffyduckandClaude Opus 4.8 2d9c42a7ea docs(changelog): 0.2.1.1–0.2.1.4 nachgetragen — der große Tag
Vier Versionen aufgeholt: manifest.speak (0.2.1.1); Standort-Intelligenz +
speak/converse-pro-Aufruf + Anti-Halluzination (0.2.1.2); skill_get + converse-
Fast-Path + Voice-Bubble-Fix (0.2.1.3); tool-loses local + Info-Halluzination-
Guard + leeres-<voice>-Fix + Live-Datei-Anhang + TDZ- & QRScanner-Cleanup und
ARIAs live geschärfter Spotify-/yt-dlp-Skill (0.2.1.4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:13:42 +02:00
duffyduck ff1205eb6b release: bump version to 0.2.1.4 2026-07-12 02:10:13 +02:00
duffyduckandClaude Opus 4.8 d12f67320b fix(app): QRScanner tsc-clean — toter Prop raus, kaputte camera-kit-Typen umgangen
colorForScannerFrame existiert in react-native-camera-kit v13 nicht (No-Op) —
entfernt. Die Lib markiert zudem etliche optionale CameraScreen-Props faelschlich
als required (defaultProps fuellen sie zur Laufzeit); Props lokal als any
gespreadet, um die fehlerhaften .d.ts zu umgehen ohne Runtime-Verhalten zu
aendern. scanBarcode/onReadCode bleibt die korrekte v13-Barcode-API.

Projekt jetzt komplett tsc-clean (0 Fehler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:08:35 +02:00
duffyduckandClaude Opus 4.8 24a1b4d837 fix(app): sendTextMessage nach seine deps verschoben — TDZ-Fehler weg
sendTextMessage stand vor interruptAriaIfBusy und sendPendingAttachments,
hatte beide aber im deps-Array → Temporal Dead Zone (TS2448/2454). Lief nur,
weil Babel const→var hebt (deps auf erstem Render undefined, Body laeuft erst
bei Interaktion). Deklaration hinter beide verschoben — Abhaengigkeit ist
einseitig, sendTextMessage wird nur in der JSX genutzt. tsc-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:05:52 +02:00
duffyduckandClaude Opus 4.8 db96258f7d fix(app): ARIA-Datei-Anhang erscheint live an der Nachricht, nicht erst nach Seitenwechsel
Die Live-chat-Payload trug keine files — Anhaenge kamen nur als separates
file_from_aria-Event (eigene, teils unsichtbare Bubble). An der Text-Nachricht
tauchte die Datei erst nach einem Seitenwechsel auf (Reload aus chat_backup,
das die files kennt).

- Bridge: files jetzt direkt in der chat-Payload (selbe Struktur wie Backup).
- App: chat-Handler haengt payload.files an die Text-Bubble (wie der Reload-Pfad)
  und entfernt die redundante Solo-file_from_aria-Bubble mit passendem serverPath.
- App: file_from_aria legt keine Doppel-Bubble an, wenn die Datei schon an einer
  Nachricht haengt (Event-Reihenfolge-unabhaengig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:01:37 +02:00
duffyduckandClaude Opus 4.8 becc83d9e3 fix(bridge): leeres <voice></voice> macht TTS nicht mehr stumm
Claude haengt manchmal reflexartig ein leeres <voice></voice> an (v.a. bei
Spotify-Antworten). clean_text_for_tts nahm dann group(1)='' → text='' → gar
keine Sprachausgabe. Das erklaerte, warum Playlist-Wechsel 'Fliegen' vorgelesen
wurde (Claude schrieb Inhalt ins Tag), 'Prodigy' aber stumm blieb (leeres Tag)
— reiner Claude-Output-Zufall, nicht Skill/Playlist-Name.

Fix: leeres/whitespace <voice> ignorieren und den normalen Anzeigetext lesen.
Deterministisch, unabhaengig von Claudes Tag-Reflex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:45:43 +02:00
duffyduckandClaude Opus 4.8 44220edbd1 revert(router): Live-Wortliste raus — Modell soll selbst eskalieren
_LIVE_HINTS war die falsche Idee: aus offenem Freitext per Wortliste die
Absicht raten ist nie vollstaendig, jeder Miss = Halo (nur von per-Skill auf
per-Wort verschoben). Generisch heisst nicht 'groessere Regex', sondern: das
Modell entscheidet selbst ob es Grundwahrheit/ein Tool braucht (<<ESCALATE>>,
Prompt). Der 8B kann das noch nicht zuverlaessig → local bleibt per Einstellung
abschaltbar (aus, nicht raus); ein staerkeres lokales Modell uebernimmt spaeter
die Selbst-Erkennung. Absicherung bleibt der Output-Guard, nicht der Input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:20:02 +02:00
duffyduckandClaude Opus 4.8 fc13957000 fix(router): 'lieder'/'songs'/'springe...weiter' matchen jetzt auch (Live-Gate)
'lied' hatte eine Wortgrenze → 'lieder' (lied+er) matchte nicht, 'springe
zwei lieder weiter' rutschte an local vorbei. lied\w*/song\w* + spring\w*
...weiter ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:06:40 +02:00
duffyduckandClaude Opus 4.8 60580fd67e refactor(brain): local wieder tool-los (B1a) — 8B nur noch Reden
B1b hatte dem 8B run_*/web_search-Tools gegeben. Das war die Wurzel von
BEIDEM: (1) ein 8B ist ein unzuverlaessiger Tool-Caller und erfindet lieber
eine plausible Antwort ('der Song ist X') statt das Tool zu rufen → halo;
(2) das zwang zu per-Skill-Guards (skaliert nicht).

Fix ohne eine einzige per-Skill-Zeile:
- Local: tools=[] — kann kein Skill-Ergebnis mehr faelschen.
- Router: _LIVE_HINTS schickt alles mit aktueller Grundwahrheit
  (Wetter/News/Uhrzeit/Musik/Preise/Timer/…) VORAB an Claude, nicht erst
  nach einem gescheiterten Local-Versuch (kein Doppel-Zahlen). Topic-Ebene,
  nicht per-Skill — ein neuer Skill braucht hier keine Zeile.
- Prompt: _AWARENESS sagt local explizit, dass es tool-los ist und fuer
  Live/Aktion eskaliert.

Arbeitsteilung: Fast-Path (Regex→Skill, deterministisch, 0 halo) fuer
Befehle · Local (tool-los) fuers Reden · Claude fuer alles mit Tool/Fakt/
Gedaechtnis/Urteil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:59:31 +02:00
duffyduckandClaude Opus 4.8 2d83e4ad8b fix(brain): Info-Halluzination-Guard + expliziter Claude-Wunsch im Router
Local erfand Live-Auskuenfte (aktueller Song, Restzeit, Skip-Titel) ohne
run_spotify zu rufen. Neuer Detektor _claims_live_media_state() + Guard
eskaliert solche Behauptungen ohne echten Skill-Call an Claude. Local-Prompt
gehaertet: nie Titel/Interpret/Restzeit ohne Tool-Ergebnis nennen.

Router: _FORCE_CLAUDE respektiert 'nimm Claude/Clodi' → nie lokal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:52:36 +02:00
duffyduck 629a3d82ac release: bump version to 0.2.1.3 2026-07-11 23:18:27 +02:00
duffyduckandClaude Opus 4.8 9bb90a777a fix(app): Sprachnachricht-Bubble verschwindet nach manuellem Stop nicht mehr
Ohne Ohr (manuelle Aufnahme) + Stop-Button: das echte STT-Endpoint (mit Text)
fuellt die Bubble, aber danach klappt ein zweites, LEERES stream_end-Endpoint
nach (Audio schon transkribiert). Der Empty-Handler loeschte die Bubble
bedingungslos per audioRequestId — auch die schon mit Text gefuellte.
Ergebnis: Bubble weg, obwohl ARIA an der Antwort arbeitet; erst nach
App-Neustart (aus chat_backup) wieder da.

Fix: leeres Endpoint entfernt nur noch den NOCH UNAUFGELOESTEN Platzhalter
(Text enthaelt 'Spracheingabe wird verarbeitet'), nicht eine bereits
aufgeloeste Bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:17:04 +02:00
duffyduckandClaude Opus 4.8 c3bb7985a2 fix(skills): skill_get-Tool nachgereicht — kein Blind-Rewrite mehr
Skandal-Ursache: die skill_update-Anleitung sagt "erst skill_get lesen, dann
updaten" — aber skill_get existierte GAR NICHT als Tool. ARIA/Claude konnte
einen Skill also nie LESEN vor dem Aendern → Blind-Rewrite aus Beschreibung +
Gedaechtnis. (Claude sagte korrekt "skill_get steht mir nicht zur Verfuegung"
und hat den Spotify-Skill trotzdem komplett neu geschrieben.)

Neu: skill_get(name) liefert Manifest (args/fast_patterns/speak/converse) +
kompletten entry_code (echter Python) + README. skills.read_skill_source().
So sieht ARIA den Ist-Code und aendert gezielt statt blind zu ueberschreiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:04:37 +02:00
duffyduckandClaude Opus 4.8 debd96b16f fix(fast-path): converse folgt dem Skill (Manifest/Output), nicht hart False
Fast-Path hatte converse hart auf False — ein Fast-Path-Skill konnte damit nie
ein Dialog-Skill sein. Jetzt konsistent zu local/Claude: speak UND converse
kommen aus dem Skill-Manifest-Default und werden vom Skill-JSON-Output pro
Aufruf ueberschrieben. Default (kein Flag) bleibt false/false = stumm/stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:57:52 +02:00
42 changed files with 3008 additions and 149 deletions
+119
View File
@@ -10,6 +10,125 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
---
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
### Hinzugefügt
- **Ansichts-Umschalter im Header** („⧉ Kompakt" / „⧉ Cockpit"): Die App startet in **Kompakt** — der klassische Vollbild-Chat, **exakt wie vor dem Umbau** (Default, Mama-tauglich). Ein Tap auf den Button oben rechts schaltet auf **Cockpit** — den zoom-/verschiebbaren Kachel-Desktop. Persistiert über Neustart (`aria_view_mode`).
- **Warum:** Nach dem 0.2.1.8-Deploy sah die App „unverändert" aus — korrekt, denn im normalen Chat gibt es nur eine Kachel (= Vollbild-Chat). Der Umschalter macht den Cockpit-Modus jetzt **explizit sichtbar/steuerbar**, statt nur bei Code-Projekten aufzutauchen.
- Im Cockpit ist die **Übersicht jetzt immer erreichbar** (auch im Hauptchat mit nur einer Kachel): „⤢ Übersicht"-Button (unten rechts, weg von den Chat-Kopf-Icons), 2-Finger-Pinch/Pan, Hardware-Back.
---
## [0.2.1.8] — 2026-07-17 — Desktop-Workspace: zoombarer Canvas, Live-Code-Editor, QEMU/VNC
### Hinzugefügt
**Zoom-/verschiebbarer Workspace-Canvas (App)**
- Die App ist jetzt eine desktop-artige Arbeitsfläche: rausgezoomt sieht man eine **Landkarte aus Kacheln** (Chat, Editor, Desktop, Vorschau), die man mit **2 Fingern zoomt und verschiebt**. Tippt man eine Kachel an, zoomt sie voll auf und wird **echt bedienbar** („Übersicht + Fokus"). „⤢ Übersicht" bzw. der Hardware-Back führen zurück zur Landkarte.
- Technisch: `react-native-gesture-handler` + `react-native-reanimated` (60 fps auf dem UI-Thread). Zwei Ebenen — eine skalierte Thumbnail-Welt und eine **Identity-Content-Ebene** (Scale 1), in der die schweren Inhalte (ChatScreen + WebViews) immer gemountet sind und nur die fokussierte sichtbar ist. Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet beim Fokuswechsel. **Reiner Chat verhält sich exakt wie bisher** (eine Kachel, dauerhaft fokussiert).
**Live-Code-Editor für Code-Projekte (App + Bridge + Proxy)**
- Wird ein Projekt zum Code-Projekt (ARIA ruft `set_project_kind('code')`), erscheinen Editor- und Desktop-Kachel. Der Editor (WebView, selbstenthaltener Highlight-Editor, offline) **zeigt live, was ARIA schreibt** — und Stefan kann selbst editieren; Änderungen gehen zurück an ARIA.
- Fluss: ARIAs `Write`/`Edit` unter `/shared/projects/<projekt-id>/` werden im Proxy abgefangen und als `code_file` über die Bridge/RVS an die App gespiegelt; Stefans Edits kommen als `code_file_edit` pfad-sicher zurück ins selbe Verzeichnis.
**QEMU für alle Architekturen + Live-Desktop per VNC (Host + Bridge + App)**
- ARIA kann jetzt VMs für **jede Architektur** bauen/testen (x86, ARM, MIPS, PPC, RISC-V, SPARC) — Host-Helper `aria-vm` (`create/boot/screenshot/list/stop`), installiert via `host-provisioning/qemu-setup.sh`. KVM für x86-Gäste, sonst TCG. Beispiel: ein Win-3.11-System bauen und in QEMU testen.
- Der **VNC-Live-Desktop wird durch den RVS-Server getunnelt**: die Bridge brückt rohes RFB-TCP (QEMU `127.0.0.1:5901`) ↔ RVS (`vnc_data`/`vnc_input`, Base64-in-JSON), der noVNC-Client läuft in der App-WebView (`window.WebSocket`-Shim). Stefan bedient die VM **live mit Maus/Tastatur** in der Desktop-Kachel — NAT-sicher, kein offener Port am Host, kein websockify/noVNC auf dem Host nötig.
**Kleineres**
- Pro-Projekt-Layout: die zuletzt fokussierte Kachel wird pro Projekt gemerkt (`aria_workspace_layout`).
- Projekt-Modell bekommt `kind` ('chat'|'code'); Seed-Regel lehrt ARIA den Code-Projekt-Workflow (Arbeitsverzeichnis `/shared/projects/<id>/`, `aria-vm`, VNC landet automatisch in der App).
### Deploy
`git pull && docker compose up -d --build brain bridge proxy` · RVS-Stack `up -d --build` · Host: `bash host-provisioning/qemu-setup.sh` (einmalig, als root) · APK neu bauen (nach `npm install` einmalig `npm start --reset-cache` + `gradlew clean`, wegen der neuen nativen Module).
---
## [0.2.1.5] — 2026-07-12 — Pro-Projekt-Queue mit Rückfrage-Loop
### Hinzugefügt
**Nachrichten-Queue pro Projekt (App + Diagnostic)**
- Eine zweite Nachricht, während ARIA am aktuellen Task arbeitet, wird jetzt **angestellt** statt den laufenden Task abzubrechen (vorher: Barge-In-Cancel). Sie läuft der Reihe nach, **pro Projekt unabhängig** (paralleles Arbeiten in mehreren Projekten bleibt). Wartende Nachrichten zeigen als ⏸-Bubble — tippen entfernt sie aus der Warteschlange.
- **Rückfrage-Loop:** Stellt ARIA eine echte, blockierende Rückfrage, **pausiert** die Queue und deine nächste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann läuft der nächste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". Der Stop-Button bricht den aktuellen Task ab und schaltet zum nächsten.
- ARIA signalisiert eine Rückfrage über einen **unsichtbaren `[[AWAIT]]`-Marker** — wie speak/converse deklariert das Modell den Zustand selbst (kein „endet-mit-?"-Raten). Brain strippt ihn, gibt `awaiting_reply` durch `chat()``ChatOut` → Bridge-Chat-Payload. Local (tool-los) und Fast-Path markieren nie.
**Pro-Projekt-Textfeld-Entwürfe (App + Diagnostic)**
- Der Feldinhalt bleibt beim Projektwechsel erhalten: in Projekt X tippen, zu Y wechseln (leeres Feld), zurück zu X → dein Entwurf steht wieder da. In Storage persistiert.
**TTS-Abspiel-Queue (App)**
- Zwei fast gleichzeitig fertige Antworten sprechen jetzt garantiert **nacheinander** statt sich gegenseitig abzuschneiden. Vorher war das Timing-Glück (`PcmStreamPlayer.start()` ruft `stopInternal()` = flush/release, hätte die laufende gecuttet). Jetzt: „spielt hörbar" gilt bis zum echten `PcmPlaybackFinished` (nicht nur bis Stream-Ende); eine neue hörbare Antwort, die währenddessen ankommt, wird gepuffert und danach nachgespielt (Kette für 3, 4, …). Harter Stop/Barge-In/Mund-Button verwirft die Queue.
### Geändert
- **Voice bricht nicht mehr ab:** eine neue Sprachnachricht während ARIA arbeitet stoppt nur akustisch das TTS (sauberes Mikro) und wird über den Brain-Projekt-Lock serialisiert, statt den laufenden Task abzubrechen (passend zu „immer anstellen + Stop-Button"). Text-Senden erkennt Brain-busy als Fallback, damit auch nach einem voice-gestarteten Turn korrekt angestellt wird. Grenze: eine per Sprache gestartete Aufgabe erscheint nicht als löschbare ⏸-Bubble (Aufnahme wird live gestreamt, nicht app-seitig gepuffert).
---
## [0.2.1.4] — 2026-07-12 — Lokales LLM: der ehrliche Rückbau
### Geändert
**Lokales LLM wieder tool-los (B1a) — ein 8B ist ein schlechter Tool-Caller**
- B1b hatte dem lokalen Modell Werkzeuge (`run_*`/`web_search`) gegeben — die gemeinsame Wurzel von **zwei** Problemen: (1) ein 8B erfindet mit Werkzeug in der Hand lieber eine plausible Antwort („der Song ist X") statt es zu rufen → Halluzination; (2) das erzwang per-Skill-Guards (skaliert nicht). Local ist jetzt wieder **tool-los** = reines Reden; alles mit Grundwahrheit (Fakt/Live-Zustand/Gedächtnis/Aktion) gehört an Claude oder den deterministischen Fast-Path. Kein Skill-Ergebnis mehr fälschbar
- **Keine Input-Wortliste im Router:** eine kurz eingeführte `_LIVE_HINTS`-Blacklist (Wetter/Musik/… → Claude) wieder entfernt — aus offenem Freitext die Absicht per Wortliste zu raten ist nie vollständig, jeder Miss = ein Halo (nur von per-Skill auf per-Wort verschoben). Generisch = das Modell entscheidet **selbst** (`<<ESCALATE>>`); ein stärkeres lokales Modell übernimmt die Selbst-Erkennung später, bis dahin ist local per Einstellung abschaltbar (aus, nicht raus)
- Expliziter Nutzer-Wunsch „nimm Claude/Clodi" wird im Router respektiert (geht nie lokal)
### Behoben
- **Info-Halluzination:** local nannte manchmal aktuellen Song/Restzeit/Skip-Titel ohne `run_spotify` zu rufen (mal echt, mal frei erfunden — „Midnight City von M83" nie aufgerufen). Neuer Output-Guard `_claims_live_media_state` eskaliert behauptete Live-Auskünfte ohne echten Skill-Call an Claude; Local-Prompt zusätzlich gehärtet (nie Titel/Zeit/Gerät ohne Tool-Ergebnis; bei „OK: next" keinen Titel erfinden)
- **Leeres `<voice></voice>` machte TTS stumm:** Claude hängt reflexartig manchmal ein leeres Voice-Tag an → `clean_text_for_tts` nahm den leeren Inhalt → gar keine Sprachausgabe (Playlist „Fliegen" gesprochen, „Prodigy" stumm — reiner Claude-Output-Zufall, nicht Skill/Playlist-Name). Leeres/whitespace-Tag wird jetzt ignoriert, der normale Anzeigetext gelesen
- **Datei-Anhang erschien erst nach Seitenwechsel:** die Live-`chat`-Payload trug keine `files` (Anhänge kamen nur als separates `file_from_aria`-Event) → an der Nachricht tauchte die Datei erst nach Reload aus `chat_backup` auf. Bridge schickt die `files` jetzt in der chat-Payload, App hängt sie live an die Text-Bubble (wie der Reload-Pfad) und entfernt die redundante Solo-Bubble
- **TDZ-Zeitbombe (App):** `sendTextMessage` stand vor seinen Dependencies (`interruptAriaIfBusy`, `sendPendingAttachments`) im deps-Array — Temporal Dead Zone; lief nur dank Babels `const``var`-Hebung, ein strengerer Bundler hätte beim Mount weißgescreent. Deklaration hinter die Deps verschoben
- **QRScanner tsc-clean:** toter Prop `colorForScannerFrame` (existiert in `react-native-camera-kit` v13 nicht) entfernt; die fehlerhaften Lib-Typen (optionale Props als required markiert) lokal + dokumentiert umgangen → **Projekt komplett tsc-clean (0 Fehler)**
**Spotify-Skill — von ARIA live im Gespräch weiter geschärft**
- Skip (`next`/`previous`) sagt jetzt den **echten** neuen Titel an (holt den Track nach dem Skip via API) statt local einen erfinden zu lassen
- `playlist_play`/`search_and_play`/`play` nennen das **tatsächliche** Wiedergabegerät (aus `GET /v1/me/player`, kein Raten — verhinderte den Fehler, dass Claude ein falsch geratenes Gerät auch noch ansteuerte) und lesen konsistent vor; saubere „Sprach-Grammatik": Ansagen sprechen, Steuerbefehle (play/pause/transfer/volume) schweigen
- `yt-dlp-download`-Skill um einen MP3-Modus erweitert — ARIA hat das fehlende Werkzeug **selbst gebaut**, als eine deutsche Titelmelodie nicht auf Spotify lag (Web-Suche → YouTube-Download → MP3 in den Chat, in <1 min)
---
## [0.2.1.3] — 2026-07-11
### Hinzugefügt
- **`skill_get`-Tool:** ARIA liest den echten Quellcode + Manifest + Readme eines Skills, **bevor** sie ihn ändert — kein Blind-Rewrite mehr (vorher wurde ein guter Skill durch eine schlechtere Neufassung ersetzt, weil das referenzierte `skill_get` gar nicht existierte)
### Behoben
- **`converse` folgt dem Skill (Fast-Path):** auch ein Fast-Path-Befehl kann einen Skill auslösen, nach dem noch etwas zu sagen ist — `converse` kommt jetzt aus Manifest/Skill-Output statt hart auf `False`
- **Sprachnachricht-Bubble verschwand nach manuellem Stop:** bei „ohne Ohr" aufgenommener Sprachnachricht + Stop entfernte ein leeres stream-end-Endpoint die schon gefüllte Bubble; jetzt wird nur noch der unaufgelöste Platzhalter entfernt
---
## [0.2.1.2] — 2026-07-11 — Standort-Intelligenz + Skill steuert seine Ausgabe
### Hinzugefügt
**GPS → Ortsname im Standort-Präfix (keyless, keine Tokens)**
- Reverse-Geocoding der Koordinaten in der Bridge (Nominatim zoom=18: Straße+Hausnr, PLZ+Ort, Bundesland; Straßen-Ref + Autobahn-km via Overpass) — damit das lokale Modell nicht „Berlin" für Oldenburg rät; Ortsname wird **vor** dem Präfix-Bau awaited (rechtzeitig für die erste Nachricht)
- Fahrtrichtung als Himmelsrichtung **+ exakte Peilung in Grad** (Haversine/Bearing aus aufeinanderfolgenden Fixes, `MIN_MOVE_M`-Schwelle gegen Zittern)
**Skill steuert seine Ausgabe selbst — `speak` + `converse` pro Aufruf**
- Ein Skill entscheidet per JSON-Output `{speak, converse}` pro Operation, ob vorgelesen wird und ob danach 30 s weitergelauscht wird (Manifest-Default, Output überschreibt) — z. B. „was läuft" vorlesen aber kein Dialog, „nächstes Lied" stumm. In der Skill-Bauanleitung **mit dem WARUM** dokumentiert, damit die KI die Flags beim Bauen versteht (kein Hardcode im Brain)
### Behoben / Geändert
- **Generischer Skill-Prompt (kein Hardcode):** der Router beschreibt `run_*`-Skills generisch (weiß nicht mehr, welche „schwer" sind); ein Stop im 30-s-Lauschen beendet dieses jetzt wirklich (kein zweiter Gong / erneutes Öffnen)
- **Anti-Halluzination:** behauptet local eine Steuerbefehl-Quittung („Spotify: …", „Playlist abspielen") ohne das Tool wirklich zu rufen → Eskalation an Claude statt erfundene Bestätigung durchzulassen
---
## [0.2.1.1] — 2026-07-11
### Hinzugefügt
- **Skill entscheidet selbst, ob vorgelesen wird (`manifest.speak`):** Grundlage der späteren `speak`/`converse`-Architektur — der `speak`-Flag greift sowohl im lokalen als auch im Claude-Pfad, statt am fragilen leeren `<voice></voice>`-Hack zu hängen
---
## [0.2.0.6] — 2026-07-11
### Hinzugefügt
+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
+10 -4
View File
@@ -8,11 +8,13 @@
import React, { useEffect } from 'react';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen';
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ViewModeToggle from './src/components/ViewModeToggle';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
@@ -132,7 +134,7 @@ const App: React.FC = () => {
}, []);
return (
<>
<GestureHandlerRootView style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
<NavigationContainer theme={DarkTheme}>
<Tab.Navigator
@@ -165,10 +167,11 @@ const App: React.FC = () => {
>
<Tab.Screen
name="Chat"
component={ChatScreen}
component={WorkspaceScreen}
options={{
title: 'ARIA Chat',
headerTitle: 'ARIA Cockpit',
headerRight: () => <ViewModeToggle />,
}}
/>
<Tab.Screen
@@ -180,13 +183,16 @@ const App: React.FC = () => {
/>
</Tab.Navigator>
</NavigationContainer>
</>
</GestureHandlerRootView>
);
};
// --- Styles ---
const styles = StyleSheet.create({
root: {
flex: 1,
},
header: {
backgroundColor: '#12122A',
elevation: 0,
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20102
versionName "0.2.1.2"
versionCode 20109
versionName "0.2.1.9"
// 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.2",
"version": "0.2.1.9",
"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",
+13 -6
View File
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
<View style={styles.container}>
{hasPermission ? (
<>
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
CameraScreen-Props faelschlich als required (defaultProps fuellen
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
zu umgehen, ohne echten Code zu veraendern. */}
<CameraScreen
scanBarcode={true}
onReadCode={handleBarcodeScan}
showFrame={true}
frameColor="#0096FF"
laserColor="#0096FF"
colorForScannerFrame="#0096FF"
{...({
scanBarcode: true,
onReadCode: handleBarcodeScan,
showFrame: true,
frameColor: '#0096FF',
laserColor: '#0096FF',
} as any)}
/>
{/* Overlay oben */}
+47
View File
@@ -0,0 +1,47 @@
/**
* ViewModeToggle — kleiner Header-Button zum Umschalten zwischen Kompakt-
* Ansicht (klassischer Chat) und Cockpit (Kachel-Desktop).
*
* Sitzt rechts im Navigations-Header ("ARIA Cockpit"), kollidiert also mit
* nichts in der Chat-Ansicht. Zeigt das Ziel des naechsten Taps.
*/
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
import viewMode, { ViewModeValue } from '../services/viewMode';
const ViewModeToggle: React.FC = () => {
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
useEffect(() => viewMode.subscribe(setMode), []);
const isCockpit = mode === 'cockpit';
return (
<TouchableOpacity
onPress={() => viewMode.toggle()}
style={[styles.pill, isCockpit && styles.pillActive]}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.75}
>
<Text style={[styles.text, isCockpit && styles.textActive]}>
{isCockpit ? '⧉ Cockpit' : '⧉ Kompakt'}
</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
pill: {
marginRight: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: '#1E1E2E',
backgroundColor: '#12122A',
},
pillActive: { borderColor: '#0096FF', backgroundColor: '#0A1F33' },
text: { color: '#9090B0', fontSize: 13, fontWeight: '700' },
textActive: { color: '#0096FF' },
});
export default ViewModeToggle;
+327 -82
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 {
@@ -64,6 +65,14 @@ interface Attachment {
deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager)
}
// Pro-Projekt-Queue-Zustand: idle = nichts laeuft; running = ARIA arbeitet am
// aktuellen Task; awaiting_reply = ARIA hat eine blockierende Rueckfrage gestellt,
// die naechste Eingabe beantwortet sie (statt einen neuen Auftrag anzustellen).
type CtxState = 'idle' | 'running' | 'awaiting_reply';
// Wartender Queue-Eintrag (id = die Bubble-ID der pending_queue-Nachricht).
interface QueuedItem { id: string; text: string; }
interface ChatMessage {
id: string;
sender: 'user' | 'aria';
@@ -131,7 +140,7 @@ interface ChatMessage {
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
deliveryStatus?: 'queued' | 'pending_queue' | 'sending' | 'sent' | 'delivered' | 'failed';
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
sendAttempts?: number;
}
@@ -275,6 +284,7 @@ async function checkFileExists(uri: string): Promise<boolean> {
const ChatScreen: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const inputTextRef = useRef('');
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [showFileUpload, setShowFileUpload] = useState(false);
const [showCameraUpload, setShowCameraUpload] = useState(false);
@@ -301,6 +311,27 @@ const ChatScreen: React.FC = () => {
// Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
// Busy-Status des fokussierten Kontexts ohne stale Closure).
const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({});
// ── Pro-Projekt-Nachrichten-Queue + Zustandsautomat (app-lokal) ──
// Key = projectId ('' = Hauptchat). state pro Kontext + Queue der wartenden
// User-Bubbles. Sendet man waehrend 'running', wird angestellt statt
// abgebrochen; 'awaiting_reply' leitet die naechste Eingabe als Antwort weiter.
const [projectStates, setProjectStates] = useState<Record<string, CtxState>>({});
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>('');
// Ref-Bruecke, damit der frueh deklarierte RVS-Message-Handler den erst spaeter
// deklarierten Queue-Automaten aufrufen kann (ohne TDZ / stale Closure).
const queueApiRef = useRef<{
getCtxState: (pid: string) => CtxState;
setCtxState: (pid: string, s: CtxState) => void;
advanceQueue: (pid: string) => void;
} | null>(null);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
@@ -495,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(() => {});
};
@@ -509,14 +545,45 @@ const ChatScreen: React.FC = () => {
return () => unsub();
}, []);
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
// fuer den Auto-Fall angenehm.
// Focus in Storage spiegeln + Pro-Projekt-Textfeld-Entwuerfe wechseln.
useEffect(() => {
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
focusedProjectIdRef.current = focusedProjectId;
// Draft-Wechsel: der aktuelle Feldinhalt gehoert dem VERLASSENEN Projekt,
// der Entwurf des neuen Projekts wird geladen (leer = leeres Feld).
const prevPid = prevFocusedPidRef.current;
if (prevPid !== focusedProjectId) {
projectDraftsRef.current = { ...projectDraftsRef.current, [prevPid]: inputTextRef.current };
setInputText(projectDraftsRef.current[focusedProjectId] || '');
prevFocusedPidRef.current = focusedProjectId;
AsyncStorage.setItem('aria_project_drafts', JSON.stringify(projectDraftsRef.current)).catch(() => {});
}
}, [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]);
// Drafts beim Start aus Storage laden.
useEffect(() => {
AsyncStorage.getItem('aria_project_drafts').then(v => {
if (!v) return;
try {
const map = JSON.parse(v);
if (map && typeof map === 'object') {
projectDraftsRef.current = map;
const cur = map[focusedProjectIdRef.current] || '';
if (cur) setInputText(cur);
}
} catch {}
}).catch(() => {});
}, []);
// Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
// die sich bei jedem Wechsel).
@@ -531,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();
@@ -866,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
@@ -899,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') {
@@ -994,6 +1087,7 @@ const ChatScreen: React.FC = () => {
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
if (message.type === 'file_from_aria') {
const p = message.payload || {};
const sp = (p.serverPath as string) || '';
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
@@ -1004,10 +1098,20 @@ const ChatScreen: React.FC = () => {
name: (p.name as string) || 'datei',
size: (p.size as number) || 0,
mimeType: (p.mimeType as string) || '',
serverPath: (p.serverPath as string) || '',
serverPath: sp,
}],
};
setMessages(prev => capMessages([...prev, ariaMsg]));
setMessages(prev => {
// Falls die Chat-Bubble mit dieser Datei schon da ist (Event-Reihen-
// folge kann variieren, und die chat-Payload traegt die files jetzt
// selbst), keine doppelte Solo-Bubble anlegen.
if (sp && prev.some(m =>
m.sender === 'aria' && m.attachments?.some(a => a.serverPath === sp)
)) {
return prev;
}
return capMessages([...prev, ariaMsg]);
});
return;
}
@@ -1156,26 +1260,53 @@ const ChatScreen: React.FC = () => {
const text = (message.payload.text as string) || '';
const ts = message.timestamp;
// ARIA-Dateien, die DIREKT an dieser Chat-Bubble haengen (Bridge schickt
// sie jetzt in der chat-Payload mit — vorher kamen sie nur als separates
// file_from_aria-Event, wodurch der Anhang live nicht an der Nachricht
// erschien, sondern erst nach einem Seitenwechsel/Reload).
const incomingFiles = Array.isArray((message.payload as any).files)
? (message.payload as any).files as Array<any> : [];
const fileAttachments: Attachment[] = incomingFiles.map(f => ({
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
name: f.name || 'datei',
size: f.size || 0,
mimeType: f.mimeType || '',
serverPath: f.serverPath || '',
}));
const incomingPaths = new Set(
fileAttachments.map(a => a.serverPath).filter(Boolean) as string[]
);
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
setMessages(prev => {
const isDuplicate = prev.some(m =>
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
);
if (isDuplicate) return prev;
const payloadAtts = (message.payload.attachments as Attachment[] | undefined) || [];
const mergedAtts = [...payloadAtts, ...fileAttachments];
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text,
timestamp: ts,
attachments: message.payload.attachments as Attachment[] | undefined,
attachments: mergedAtts.length ? mergedAtts : undefined,
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '',
};
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
const deduped = incomingPaths.size
? prev.filter(m => !(
m.sender === 'aria' && !m.text && (m.attachments?.length) &&
m.attachments.every(a => a.serverPath && incomingPaths.has(a.serverPath))
))
: prev;
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...prev, ariaMsg]).map(m =>
return capMessages([...deduped, ariaMsg]).map(m =>
m.sender === 'user'
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
? { ...m, deliveryStatus: 'delivered' }
@@ -1198,6 +1329,18 @@ const ChatScreen: React.FC = () => {
: prev);
setAgentActivity(cur =>
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
// ── Pro-Projekt-Queue-Automat: auf ARIAs Antwort reagieren ──
const awaiting = !!(message.payload as any).awaiting_reply;
const qapi = queueApiRef.current;
if (qapi) {
const st = qapi.getCtxState(ansPid);
// Nur reagieren, wenn wir fuer diesen Kontext wirklich auf eine
// Antwort warten (nicht bei unaufgeforderten Trigger-Nachrichten).
if (st === 'running' || st === 'awaiting_reply') {
if (awaiting) qapi.setCtxState(ansPid, 'awaiting_reply');
else qapi.advanceQueue(ansPid);
}
}
}
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
@@ -1537,9 +1680,15 @@ const ChatScreen: React.FC = () => {
} else {
// Kein Speech im Window → Konversation beenden
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
// Placeholder-Bubble wieder weg
// NUR den noch UNAUFGELOESTEN Platzhalter entfernen. Nach manuellem Stop
// klappt oft ein zweites, LEERES stream_end-Endpoint nach (das Audio war
// schon vom echten Endpoint transkribiert) — das darf die bereits mit
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
setMessages(prev => prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
@@ -1623,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();
@@ -1631,6 +1787,7 @@ const ChatScreen: React.FC = () => {
unsubPassive();
unsubTtsStart();
unsubTtsEnd();
unsubPcmCached();
};
}, [wakeWordActive]);
@@ -1928,6 +2085,68 @@ const ChatScreen: React.FC = () => {
// --- Nachricht senden ---
// ── Pro-Projekt-Queue: Zustands-Helfer ──────────────────────────
const getCtxState = useCallback((pid: string): CtxState =>
projectStatesRef.current[pid] || 'idle', []);
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 }));
}, []);
const setCtxQueue = useCallback((pid: string, items: QueuedItem[]) => {
projectQueuesRef.current = { ...projectQueuesRef.current, [pid]: items };
setProjectQueues(prev => ({ ...prev, [pid]: items }));
}, []);
// Sendet eine Nachricht WIRKLICH (Location holen + dispatchWithAck) und setzt
// den Kontext auf 'running'. existingId gesetzt = eine bereits als pending_queue
// angezeigte Bubble wird auf 'sending' gehoben (Dequeue), sonst neue Bubble.
const actuallySend = useCallback(async (pid: string, text: string, existingId?: string) => {
const cmid = nextClientMsgId();
const location = await getCurrentLocation();
const status: ChatMessage['deliveryStatus'] =
connectionStateRef.current === 'connected' ? 'sending' : 'queued';
if (existingId) {
setMessages(prev => prev.map(m =>
m.id === existingId ? { ...m, clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1 } : m));
} else {
setMessages(prev => capMessages([...prev, {
id: nextId(), sender: 'user', text, timestamp: Date.now(),
clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1, projectId: pid,
}]));
}
setCtxState(pid, 'running');
dispatchWithAck(cmid, 'chat', {
text, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current,
projectId: pid, ...(location && { location }),
});
}, [getCurrentLocation, dispatchWithAck, setCtxState]);
// Naechsten Queue-Eintrag von pid abarbeiten (falls vorhanden), sonst idle.
const advanceQueue = useCallback((pid: string) => {
const q = projectQueuesRef.current[pid] || [];
if (q.length === 0) { setCtxState(pid, 'idle'); return; }
const [next, ...rest] = q;
setCtxQueue(pid, rest);
actuallySend(pid, next.text, next.id);
}, [actuallySend, setCtxQueue, setCtxState]);
// Einen wartenden Eintrag aus der Queue entfernen (User tippt auf ✕).
const removeQueued = useCallback((pid: string, id: string) => {
setCtxQueue(pid, (projectQueuesRef.current[pid] || []).filter(it => it.id !== id));
setMessages(prev => prev.filter(m => m.id !== id));
}, [setCtxQueue]);
// Ref-Bruecke fuellen, damit der frueh deklarierte Message-Handler den Automaten
// erreicht (ohne TDZ / stale Closure).
useEffect(() => {
queueApiRef.current = { getCtxState, setCtxState, advanceQueue };
}, [getCtxState, setCtxState, advanceQueue]);
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
@@ -1951,49 +2170,6 @@ const ChatScreen: React.FC = () => {
}, timeoutMs);
}, []);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
const cancelRequest = useCallback(() => {
const pid = focusedProjectIdRef.current || '';
@@ -2001,34 +2177,22 @@ const ChatScreen: React.FC = () => {
setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
clearStuckWatchdog();
rvs.send('cancel_request' as any, { projectId: pid });
}, []);
// Aktuellen Task abgebrochen → naechsten Queue-Eintrag dieses Projekts
// starten (oder idle, wenn leer). Wartende Eintraege einzeln loeschbar.
advanceQueue(pid);
}, [advanceQueue]);
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
// mach lieber X" sagen wie in einem echten Gespraech.
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
const interruptAriaIfBusy = useCallback(() => {
const speaking = audioService.isPlayingAudio();
// Multi-Threading: NUR den fokussierten Kontext als "busy" werten — nicht
// global. Sonst bricht eine Nachricht im Hauptchat die parallele Arbeit in
// einem Projekt ab (bzw. wird faelschlich als Barge-In behandelt und die
// eigene Anfrage geht unter). Der Busy-Status kommt kontextgenau aus
// /projects/queue-status (queueStatusRef). agentActivity ist global und
// taugt dafuer nicht.
const pid = focusedProjectIdRef.current || '';
const focusKey = pid || '__main__';
const focusBusy = !!queueStatusRef.current?.[focusKey]?.busy;
if (!speaking && !focusBusy) return false;
console.log('[Chat] Barge-In: speaking=%s focusBusy=%s (ctx=%s) — interrupting',
speaking, focusBusy, focusKey);
// TTS immer stoppen wenn ARIA gerade spricht — egal welcher Kontext.
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
// Brain-Arbeit nur abbrechen wenn GENAU dieser Kontext arbeitet.
if (focusBusy) {
clearStuckWatchdog();
rvs.send('cancel_request' as any, { projectId: pid });
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
}
return true;
return false;
}, []);
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
@@ -2200,6 +2364,53 @@ const ChatScreen: React.FC = () => {
setInputText('');
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
const activePid = focusedProjectIdRef.current;
setInputText('');
// Draft dieses Projekts leeren (wurde ja gerade abgeschickt/angestellt).
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
const focusKey = activePid || '__main__';
const brainBusy = !!queueStatusRef.current?.[focusKey]?.busy;
let state = getCtxState(activePid);
// Fallback: ein per SPRACHE gestarteter Turn streamt live und setzt den
// App-State nicht — aber der Brain meldet busy. Dann Kontext als 'running'
// behandeln (anstellen; die spaetere Antwort schaltet die Queue weiter).
if (state === 'idle' && brainBusy) { setCtxState(activePid, 'running'); state = 'running'; }
if (state === 'running') {
// ARIA arbeitet noch am aktuellen Task → ANSTELLEN statt abbrechen.
// Sichtbare pending_queue-Bubble; laeuft der Reihe nach, wenn der
// aktuelle Task (und ggf. seine Rueckfragen) fertig ist.
const id = nextId();
setMessages(prev => capMessages([...prev, {
id, sender: 'user', text, timestamp: Date.now(),
projectId: activePid, deliveryStatus: 'pending_queue',
}]));
setCtxQueue(activePid, [...(projectQueuesRef.current[activePid] || []), { id, text }]);
console.log('[Chat] angestellt (queue) project=%s len=%d', activePid || '(main)',
(projectQueuesRef.current[activePid] || []).length);
} else {
// idle ODER awaiting_reply → sofort senden. Bei awaiting_reply ist DAS die
// Antwort auf ARIAs Rueckfrage (normaler Turn in diesem Projekt).
console.log('[Chat] sende sofort (state=%s) project=%s', state, activePid || '(main)');
actuallySend(activePid, text);
}
}, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]);
// --- Rendering ---
const renderMessage = ({ item }: { item: ChatMessage }) => {
@@ -2498,7 +2709,16 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity>
) : null}
{isUser && item.deliveryStatus ? (
item.deliveryStatus === 'failed' && item.clientMsgId ? (
item.deliveryStatus === 'pending_queue' ? (
// Wartet in der Projekt-Queue → tippen entfernt den Eintrag.
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => removeQueued(item.projectId || '', item.id)}
accessibilityLabel="Aus Warteschlange entfernen"
>
<Text style={styles.statusQueued}>{'⏸ wartet · ✕'}</Text>
</TouchableOpacity>
) : item.deliveryStatus === 'failed' && item.clientMsgId ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)}
@@ -2921,6 +3141,18 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity>
)}
{/* Rueckfrage-Banner / Queue-Zaehler fuer den fokussierten Kontext */}
{(projectStates[focusedProjectId] === 'awaiting_reply' ||
(projectQueues[focusedProjectId]?.length || 0) > 0) && (
<View style={styles.queueBanner}>
<Text style={styles.queueBannerText}>
{projectStates[focusedProjectId] === 'awaiting_reply'
? '❓ ARIA fragt nach — deine Eingabe beantwortet das'
: `${projectQueues[focusedProjectId]?.length || 0} in der Warteschlange`}
</Text>
</View>
)}
{/* Eingabebereich */}
<View style={styles.inputContainer}>
{/* Datei-Buttons */}
@@ -3418,6 +3650,19 @@ const styles = StyleSheet.create({
fontSize: 14,
marginTop: 4,
},
queueBanner: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#2A2410',
borderTopWidth: 1,
borderTopColor: '#4A3F14',
},
queueBannerText: {
color: '#FFD60A',
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
+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;
+57
View File
@@ -0,0 +1,57 @@
/**
* viewMode App-Ansicht: 'compact' (klassischer Vollbild-Chat wie vor 0.2.2.0)
* oder 'cockpit' (zoombarer Kachel-Desktop).
*
* Default 'compact' fuer normale Nutzung aendert sich nichts (Mama-tauglich).
* Umschaltbar ueber den Header-Button; persistiert in AsyncStorage. Muster wie
* services/rvs.ts (Singleton mit Listener-Liste).
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export type ViewModeValue = 'compact' | 'cockpit';
const KEY = 'aria_view_mode';
type Sub = (mode: ViewModeValue) => void;
class ViewMode {
private mode: ViewModeValue = 'compact';
private subs: Sub[] = [];
private loaded = false;
constructor() {
AsyncStorage.getItem(KEY).then((v) => {
if (v === 'cockpit' || v === 'compact') this.mode = v;
this.loaded = true;
this.emit();
}).catch(() => { this.loaded = true; });
}
get(): ViewModeValue { return this.mode; }
isLoaded(): boolean { return this.loaded; }
set(mode: ViewModeValue): void {
if (this.mode === mode) return;
this.mode = mode;
AsyncStorage.setItem(KEY, mode).catch(() => {});
this.emit();
}
toggle(): void {
this.set(this.mode === 'compact' ? 'cockpit' : 'compact');
}
subscribe(cb: Sub): () => void {
this.subs.push(cb);
cb(this.mode);
return () => { this.subs = this.subs.filter((s) => s !== cb); };
}
private emit(): void {
const m = this.mode;
this.subs.forEach((cb) => cb(m));
}
}
const viewMode = new ViewMode();
export default viewMode;
+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;
+241
View File
@@ -0,0 +1,241 @@
/**
* 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 { 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);
// Verschwindet die fokussierte Kachel aus der Sichtbarkeit → auf Chat zurueck.
useEffect(() => {
if (focusedTileId && !visibleTiles.includes(focusedTileId)) {
setFocusedTileId('chat');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleKey]);
// Zuletzt fokussierte Kachel pro Projekt wiederherstellen.
useEffect(() => {
if (!layoutLoaded) 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 → zurueck zur Uebersicht.
useEffect(() => {
const onBack = () => {
if (focusedTileId) {
setFocusedTileId(null);
return true;
}
return false;
};
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
return () => sub.remove();
}, [focusedTileId]);
// Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel).
const gesturesEnabled = !focusedTileId;
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 && (
<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 },
// Unten rechts (ueber der Chat-Eingabezeile) — weg von ChatScreens Kopf-Icons.
overviewBtn: {
position: 'absolute',
bottom: 88,
right: 14,
backgroundColor: 'rgba(18,18,42,0.92)',
borderColor: '#0096FF',
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 14,
paddingVertical: 8,
elevation: 4,
},
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;
+69
View File
@@ -0,0 +1,69 @@
/**
* 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 viewMode, { ViewModeValue } from '../services/viewMode';
import ChatScreen from '../screens/ChatScreen';
import { TileId, visibleTilesFor } from './layout';
import WorkspaceCanvas from './WorkspaceCanvas';
const WorkspaceScreen: React.FC = () => {
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
const [hasCode, setHasCode] = useState(false);
const [hasDesktop, setHasDesktop] = useState(false);
useEffect(() => viewMode.subscribe(setMode), []);
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],
);
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
if (mode === 'compact') {
return <ChatScreen />;
}
// Cockpit: zoombarer Kachel-Desktop.
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 };
}
+225 -28
View File
@@ -284,6 +284,27 @@ META_TOOLS = [
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "skill_get",
"description": (
"Liest einen EXISTIERENDEN Skill VOLLSTAENDIG: Manifest (inkl. "
"args, fast_patterns, speak/converse) + kompletter entry_code "
"(der echte Python-Code) + README. IMMER vor `skill_update` "
"aufrufen, damit du den bestehenden Code SIEHST und ihn gezielt "
"aenderst statt blind zu ueberschreiben (Blind-Rewrite killt "
"leicht funktionierende Teile!)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
@@ -519,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"},
@@ -1021,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"],
},
@@ -1054,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"],
},
},
},
]
@@ -1110,6 +1158,55 @@ def _looks_like_skill_action(text: str) -> bool:
return False
def _claims_live_media_state(text: str) -> bool:
"""Erkennt eine BEHAUPTETE Live-Zustands-Auskunft, die NUR ein Werkzeug
wissen kann: aktueller Song/Interpret, Restzeit, was gerade laeuft, welches
Geraet spielt. local erfindet das gern (mal 'Midnight City von M83', mal
'The House Of House') ohne run_spotify zu rufen. Ohne echten Skill-Aufruf ist
das reine Halluzination eskalieren (Claude ruft das Tool zuverlaessig).
Bewusst auf Medien-/Wiedergabe-Zustand begrenzt, damit normale Musik-
Konversation ('ich mag M83') NICHT getroffen wird es muss nach einer
konkreten Ist-Zustands-Auskunft klingen."""
t = (text or "").strip().lower()
if not t:
return False
# Restzeit/Abspielposition ('noch 52 Sekunden', 'Restzeit 2:34').
if "restzeit" in t or "restlaufzeit" in t:
return True
if re.search(r"\bnoch\s+\d+\s*(sekunde|minute|sek|min)", t):
return True
# 'aktueller Song/Titel/Lied heisst/ist …' / 'es laeuft gerade …' /
# 'spielt gerade …' / 'jetzt laeuft …' — Ist-Zustands-Auskunft.
if re.search(r"(aktuell\w*\s+(song|titel|lied|stueck|track))", t):
return True
if re.search(r"(l(ae|ä)uft|spielt)\s+(gerade|jetzt|aktuell|zurzeit|grade)", t):
return True
if re.search(r"(gerade|jetzt|zurzeit|grade)\s+(l(ae|ä)uft|spielt)", t):
return True
# Skip-Quittung, die einen konkreten Folgetitel behauptet ('… ist jetzt „X"').
if ("ueberspring" in t or "übersprung" in t or "uebersprung" in t) and (
"song" in t or "titel" in t or "lied" in t):
return True
return False
_AWAIT_MARKER_RE = re.compile(r"\[\[\s*AWAIT(?:_REPLY)?\s*\]\]", re.IGNORECASE)
def _extract_await_marker(text: str) -> tuple:
"""Erkennt ARIAs Rueckfrage-Marker `[[AWAIT]]` — den sie ans Ende haengt,
wenn ihre Antwort eine echte Rueckfrage ist, auf die sie eine Nutzer-Antwort
BRAUCHT, bevor der aktuelle Task fertig ist. Entfernt den Marker (nicht
anzeigen/vorlesen/in History) und meldet, ob er da war. Wie speak/converse:
ARIA deklariert den Zustand selbst kein '?'-Raten."""
if not text:
return text, False
if _AWAIT_MARKER_RE.search(text):
return _AWAIT_MARKER_RE.sub("", text).strip(), True
return text, False
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -1225,8 +1322,12 @@ class Agent:
continue
args = pat.get("args") or {}
reply = pat.get("reply") or f"{skill_name}: ok"
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl).
# Vorlesen/Weiterlauschen folgen dem Skill — GENAU wie bei local/
# Claude: Manifest-Default, vom Skill-Output pro Aufruf ueber-
# schreibbar. Also NICHT generell converse=False: ein Fast-Path-
# Skill darf ein Dialog-Skill sein.
self._fast_path_speak = bool(skill.get("speak", False))
self._fast_path_converse = bool(skill.get("converse", False))
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
skill_name, rx, user_message[:60])
try:
@@ -1239,6 +1340,16 @@ class Agent:
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
# Per-Aufruf-Override aus dem Skill-JSON-Output (falls vorhanden).
try:
_j = json.loads((res.get("stdout") or "").strip())
if isinstance(_j, dict):
if isinstance(_j.get("speak"), bool):
self._fast_path_speak = _j["speak"]
if isinstance(_j.get("converse"), bool):
self._fast_path_converse = _j["converse"]
except Exception:
pass
return reply
return None
@@ -1350,7 +1461,17 @@ class Agent:
self._local_ran_skill = False
local_only = bool(cfg.get("localOnly"))
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
tools = self._build_local_tools() # B1b: kuratierte Tools
# B1a-Rueckbau (bewusst): local ist TOOL-LOS — nur reines Reden. Ein 8B
# ist ein unzuverlaessiger Tool-Caller: mit Werkzeug in der Hand erfindet
# er lieber eine plausible Antwort ('der Song ist X'), statt das Tool zu
# rufen — das war die Halo-Quelle UND zwang zu per-Skill-Guards. Ohne
# Tools KANN er kein Skill-Ergebnis faelschen. Alles mit Grundwahrheit
# (Fakt/Live-Zustand/Gedaechtnis/Aktion) gehoert an Claude oder an den
# deterministischen Fast-Path — nicht an den 8B. WIE es dorthin kommt:
# das Modell eskaliert SELBST (<<ESCALATE>>, siehe Prompt) — bewusst KEINE
# Input-Wortliste im Router. Der Output-Guard unten faengt ab, wenn der
# 8B doch mal statt zu eskalieren einen Live-Zustand behauptet.
tools = []
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools))
@@ -1451,8 +1572,9 @@ class Agent:
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
if not self._local_ran_skill and _looks_like_skill_action(final):
logger.info("[router] lokal: Skill-Aktion behauptet ohne Tool-Call → Claude")
if not self._local_ran_skill and (
_looks_like_skill_action(final) or _claims_live_media_state(final)):
logger.info("[router] lokal: Aktion/Live-Zustand behauptet ohne Tool-Call → Claude")
return None
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
@@ -1507,8 +1629,10 @@ class Agent:
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
speak = bool(getattr(self, "_fast_path_speak", False))
# Fast-Path = reiner Steuerbefehl → nie ins 30s-Gespraech (converse=False).
return fast_reply, "fast-path", speak, False
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source,
@@ -1526,7 +1650,8 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
return local_reply, "local", speak, converse
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -1573,6 +1698,23 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
# (statt als neuen Auftrag). Wie speak/converse: ARIA deklariert selbst.
system_prompt += (
"\n\n## RUECKFRAGE-SIGNAL [[AWAIT]]\n"
"Wenn deine Antwort eine echte RUECKFRAGE ist, auf die du Stefans "
"Antwort BRAUCHST, um den aktuellen Task abzuschliessen (z.B. 'Welche "
"der drei Playlists meinst du?', 'Soll ich X oder Y nehmen?'), haenge "
"als ALLERLETZTES exakt `[[AWAIT]]` an. Der Marker wird entfernt (nicht "
"angezeigt, nicht vorgelesen) und sagt der App: warte auf Stefans "
"Antwort, bevor der naechste Task der Warteschlange laeuft.\n"
"NUR bei echten, blockierenden Rueckfragen — NICHT bei rhetorischen "
"Fragen, unverbindlichen Vorschlaegen ('soll ich noch...?', die auch "
"ohne Antwort ok sind) oder wenn du den Task einfach fertig hast. Im "
"Zweifel: KEIN Marker."
)
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
@@ -1737,13 +1879,19 @@ class Agent:
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
"sag mir einfach, was du brauchst.")
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
final_reply, awaiting_reply = _extract_await_marker(final_reply)
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)))
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -1817,6 +1965,29 @@ class Agent:
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
for s in items
)
if name == "skill_get":
sk_name = (arguments.get("name") or "").strip()
if not sk_name:
return "FEHLER: name ist Pflicht."
src = skills_mod.read_skill_source(sk_name)
if src is None:
return f"FEHLER: Skill '{sk_name}' nicht gefunden."
m = src["manifest"]
# Manifest kompakt + kompletter Code, damit ARIA gezielt aendern kann.
meta = {
"name": m.get("name"), "description": m.get("description"),
"execution": m.get("execution"), "entry": m.get("entry"),
"active": m.get("active"), "args": m.get("args"),
"requires": m.get("requires"),
"config_schema": m.get("config_schema"),
"fast_patterns": m.get("fast_patterns"),
"speak": m.get("speak"), "converse": m.get("converse"),
}
return (
f"MANIFEST:\n{json.dumps(meta, ensure_ascii=False, indent=2)}\n\n"
f"ENTRY-CODE ({src['entry']}):\n{src['entry_code']}\n\n"
f"README:\n{src.get('readme') or '(leer)'}"
)
if name == "skill_update":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
@@ -2391,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()
@@ -2422,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)
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try:
agent = agent_factory()
reply, _, _, _ = agent.chat(prompt, source="trigger")
reply, _, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+6 -1
View File
@@ -639,6 +639,10 @@ class ChatOut(BaseModel):
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
converse: bool = True
# Stellt ARIA eine blockierende Rueckfrage (braucht Stefans Antwort, bevor der
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -722,7 +726,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply, answered_by, speak, converse = await loop.run_in_executor(
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -747,6 +751,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
answered_by=answered_by,
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
)
finally:
_project_pending[pid] = [
+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)
+42 -3
View File
@@ -75,6 +75,16 @@ _TOOL_HINTS = re.compile(
re.IGNORECASE,
)
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
_HARD_HINTS = re.compile(
r"```|" # Codeblock
@@ -86,6 +96,18 @@ _HARD_HINTS = re.compile(
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
_FORCE_CLAUDE = re.compile(
r"\b(clodi|clody)\b"
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
re.IGNORECASE,
)
def _strip_leading_hint_blocks(text: str) -> str:
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
@@ -108,6 +130,10 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
msg = _strip_leading_hint_blocks(user_message)
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
if _FORCE_CLAUDE.search(msg):
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
return False
if _TOOL_HINTS.search(msg):
return False
if _HARD_HINTS.search(msg):
@@ -122,10 +148,16 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
# kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
# Aktion braucht, gehoert ans grosse Modell.
_AWARENESS = (
"Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
"Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
"sowie tiefe/technische Analysen und langen Code."
"Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
"nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
"News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
"und langen Code. Fuer ALL das eskalierst du."
)
@@ -166,6 +198,13 @@ def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
]
parts += [
"",
+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",
+20
View File
@@ -139,6 +139,26 @@ def read_manifest(name: str) -> Optional[dict]:
return None
def read_skill_source(name: str) -> Optional[dict]:
"""Manifest + kompletter entry_code + README eines Skills. Damit ARIA einen
Skill LESEN kann bevor sie ihn per skill_update aendert (sonst Blind-Rewrite,
der bestehende Funktionen killt)."""
m = read_manifest(name)
if m is None:
return None
d = _skill_dir(name)
entry = m.get("entry", "run.sh")
try:
code = (d / entry).read_text(encoding="utf-8")
except Exception as exc:
code = f"(entry-Datei '{entry}' nicht lesbar: {exc})"
try:
readme = (d / "README.md").read_text(encoding="utf-8")
except Exception:
readme = ""
return {"manifest": m, "entry": entry, "entry_code": code, "readme": readme}
def write_manifest(name: str, manifest: dict) -> None:
d = _skill_dir(name)
d.mkdir(parents=True, exist_ok=True)
+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 = {
+217 -3
View File
@@ -295,10 +295,17 @@ def clean_text_for_tts(text: str) -> str:
if not text:
return ""
# <voice>...</voice> wenn vorhanden → nur das nehmen
# <voice>...</voice> wenn vorhanden → nur diesen Inhalt lesen. ABER: ein
# LEERES <voice></voice> darf die Sprachausgabe nicht verstummen lassen.
# Claude haengt reflexartig manchmal ein leeres Tag an (v.a. bei Spotify-
# Antworten) — frueher wurde daraus text="" → gar keine TTS (Playlist-Wechsel
# 'Prodigy' stumm, 'Fliegen' gesprochen, rein je nachdem ob Claude Inhalt ins
# Tag schrieb). Leeres/whitespace-Tag → ignorieren und den normalen Text lesen.
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
if voice_match:
if voice_match and voice_match.group(1).strip():
text = voice_match.group(1)
else:
text = _re_tts.sub(r'<voice>[\s\S]*?</voice>', ' ', text, flags=_re_tts.IGNORECASE)
t = text
@@ -727,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] = {}
@@ -1553,6 +1565,13 @@ class ARIABridge:
"text": display_text,
"sender": "aria",
"messageId": message_id,
# ARIA-Dateien DIREKT an der Chat-Bubble mitschicken — sonst kommt
# der Anhang live nur als separates file_from_aria-Event (eigene
# Bubble) und taucht an der Nachricht erst nach einem Seiten-
# wechsel auf (Reload aus chat_backup, das die files kennt). Selbe
# Struktur wie im Backup, damit App live == Reload rendert.
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
"backupTs": assistant_backup_ts,
@@ -1571,6 +1590,10 @@ class ARIABridge:
# Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs-
# Fenster in der App (Skill/Einzelaktion=False, Konversation=True).
"converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True,
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -1934,6 +1957,9 @@ class ARIABridge:
# Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs
# Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus.
converse = data.get("converse", True)
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
awaiting_reply = bool(data.get("awaiting_reply", False))
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen
@@ -2003,7 +2029,8 @@ class ARIABridge:
await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by,
"speak": speak,
"converse": converse})
"converse": converse,
"awaiting_reply": awaiting_reply})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id)
@@ -3329,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
@@ -4170,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),
@@ -4279,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.
+89 -7
View File
@@ -320,6 +320,7 @@
</div>
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
</div>
<div id="diag-queue-banner" style="display:none;padding:6px 10px;background:#2A2410;border:1px solid #4A3F14;border-radius:6px;margin-bottom:6px;"></div>
<div class="input-row">
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
&#x1F4CE;
@@ -1425,6 +1426,12 @@
let diagQueueStatus = {};
let diagProjectsCache = [];
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
// ── Pro-Kontext-Queue + Zustandsautomat (spiegelt die App) ──
const diagCtxStates = {}; // pid → 'idle' | 'running' | 'awaiting_reply'
const diagCtxQueues = {}; // pid → [{id,text}]
const diagDrafts = JSON.parse(localStorage.getItem('diag_ctx_drafts') || '{}');
let diagQid = 0;
const diagState = (pid) => diagCtxStates[pid] || 'idle';
function updateChatVisibilityByFocus() {
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
@@ -1438,10 +1445,22 @@
}
function switchDiagFocus(id) {
focusedContextId = id || '';
const nextId = id || '';
// Pro-Kontext-Textfeld-Entwuerfe: Feldinhalt dem verlassenen Kontext
// zuordnen, Entwurf des neuen laden.
if (nextId !== focusedContextId) {
const input = document.getElementById('chat-input');
if (input) {
diagDrafts[focusedContextId] = input.value;
input.value = diagDrafts[nextId] || '';
}
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
}
focusedContextId = nextId;
localStorage.setItem('diag_focused_context_id', focusedContextId);
updateChatVisibilityByFocus();
renderContextStrip();
renderDiagQueue();
}
function renderContextStrip() {
@@ -1983,6 +2002,15 @@
projectId: p.projectId || '',
answeredBy: p.answeredBy || '',
});
// ── Pro-Kontext-Queue-Automat: auf ARIAs Antwort reagieren ──
if (sender === 'aria') {
const apid = p.projectId || '';
const st = diagState(apid);
if (st === 'running' || st === 'awaiting_reply') {
if (p.awaiting_reply) { diagCtxStates[apid] = 'awaiting_reply'; renderDiagQueue(); }
else advanceDiagQueue(apid);
}
}
return;
}
if (msg.type === 'chat_message_deleted') {
@@ -2179,16 +2207,64 @@
renderDiagPending();
}
// ── Pro-Kontext-Queue: Helfer (spiegelt die App) ──
function actuallySendDiag(pid, text) {
addChat('sent', text, 'via RVS', { projectId: pid });
send({ action: 'test_rvs', text, projectId: pid });
diagCtxStates[pid] = 'running';
renderDiagQueue();
}
function advanceDiagQueue(pid) {
const q = diagCtxQueues[pid] || [];
if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; }
const next = q.shift();
actuallySendDiag(pid, next.text);
}
function enqueueOrSendDiag(pid, text) {
if (diagState(pid) === 'running') {
(diagCtxQueues[pid] = diagCtxQueues[pid] || []).push({ id: 'q' + (++diagQid), text });
renderDiagQueue();
} else {
// idle ODER awaiting_reply → sofort (bei awaiting = Antwort auf Rueckfrage).
actuallySendDiag(pid, text);
}
}
function removeDiagQueuedFocused(id) {
const pid = focusedContextId;
diagCtxQueues[pid] = (diagCtxQueues[pid] || []).filter(it => it.id !== id);
renderDiagQueue();
}
function renderDiagQueue() {
const el = document.getElementById('diag-queue-banner');
if (!el) return;
const st = diagState(focusedContextId);
const q = diagCtxQueues[focusedContextId] || [];
if (st !== 'awaiting_reply' && q.length === 0) { el.style.display = 'none'; el.innerHTML = ''; return; }
el.style.display = 'block';
let html = '';
if (st === 'awaiting_reply') {
html += '<div style="color:#FFD60A;font-weight:600;margin-bottom:4px;">&#x2753; ARIA fragt nach &mdash; deine Eingabe beantwortet das</div>';
}
if (q.length) {
html += q.map(it =>
'<span style="display:inline-block;background:#1E1E2E;border:1px solid #4A3F14;border-radius:10px;padding:2px 8px;margin:2px;font-size:12px;color:#FFD60A;">&#x23F8; ' +
escapeHtml(it.text.slice(0, 40)) +
' <a href="#" style="color:#FF6B6B;text-decoration:none;" onclick="removeDiagQueuedFocused(\'' + it.id + '\');return false;">&#x2715;</a></span>'
).join('');
}
el.innerHTML = html;
}
function testRVS() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text && diagPendingFiles.length === 0) return;
if (diagPendingFiles.length > 0) sendDiagAttachments();
if (text) {
// Multi-Threading: mit fokussierter Kontext-ID senden.
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
send({ action: 'test_rvs', text, projectId: focusedContextId });
// Draft dieses Kontexts leeren + Queue-Automat entscheidet senden/anstellen.
diagDrafts[focusedContextId] = '';
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
enqueueOrSendDiag(focusedContextId, text);
}
input.value = '';
}
@@ -2675,8 +2751,8 @@
const input = document.getElementById('chat-input-fs');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
// Ueber denselben Queue-Automaten wie der Haupt-Chat (fokussierter Kontext).
enqueueOrSendDiag(focusedContextId, text);
input.value = '';
}
// Escape schliesst Vollbild-Chat
@@ -3650,6 +3726,12 @@
}
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
// Entwurf des zuletzt fokussierten Kontexts ins Feld laden + Queue-Banner init.
(function initDiagDraft() {
const input = document.getElementById('chat-input');
if (input && diagDrafts[focusedContextId]) input.value = diagDrafts[focusedContextId];
renderDiagQueue();
})();
// Escape schliesst Lightbox
document.addEventListener('keydown', (e) => {
+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> }